From 614fef41899bdc92004faad0b7cadba6c806e104 Mon Sep 17 00:00:00 2001 From: Ken Sanislo Date: Wed, 24 Jun 2026 00:33:18 +0000 Subject: [PATCH] G515 LS TKL and G502 X PLUS support, via Logitech HID++ 2.0 controller with feature discovery --- .../LogitechControllerDetect.cpp | 237 +- .../LogitechHIDPP20Controller.cpp | 5709 +++++++++++++++++ .../LogitechHIDPP20Controller.h | 874 +++ .../LogitechHIDPP20Controller_Linux.cpp | 433 ++ ...ogitechHIDPP20Controller_Windows_MacOS.cpp | 302 + .../LogitechHIDPP20IdleSettings.cpp | 146 + .../LogitechHIDPP20IdleSettings.h | 47 + .../RGBController_LogitechHIDPP20.cpp | 1796 ++++++ .../RGBController_LogitechHIDPP20.h | 72 + StringUtils.cpp | 29 +- StringUtils.h | 2 + 11 files changed, 9638 insertions(+), 9 deletions(-) create mode 100644 Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller.cpp create mode 100644 Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller.h create mode 100644 Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller_Linux.cpp create mode 100644 Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller_Windows_MacOS.cpp create mode 100644 Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20IdleSettings.cpp create mode 100644 Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20IdleSettings.h create mode 100644 Controllers/LogitechController/LogitechHIDPP20Controller/RGBController_LogitechHIDPP20.cpp create mode 100644 Controllers/LogitechController/LogitechHIDPP20Controller/RGBController_LogitechHIDPP20.h diff --git a/Controllers/LogitechController/LogitechControllerDetect.cpp b/Controllers/LogitechController/LogitechControllerDetect.cpp index 000bed028..5ce923433 100644 --- a/Controllers/LogitechController/LogitechControllerDetect.cpp +++ b/Controllers/LogitechController/LogitechControllerDetect.cpp @@ -10,6 +10,7 @@ #include #include #include "Detector.h" +#include "ResourceManager.h" #include "LogManager.h" #include "LogitechProtocolCommon.h" #include "LogitechG203LController.h" @@ -40,6 +41,8 @@ #include "RGBController_LogitechLightspeed.h" #include "RGBController_LogitechGPowerPlay.h" // Linux-only #include "RGBController_LogitechX56.h" +#include "LogitechHIDPP20Controller.h" +#include "RGBController_LogitechHIDPP20.h" using namespace std::chrono_literals; @@ -84,6 +87,9 @@ using namespace std::chrono_literals; #define LOGITECH_G502_HERO_PID 0xC08B #define LOGITECH_G502_LIGHTSPEED_PID 0xC08D #define LOGITECH_G502_X_PLUS_PID 0xC095 +#define LOGITECH_G515_LS_TKL_PID 0xC355 +#define LOGITECH_G522_LIGHTSPEED_USB_PID 0x0B19 +#define LOGITECH_G522_LIGHTSPEED_DONGLE_PID 0x0B18 #define LOGITECH_G600_PID 0xC24A #define LOGITECH_G703_LIGHTSPEED_PID 0xC087 #define LOGITECH_G703_HERO_LIGHTSPEED_PID 0xC090 @@ -135,6 +141,8 @@ using namespace std::chrono_literals; #define LOGITECH_G903_LIGHTSPEED_VIRTUAL_HERO_PID 0x4087 #define LOGITECH_G_PRO_WIRELESS_VIRTUAL_PID 0x4079 #define LOGITECH_POWERPLAY_MAT_VIRTUAL_PID 0x405F +#define LOGITECH_G502_X_PLUS_LIGHTSPEED_VIRTUAL_PID 0x4099 +#define LOGITECH_G515_LS_TKL_LIGHTSPEED_VIRTUAL_PID 0x40B4 /*-----------------------------------------------------*\ | Joystick product IDs | @@ -650,6 +658,220 @@ void DetectLogitechX56(hid_device_info* info, const std::string& name) } } +/*------------------------------------------------------------------------------*\ +| Unified HID++ 2.0 Detection | +| Probes IRoot (feature 0x0000) to determine if the device speaks HID++ 2.0. | +| If it does and has RGB features, the unified controller handles it. | +| If not, the device is released for legacy controllers. | +\*------------------------------------------------------------------------------*/ +void DetectLogitechHIDPP20(hid_device_info* info, const std::string& /*name*/) +{ + hid_device* dev = hid_open_path(info->path); + + if(!dev) + { + return; + } + + LogitechHIDPP20Controller* controller = new LogitechHIDPP20Controller( + dev, info->path, LOGITECH_DEFAULT_DEVICE_INDEX, false, nullptr, + info->usage_page); + + if(controller->Probe()) + { + controller->Initialize(); + + const HIDPP20DeviceCapabilities& caps = controller->GetCapabilities(); + + if(caps.has_zone_effects || caps.has_perkey) + { + /*-------------------------------------------------*\ + | Device has RGB features — create and register | + | RGBController for the UI. | + \*-------------------------------------------------*/ + RGBController_LogitechHIDPP20* rgb_controller = new RGBController_LogitechHIDPP20(controller); + + LOG_INFO("[%s] Registering RGB controller", caps.device_name.c_str()); + + ResourceManager::get()->RegisterRGBController(rgb_controller); + + /*--------------------------------------------------*\ + | Start reader + power threads immediately so we | + | detect connection events and handle power mgmt | + | from the start — not deferred to DeviceUpdateMode. | + \*--------------------------------------------------*/ + if(caps.has_power_mgmt || caps.idx_wireless_status != 0) + { + controller->StartPowerManager(); + + if(!caps.has_power_mgmt && caps.idx_wireless_status != 0) + { + controller->StartEventWatcher(); + } + } + } + else if(controller->HasBridge()) + { + /*--------------------------------------------------*\ + | Centurion dongle with no sub-device — keep the | + | controller alive and start reader thread to watch | + | for sub-device connection events. | + \*--------------------------------------------------*/ + LOG_INFO("[%s] Dongle registered, watching for sub-device", + caps.device_name.c_str()); + + controller->SetRegisterCallback([](RGBController* rgb) + { + ResourceManager::get()->RegisterRGBController(rgb); + }); + + controller->StartEventWatcher(); + } + else + { + /*--------------------------------------------------*\ + | Device probed successfully but has no RGB and no | + | bridge — nothing to do (e.g., headset without RGB) | + \*--------------------------------------------------*/ + LOG_INFO("[%s] No RGB features, skipping", caps.device_name.c_str()); + delete controller; + } + } + else + { + /*--------------------------------------------------*\ + | Probe failed. Could be an offline paired device, | + | a stale pairing slot, or a receiver itself. | + | | + | Only skip if the name explicitly says "Receiver". | + | Everything else gets a watcher — devices can come | + | back at any time (power cycle, dongle swap, etc.) | + \*--------------------------------------------------*/ + std::string hid_name; + + if(info->product_string) + { + std::wstring ws(info->product_string); + hid_name = std::string(ws.begin(), ws.end()); + } + + if(hid_name.find("Receiver") != std::string::npos || + hid_name.find("receiver") != std::string::npos) + { + delete controller; + } + else + { + LOG_INFO("[HID++2.0 %s] Probe failed — watching for device (name='%s')", + info->path, hid_name.c_str()); + + controller->SetRegisterCallback([](RGBController* rgb) + { + ResourceManager::get()->RegisterRGBController(rgb); + }); + + controller->StartProbeWatcher(); + } + } +} + +#if defined(_WIN32) || defined(__APPLE__) +/*-------------------------------------------------------------------------------------------------------------------------------------------------*\ +| Unified HID++ 2.0 Lightspeed Receiver Detection (Windows / macOS) | +| | +| On Linux, hid-logitech-dj splits receiver traffic into per-slot virtual child hidraw nodes with their own 0x40XX PIDs, so Linux detection can | +| use DetectLogitechHIDPP20 directly against the virtual PIDs. Windows and macOS have no DJ driver — the receiver appears as a single HID device | +| and we must probe each paired slot by hand, addressing it via the HID++ device_index header byte. | +| | +| Iterates device indices 0x01..0x06. c547 is dual-pair, but the loop covers Unifying-style receivers and any future wider-pair variants. Each | +| responding slot gets its own hid_device handle and a shared std::mutex so sibling slots serialize HID writes — matching the pattern in the | +| legacy LogitechLightspeedController (see CreateLogitechLightspeedDevice around line 860). | +| | +| TODO (untested on Windows): runtime reader-thread coordination. Each slot controller starts its own reader thread; on a shared receiver both | +| threads will see both slots' incoming packets. The reader needs to drop frames whose device_index doesn't match its own, or dispatch across | +| sibling controllers. Safe during probe (mutex serializes writes, reads are direct); becomes an issue post-StartPowerManager. | +\*-------------------------------------------------------------------------------------------------------------------------------------------------*/ +void DetectLogitechHIDPP20LightspeedReceiver(hid_device_info* info, const std::string& /*name*/) +{ + std::shared_ptr receiver_mutex = std::make_shared(); + + for(uint8_t idx = 0x01; idx <= 0x06; idx++) + { + hid_device* dev = hid_open_path(info->path); + + if(!dev) + { + continue; + } + + LogitechHIDPP20Controller* controller = new LogitechHIDPP20Controller( + dev, info->path, idx, true, receiver_mutex, info->usage_page); + + if(!controller->Probe()) + { + /*--------------------------------------------------*\ + | Slot is empty, stale, or not HID++ 2.0. Destructor | + | closes the per-slot handle we opened above. | + \*--------------------------------------------------*/ + delete controller; + continue; + } + + controller->Initialize(); + + const HIDPP20DeviceCapabilities& caps = controller->GetCapabilities(); + + if(caps.has_zone_effects || caps.has_perkey) + { + RGBController_LogitechHIDPP20* rgb_controller = new RGBController_LogitechHIDPP20(controller); + + LOG_INFO("[%s slot=%u] Registering RGB controller", caps.device_name.c_str(), idx); + + ResourceManager::get()->RegisterRGBController(rgb_controller); + + if(caps.has_power_mgmt || caps.idx_wireless_status != 0) + { + controller->StartPowerManager(); + + if(!caps.has_power_mgmt && caps.idx_wireless_status != 0) + { + controller->StartEventWatcher(); + } + } + } + else + { + LOG_INFO("[%s slot=%u] No RGB features, skipping", caps.device_name.c_str(), idx); + delete controller; + } + } +} +#endif + +/*-------------------------------------------------------------------------------------------------------------------------------------------------*\ +| Unified HID++ 2.0 Devices | +| PID-specific registrations for devices tested with the unified controller. | +| Wired paths use the device's own USB PID; wireless paths on Linux use the hid-logitech-dj virtual child PIDs (0x40XX range). | +\*-------------------------------------------------------------------------------------------------------------------------------------------------*/ +REGISTER_HID_DETECTOR_IPU("Logitech HID++ 2.0 G502 X Plus (wired)", DetectLogitechHIDPP20, LOGITECH_VID, LOGITECH_G502_X_PLUS_PID, 2, 0xFF00, 2); +REGISTER_HID_DETECTOR_IPU("Logitech HID++ 2.0 G515 LS TKL (wired)", DetectLogitechHIDPP20, LOGITECH_VID, LOGITECH_G515_LS_TKL_PID, 2, 0xFF00, 2); +#ifdef __linux__ +REGISTER_HID_DETECTOR_IPU("Logitech HID++ 2.0 G502 X Plus (wireless)", DetectLogitechHIDPP20, LOGITECH_VID, LOGITECH_G502_X_PLUS_LIGHTSPEED_VIRTUAL_PID, 2, 0xFF00, 2); +REGISTER_HID_DETECTOR_IPU("Logitech HID++ 2.0 G515 LS TKL (wireless)", DetectLogitechHIDPP20, LOGITECH_VID, LOGITECH_G515_LS_TKL_LIGHTSPEED_VIRTUAL_PID, 2, 0xFF00, 2); +#endif +#if defined(_WIN32) || defined(__APPLE__) +REGISTER_HID_DETECTOR_IPU("Logitech HID++ 2.0 Lightspeed Receiver (C547)", DetectLogitechHIDPP20LightspeedReceiver, LOGITECH_VID, 0xC547, 2, 0xFF00, 2); +#endif + +/*-------------------------------------------------------------------------------------------------------------------------------------------------*\ +| Centurion-transport devices (63-byte reports on usage page 0xFFA0, 0x50 addressed or 0x51 direct). | +| Centurion receivers are not DJ-style Lightspeed receivers and are not split by hid-logitech-dj — the dongle PID enumerates as a single hidraw | +| on all platforms. The controller's DiscoverTransport parses the report descriptor to pick the 0x50/0x51 variant and runs a 0x00..0xFF address | +| sweep for the 0x50 (addressed) variant, so the detector only needs VID/PID + usage page 0xFFA0. | +\*-------------------------------------------------------------------------------------------------------------------------------------------------*/ +REGISTER_HID_DETECTOR_P("Logitech HID++ 2.0 G522 Lightspeed (wired)", DetectLogitechHIDPP20, LOGITECH_VID, LOGITECH_G522_LIGHTSPEED_USB_PID, 0xFFA0); +REGISTER_HID_DETECTOR_P("Logitech HID++ 2.0 G522 Lightspeed (dongle)", DetectLogitechHIDPP20, LOGITECH_VID, LOGITECH_G522_LIGHTSPEED_DONGLE_PID, 0xFFA0); + /*-------------------------------------------------------------------------------------------------------------------------------------------------*\ | Keyboards | \*-------------------------------------------------------------------------------------------------------------------------------------------------*/ @@ -683,11 +905,11 @@ REGISTER_HID_DETECTOR_IP ("Logitech G600 Gaming Mouse", Dete REGISTER_HID_DETECTOR_IP ("Logitech G Pro Gaming Mouse", DetectLogitechMouseGPRO, LOGITECH_VID, LOGITECH_G_PRO_PID, 1, 0xFF00); REGISTER_HID_DETECTOR_IP ("Logitech G Pro HERO Gaming Mouse", DetectLogitechMouseGPRO, LOGITECH_VID, LOGITECH_G_PRO_HERO_PID, 1, 0xFF00); /*-------------------------------------------------------------------------------------------------------------------------------------------------*\ -| Speakers | +| Speakers | \*-------------------------------------------------------------------------------------------------------------------------------------------------*/ REGISTER_HID_DETECTOR_IPU("Logitech G560 Lightsync Speaker", DetectLogitechG560, LOGITECH_VID, LOGITECH_G560_PID, 2, 0xFF43, 514); /*-------------------------------------------------------------------------------------------------------------------------------------------------*\ -| Headsets | +| Headsets | \*-------------------------------------------------------------------------------------------------------------------------------------------------*/ REGISTER_HID_DETECTOR_IPU("Logitech G933 Lightsync Headset", DetectLogitechG933, LOGITECH_VID, LOGITECH_G933_PID, 3, 0xFF43, 514); /*-------------------------------------------------------------------------------------------------------------------------------------------------*\ @@ -890,12 +1112,12 @@ void DetectLogitechWireless(hid_device_info* info, const std::string& /*name*/) } } -/*-------------------------------------------------------------------------------------------------------------------------------------------------*\ -| Lightspeed Devices (Linux Wireless) | -| | -| DUMMY_DEVICE_DETECTOR("Logitech G Lightspeed Receiver", DetectLogitechWireless, 0x046D, 0xC539 ) | +/*--------------------------------------------------------------------------------------------------------------------------------------------------*\ +| Lightspeed Devices (Linux Wireless) | +| | +| DUMMY_DEVICE_DETECTOR("Logitech G Lightspeed Receiver", DetectLogitechWireless, 0x046D, 0xC539 ) | | DUMMY_DEVICE_DETECTOR("Logitech Powerplay Mat Receiver", DetectLogitechWireless, 0x046D, 0xC53A ) | -\*-------------------------------------------------------------------------------------------------------------------------------------------------*/ +\*--------------------------------------------------------------------------------------------------------------------------------------------------*/ REGISTER_HID_DETECTOR_IPU("Logitech G403 Wireless Gaming Mouse", DetectLogitechWireless, LOGITECH_VID, LOGITECH_G403_LIGHTSPEED_VIRTUAL_PID, 2, 0xFF00, 2); REGISTER_HID_DETECTOR_IPU("Logitech G502 Wireless Gaming Mouse", DetectLogitechWireless, LOGITECH_VID, LOGITECH_G502_LIGHTSPEED_VIRTUAL_PID, 2, 0xFF00, 2); REGISTER_HID_DETECTOR_IPU("Logitech G703 Wireless Gaming Mouse", DetectLogitechWireless, LOGITECH_VID, LOGITECH_G703_LIGHTSPEED_VIRTUAL_PID, 2, 0xFF00, 2); @@ -913,7 +1135,6 @@ REGISTER_HID_DETECTOR_IPU("Logitech Powerplay Mat", | G502 changed to PU to accomodate old and new firmware. Other devices may require similar update #4627 | \*-------------------------------------------------------------------------------------------------------------------------------------------------*/ REGISTER_HID_DETECTOR_PU("Logitech G502 Wireless Gaming Mouse (wired)", DetectLogitechWired, LOGITECH_VID, LOGITECH_G502_LIGHTSPEED_PID, 0xFF00, 2); -REGISTER_HID_DETECTOR_IPU("Logitech G502 X Plus (wired)", DetectLogitechWired, LOGITECH_VID, LOGITECH_G502_X_PLUS_PID, 2, 0xFF00, 2); REGISTER_HID_DETECTOR_IPU("Logitech G502 Proteus Spectrum Gaming Mouse", DetectLogitechWired, LOGITECH_VID, LOGITECH_G502_PROTEUS_SPECTRUM_PID, 1, 0xFF00, 2); REGISTER_HID_DETECTOR_IPU("Logitech G502 HERO Gaming Mouse", DetectLogitechWired, LOGITECH_VID, LOGITECH_G502_HERO_PID, 1, 0xFF00, 2); REGISTER_HID_DETECTOR_IPU("Logitech G403 Prodigy Gaming Mouse", DetectLogitechWired, LOGITECH_VID, LOGITECH_G403_PID, 1, 0xFF00, 2); diff --git a/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller.cpp b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller.cpp new file mode 100644 index 000000000..b7380ca21 --- /dev/null +++ b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller.cpp @@ -0,0 +1,5709 @@ +/*---------------------------------------------------------*\ +| LogitechHIDPP20Controller.cpp | +| | +| Unified Logitech HID++ 2.0 controller implementation | +| | +| This file is part of the OpenRGB project | +| SPDX-License-Identifier: GPL-2.0-or-later | +\*---------------------------------------------------------*/ + +#include +#include +#include +#include +#include +#include "LogitechHIDPP20Controller.h" +#include "RGBController_LogitechHIDPP20.h" +#include "LogManager.h" + +#include "LogitechHIDPP20IdleSettings.h" + + +#define LOG_TAG log_tag.c_str() + +/*----------------------------------------------------------*\ +| Hard cap on per-call non-HID++ drains in the read loop. | +| A high-polling-rate mouse can put 50+ input reports in the | +| buffer between our reads; this cap prevents pathological | +| input-flood scenarios from locking up a single read call. | +| 64 is enough headroom for normal congestion at 1 kHz. | +\*----------------------------------------------------------*/ +static const int HIDPP20_READ_DRAIN_BUDGET = 64; + +/*----------------------------------------------------------*\ +| Per-candidate read timeout (ms) for the Centurion 0x50 | +| device-address probe. USB round-trip is <1ms; 5ms gives | +| 5x margin. Worst case (no device responds) 256 × 5 = | +| ~1.3s; typical G522 at addr 0x23 is ~180ms. Matches | +| Solaar's probe_centurion_device_addr constant. | +\*----------------------------------------------------------*/ +static const int CENTURION_PROBE_PER_ADDR_TIMEOUT_MS = 5; + +/*----------------------------------------------------------*\ +| Observed HID++ 2.0 feature versions. Each row is a feature | +| ID plus the versions we've empirically verified working. | +| When feature discovery reports a version outside this set, | +| we log a one-shot INFO tripwire so a tester with new | +| hardware immediately surfaces unknown firmware revs. | +| | +| Purely observational — no behavior branches on version. | +| Solaar has effectively zero version gating for the RGB | +| features we implement, so we don't either; the table is a | +| "have we seen this combination work" ledger, not a | +| compatibility matrix. Add versions as devices report them. | +| | +| A feature_id absent from this table is silent (no | +| tripwire). Only features we actually exercise are worth | +| flagging. | +\*----------------------------------------------------------*/ +struct HIDPP20FeatureVersionSet +{ + uint16_t feature_id; + uint8_t versions[8]; /* approved versions; first `count` valid */ + uint8_t count; +}; + +static constexpr HIDPP20FeatureVersionSet HIDPP20_FEATURE_OBSERVED_VERSIONS[] = +{ + { 0x0620, { 1 }, 1 }, + { 0x1D4B, { 0 }, 1 }, + { 0x4540, { 1 }, 1 }, + { 0x8071, { 4 }, 1 }, + { 0x8081, { 0, 2 }, 2 }, +}; + +/*----------------------------------------------------------*\ +| Returns true if feature_id is not tracked (silent) or if | +| version appears in the tracked feature's approved set. | +\*----------------------------------------------------------*/ +static bool FeatureVersionIsObserved(uint16_t feature_id, uint8_t version) +{ + size_t table_len = sizeof(HIDPP20_FEATURE_OBSERVED_VERSIONS) + / sizeof(HIDPP20_FEATURE_OBSERVED_VERSIONS[0]); + + for(size_t r = 0; r < table_len; r++) + { + const HIDPP20FeatureVersionSet& row = HIDPP20_FEATURE_OBSERVED_VERSIONS[r]; + + if(row.feature_id != feature_id) + { + continue; + } + + for(uint8_t i = 0; i < row.count; i++) + { + if(row.versions[i] == version) + { + return true; + } + } + + return false; /* tracked feature, unknown version */ + } + + return true; /* feature not tracked — silent */ +} + +LogitechHIDPP20Controller::LogitechHIDPP20Controller + ( + hid_device* dev, + const char* path, + uint8_t device_index, + bool wireless, + std::shared_ptr mutex_ptr, + uint16_t usage_page + ) +{ + this->dev = dev; + this->location = path; + this->device_index = device_index; + this->wireless = wireless; + this->mutex = mutex_ptr; + this->initialized = false; + this->sw_control_claimed = false; + this->sw_control_needs_upgrade_to_5 = false; + this->frame_counter = 0; + this->retry_paint_deadline_.store(std::chrono::steady_clock::time_point{}); + this->retry_paint_attempt_.store(0); + this->wake_full_repaint_pending_.store(false); + this->init_generation = 0; + this->log_tag = "[LogitechHID++ " + std::string(path) + "]"; + this->reader_thread = nullptr; + this->reader_running = false; + this->power_thread = nullptr; + this->power_thread_running = false; + this->pending_activity = -1; + this->pending_connection = 0; + this->pending_path_check = 0; + this->device_online = true; + this->consecutive_timeouts = 0; + this->watcher_mode = false; + this->power_state = HIDPP20_POWER_ACTIVE; + this->deep_sleep = false; + this->consecutive_frame_end_failures = 0; + this->dim_brightness_pct = 100; + this->dim_step = 0; + this->idle_timeout_s = 60; + this->sleep_timeout_s = 300; + + caps = {}; + + /*---------------------------------------------------------*\ + | Default to standard HID++ transport; DiscoverTransport() | + | may change this during Probe() if Centurion is detected. | + \*---------------------------------------------------------*/ + transport.type = HIDPP20_TRANSPORT_STANDARD; + transport.usage_page = usage_page; + transport.report_id = LOGITECH_LONG_MESSAGE; + transport.addressed = false; + transport.device_address = 0x00; + transport.bridge_feat_idx = 0; + transport.sub_device_id = 0; + transport.bridge_mtu = 0; +} + +LogitechHIDPP20Controller::~LogitechHIDPP20Controller() +{ + if(initialized) + { + Shutdown(); + } + + if(dev) + { + hid_close(dev); + } +} + +/*---------------------------------------------------------*\ +| Transport-layer I/O | +| | +| SendMessage/ReadMessage dispatch to the appropriate | +| transport implementation based on transport.type. | +| SendAndReceive is a convenience wrapper. | +\*---------------------------------------------------------*/ + +int LogitechHIDPP20Controller::SendMessage + ( + uint8_t feat_idx, + uint8_t function, + const uint8_t* data, + size_t len + ) +{ + switch(transport.type) + { + case HIDPP20_TRANSPORT_CENTURION: + return SendCenturion(feat_idx, function, data, len); + + case HIDPP20_TRANSPORT_STANDARD: + default: + return SendStandard(feat_idx, function, data, len); + } +} + +int LogitechHIDPP20Controller::ReadMessage + ( + uint8_t* feat_idx_out, + uint8_t* function_out, + uint8_t* data_out, + size_t data_max, + int timeout_ms + ) +{ + /*---------------------------------------------------------*\ + | When the reader thread is running, it is the sole caller | + | of hid_read_timeout. All other reads come from the queue. | + | Before the reader starts (during Probe/Initialize), read | + | directly from HID. | + \*---------------------------------------------------------*/ + if(reader_running.load()) + { + return ReadFromQueue(feat_idx_out, function_out, data_out, data_max, timeout_ms); + } + + return ReadHIDDirect(feat_idx_out, function_out, data_out, data_max, timeout_ms); +} + +int LogitechHIDPP20Controller::ReadHIDDirect + ( + uint8_t* feat_idx_out, + uint8_t* function_out, + uint8_t* data_out, + size_t data_max, + int timeout_ms + ) +{ + switch(transport.type) + { + case HIDPP20_TRANSPORT_CENTURION: + return ReadCenturionDirect(feat_idx_out, function_out, data_out, data_max, timeout_ms); + + case HIDPP20_TRANSPORT_STANDARD: + default: + return ReadStandardDirect(feat_idx_out, function_out, data_out, data_max, timeout_ms); + } +} + +int LogitechHIDPP20Controller::ReadFromQueue + ( + uint8_t* feat_idx_out, + uint8_t* function_out, + uint8_t* data_out, + size_t data_max, + int timeout_ms + ) +{ + std::unique_lock lock(response_mutex); + + std::chrono::steady_clock::time_point deadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds(timeout_ms); + + while(response_queue.empty()) + { + if(response_cv.wait_until(lock, deadline) == std::cv_status::timeout) + { + /*-------------------------------------------------*\ + | Offline detection lives at the SendAcked layer | + | now: one tick per fully-failed call, not per | + | per-attempt read window. Streaming policies that | + | retry several times don't artificially accelerate | + | the offline declaration. | + \*-------------------------------------------------*/ + return 0; + } + + if(!reader_running.load()) + { + return 0; + } + } + + HIDPP20RawMessage msg = response_queue.front(); + response_queue.pop_front(); + + if(feat_idx_out) + { + *feat_idx_out = msg.feat; + } + + if(function_out) + { + *function_out = msg.func; + } + + if(data_out && data_max > 0) + { + size_t copy_len = (data_max > sizeof(msg.data)) ? sizeof(msg.data) : data_max; + memcpy(data_out, msg.data, copy_len); + } + + return msg.result; +} + +int LogitechHIDPP20Controller::SendAndReceive + ( + uint8_t feat_idx, + uint8_t function, + const uint8_t* send_data, + size_t send_len, + uint8_t* recv_data, + size_t recv_max + ) +{ + /*---------------------------------------------------------*\ + | Thin wrapper around SendAcked with the reliable policy. | + | Preserved as a named entry point so existing call sites | + | don't need to be touched. | + \*---------------------------------------------------------*/ + return SendAcked(feat_idx, function, + send_data, send_len, + recv_data, recv_max, + HIDPP20_POLICY_RELIABLE); +} + +int LogitechHIDPP20Controller::SendAcked + ( + uint8_t feat_idx, + uint8_t function, + const uint8_t* send_data, + size_t send_len, + uint8_t* recv_data, + size_t recv_max, + const HIDPP20RetryPolicy& policy, + uint8_t* hidpp20_error_out + ) +{ + /*----------------------------------------------------------*\ + | Universal send-and-ack with policy-driven retry. Mirrors | + | the firmware's own event burst pattern: 7-attempt | + | exponential backoff for reliable one-shot commands, tight | + | 2-attempt for streaming animation frames. | + | | + | Loop semantics per attempt: | + | 1. Sleep backoff_ms[i] (0 on first attempt) | + | 2. Bail if device went offline | + | 3. SendMessage; on wire error, mark and retry | + | 4. Read loop bounded by read_window_ms: | + | - matching response -> success | + | - HID++ error 0xFF for our request: | + | BUSY (0x08) + retry_on_busy -> retry the send | + | other code -> hard fail (-1) | + | - HID++ error for different request -> discard | + | - non-matching, non-error frame -> discard | + | - read timeout (0) -> retry the send | + \*----------------------------------------------------------*/ + if(hidpp20_error_out) + { + *hidpp20_error_out = 0; + } + + if(policy.flush_before) + { + FlushResponseQueue(); + } + + int last_result = 0; + uint8_t last_error = 0; + + for(uint8_t attempt = 0; attempt < policy.attempts; attempt++) + { + /*-----------------------------------------------------*\ + | Backoff before each attempt (0 on first) | + \*-----------------------------------------------------*/ + uint16_t delay_ms = policy.backoff_ms[attempt]; + + if(delay_ms > 0) + { + std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms)); + } + + /*-----------------------------------------------------*\ + | Bail early if device went offline mid-retry | + \*-----------------------------------------------------*/ + if(!device_online.load()) + { + return 0; + } + + int send_result = SendMessage(feat_idx, function, send_data, send_len); + + if(send_result < 0) + { + LOG_DEBUG("%s SendAcked[%s] wire send failed (attempt %d, result=%d) " + "feat=0x%02X func=0x%02X", + LOG_TAG, policy.name, attempt, send_result, feat_idx, function); + last_result = -2; + continue; + } + + /*-----------------------------------------------------*\ + | Read loop bounded by per-attempt window. Drain | + | non-matching HID++ frames within this window — they | + | are stale responses or unrelated events from prior | + | commands. Only retry the send if the window expires | + | with no match (lost on wire) or we got BUSY. | + \*-----------------------------------------------------*/ + std::chrono::steady_clock::time_point window_deadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds(policy.read_window_ms); + bool need_resend = false; + + while(!need_resend) + { + std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); + + if(now >= window_deadline) + { + LOG_TRACE("%s SendAcked[%s] window expired (attempt %d)", + LOG_TAG, policy.name, attempt); + last_result = 0; + break; + } + + int remaining = (int)std::chrono::duration_cast( + window_deadline - now).count(); + + if(remaining <= 0) + { + last_result = 0; + break; + } + + uint8_t resp_feat = 0; + uint8_t resp_func = 0; + uint8_t resp_data[60] = {}; + + int rd = ReadMessage(&resp_feat, &resp_func, + resp_data, sizeof(resp_data), + remaining); + + if(rd < 0) + { + /* Wire error — propagate, don't retry */ + return -2; + } + + if(rd == 0) + { + /* Window drained with nothing matching — retry the send */ + last_result = 0; + break; + } + + /*-------------------------------------------------*\ + | HID++ error frame | + | feat=0xFF, func=err_feat, data[0]=err_func, | + | data[1]=err_code | + \*-------------------------------------------------*/ + if(resp_feat == 0xFF) + { + uint8_t err_feat = resp_func; + uint8_t err_func = resp_data[0]; + uint8_t err_code = resp_data[1]; + + /*-----------------------------------------------*\ + | Match: either a direct error for our request, | + | or a Centurion bridge error attributed to the | + | bridge feature index when we're routing through | + | it. The bridge swallows the sub-device feat in | + | the error response, so all bridge-routed | + | failures look like errors from the bridge. | + \*-----------------------------------------------*/ + bool is_our_error = + (err_feat == feat_idx && + (err_func & 0xF0) == (function & 0xF0)) + || (transport.bridge_feat_idx != 0 && + err_feat == transport.bridge_feat_idx); + + if(is_our_error) + { + if(err_code == 0x08 && policy.retry_on_busy) + { + /* BUSY: retry the send after backoff */ + LOG_TRACE("%s SendAcked[%s] BUSY (attempt %d) feat=0x%02X func=0x%02X", + LOG_TAG, policy.name, attempt, feat_idx, function); + last_error = 0x08; + last_result = 0; + need_resend = true; + continue; + } + + /* Non-BUSY HID++ error: hard fail */ + LOG_DEBUG("%s SendAcked[%s] LogitechHID++ error 0x%02X " + "feat=0x%02X func=0x%02X", + LOG_TAG, policy.name, err_code, feat_idx, function); + + if(hidpp20_error_out) + { + *hidpp20_error_out = err_code; + } + + return -1; + } + + /* Error for a different request — stale, discard and keep reading */ + continue; + } + + /*-------------------------------------------------*\ + | Match our expected response | + \*-------------------------------------------------*/ + if(resp_feat == feat_idx && + (resp_func & 0xF0) == (function & 0xF0)) + { + if(recv_data && recv_max > 0) + { + size_t copy = (recv_max > sizeof(resp_data)) + ? sizeof(resp_data) : recv_max; + memcpy(recv_data, resp_data, copy); + } + + if(attempt > 0) + { + LOG_DEBUG("%s SendAcked[%s] succeeded on attempt %d " + "feat=0x%02X func=0x%02X", + LOG_TAG, policy.name, attempt, feat_idx, function); + } + + consecutive_timeouts.store(0); + return rd; + } + + /* Non-matching, non-error: stale unrelated frame, keep reading */ + } + } + + LOG_DEBUG("%s SendAcked[%s] exhausted %d attempts feat=0x%02X func=0x%02X " + "(last_error=0x%02X)", + LOG_TAG, policy.name, (int)policy.attempts, + feat_idx, function, last_error); + + if(hidpp20_error_out) + { + *hidpp20_error_out = last_error; + } + + /*----------------------------------------------------------*\ + | Offline detection: tick once per fully-failed call (all | + | retry attempts exhausted with no response). At a threshold | + | of 10 we declare the device gone. Reset to 0 happens on | + | any successful call (above) — single delayed responses | + | don't push us toward offline. | + \*----------------------------------------------------------*/ + if(last_result == 0) + { + int timeouts = ++consecutive_timeouts; + + if(timeouts >= 10 && device_online.load()) + { + LOG_DEBUG("%s Device appears offline (%d consecutive failed calls)", + LOG_TAG, timeouts); + device_online.store(false); + } + } + + return last_result; +} + +int LogitechHIDPP20Controller::SendAckedIntoFAP + ( + uint8_t feat_idx, + uint8_t function, + const uint8_t* send_data, + size_t send_len, + blankFAPmessage& response, + const HIDPP20RetryPolicy& policy + ) +{ + /*---------------------------------------------------------*\ + | Compatibility shim for callers that inherited the | + | SendLong+ReadResponse interface and inspect | + | response.data[] downstream. Calls SendAcked into a local | + | buffer, then reconstructs a blankFAPmessage on success. | + \*---------------------------------------------------------*/ + response.init(); + + uint8_t recv[60] = {}; + int result = SendAcked(feat_idx, function, + send_data, send_len, + recv, sizeof(recv), + policy); + + if(result > 0) + { + response.report_id = LOGITECH_LONG_MESSAGE; + response.device_index = device_index; + response.feature_index = feat_idx; + response.feature_command = function; + memcpy(response.data, recv, sizeof(response.data)); + } + + return result; +} + +/*---------------------------------------------------------*\ +| Standard HID++ transport (0xFF00 / 0xFF43) | +| Report IDs 0x10 (7 bytes) / 0x11 (20 bytes) | +\*---------------------------------------------------------*/ + +int LogitechHIDPP20Controller::SendStandard + ( + uint8_t feat_idx, + uint8_t function, + const uint8_t* data, + size_t len + ) +{ + /*-----------------------------------------------------------*\ + | Auto-select short (0x10, 7 bytes) vs long (0x11, 20 bytes) | + | based on data length. Upper layers just provide data; | + | transport picks the smallest frame that fits. | + | | + | Windows exception: HIDClass splits the HID++ short and long | + | message Top-Level Collections into separate virtual HID | + | devices (page 0xFF00 usage 1 vs usage 2). We open the long- | + | message TLC, which rejects 7-byte writes. Force long format | + | on Windows so every outgoing frame matches the collection | + | we opened — Linux hidraw and macOS IOHIDManager expose both | + | TLCs through one handle and keep the size-based heuristic. | + \*-----------------------------------------------------------*/ + uint8_t buf[LOGITECH_LONG_MESSAGE_LEN]; + size_t msg_len; + +#if defined(_WIN32) + const bool prefer_short = false; +#else + const bool prefer_short = (len <= 3); +#endif + + if(prefer_short) + { + memset(buf, 0, LOGITECH_SHORT_MESSAGE_LEN); + buf[0] = LOGITECH_SHORT_MESSAGE; + buf[1] = device_index; + buf[2] = feat_idx; + buf[3] = function | HIDPP20_SW_ID; + + if(data && len > 0) + { + memcpy(buf + 4, data, len); + } + + msg_len = LOGITECH_SHORT_MESSAGE_LEN; + } + else + { + memset(buf, 0, LOGITECH_LONG_MESSAGE_LEN); + buf[0] = LOGITECH_LONG_MESSAGE; + buf[1] = device_index; + buf[2] = feat_idx; + buf[3] = function | HIDPP20_SW_ID; + + if(data && len > 0) + { + size_t copy_len = (len > 16) ? 16 : len; + memcpy(buf + 4, data, copy_len); + } + + msg_len = LOGITECH_LONG_MESSAGE_LEN; + } + + int result; + + if(mutex) + { + std::lock_guard lock(*mutex); + result = hid_write(dev, buf, msg_len); + } + else + { + result = hid_write(dev, buf, msg_len); + } + + return result; +} + +int LogitechHIDPP20Controller::ReadStandardDirect + ( + uint8_t* feat_idx_out, + uint8_t* function_out, + uint8_t* data_out, + size_t data_max, + int timeout_ms + ) +{ + /*---------------------------------------------------------*\ + | No mutex needed for reads — when the reader thread is | + | running, it is the sole caller. Before the reader starts, | + | all access is single-threaded. | + | | + | Loop within the timeout window draining non-HID++ reports | + | (mouse motion, keystrokes, media keys, DJ events) until | + | we either find a HID++ short/long frame or actually time | + | out. A high-polling-rate device can put 50+ input reports | + | in the hidraw buffer between our calls; without the drain | + | loop the synchronous probe path can never get past them | + | to find its response. | + \*---------------------------------------------------------*/ + std::chrono::steady_clock::time_point deadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds(timeout_ms); + int drained = 0; + + while(true) + { + std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); + + if(now >= deadline) + { + return 0; + } + + int remaining_ms = (int)std::chrono::duration_cast( + deadline - now).count(); + + if(remaining_ms <= 0) + { + return 0; + } + + blankFAPmessage response; + response.init(); + + int result = hid_read_timeout(dev, response.buffer, response.size(), remaining_ms); + + if(result < 0) + { + /* Real wire error (e.g. device removed). */ + return result; + } + + if(result == 0) + { + /* hidapi timeout — window expired with nothing pending. */ + return 0; + } + + /*-----------------------------------------------------*\ + | Validate report ID. The hidraw can carry HID input | + | reports (keyboard, mouse, media keys) in addition to | + | HID++. Drop anything that isn't a HID++ short (0x10) | + | or long (0x11) message and keep draining within the | + | remaining window — otherwise the parser would read | + | buf[2]/buf[3] as feat/func and misinterpret | + | keystrokes/motion as HID++ events. | + \*-----------------------------------------------------*/ + if(response.buffer[0] != LOGITECH_SHORT_MESSAGE && + response.buffer[0] != LOGITECH_LONG_MESSAGE) + { + if(++drained > HIDPP20_READ_DRAIN_BUDGET) + { + LOG_DEBUG("%s ReadStandardDirect: drain budget (%d) exceeded", + LOG_TAG, HIDPP20_READ_DRAIN_BUDGET); + return 0; + } + continue; + } + + if(feat_idx_out) + { + *feat_idx_out = response.feature_index; + } + + if(function_out) + { + *function_out = response.feature_command; + } + + if(data_out && data_max > 0) + { + size_t copy_len = (data_max > sizeof(response.data)) ? sizeof(response.data) : data_max; + memcpy(data_out, response.data, copy_len); + } + + return result; + } +} + +/*---------------------------------------------------------*\ +| Centurion transport (0xFFA0) | +| | +| Wire format per protocol doc: | +| 0x51 (direct): [reportId] [cplLen] [flags] [featIdx] | +| [func|swid] [params...] | +| 0x50 (addressed): [reportId] [devAddr] [cplLen] [flags] | +| [featIdx] [func|swid] [params...] | +| | +| For sub-device access, the parent CentPPBridge wraps | +| sub-device messages: | +| params = [devId<<4|lenHi, lenLo, subCPL, subFeatIdx, | +| subFunc|swid, subParams...] | +| | +| Selects direct (0x50/0x51) or bridge-wrapped framing | +| based on transport; routes sub-devices via CentPPBridge. | +\*---------------------------------------------------------*/ + +int LogitechHIDPP20Controller::SendCenturion + ( + uint8_t feat_idx, + uint8_t function, + const uint8_t* data, + size_t len + ) +{ + uint8_t buf[64]; + memset(buf, 0, sizeof(buf)); + + if(transport.bridge_feat_idx != 0) + { + /*-----------------------------------------------------*\ + | Sub-device message routed through CentPPBridge | + | Parent message: feat=bridge, func=sendFragment(0x10) | + | Payload: [devId<<4|lenHi, lenLo, subCPL=0x00, | + | subFeatIdx, subFunc|swid, subParams...] | + \*-----------------------------------------------------*/ + uint16_t sub_msg_len = 3 + (uint16_t)len; // subCPL + featIdx + func + data + + if(transport.addressed) + { + buf[0] = transport.report_id; + buf[1] = transport.device_address; + buf[2] = 5 + sub_msg_len; // cplLen + buf[3] = 0x00; // flags (single fragment) + buf[4] = transport.bridge_feat_idx; + buf[5] = 0x10 | HIDPP20_SW_ID; // sendFragment (func 1) + buf[6] = (transport.sub_device_id << 4) | ((sub_msg_len >> 8) & 0x0F); + buf[7] = sub_msg_len & 0xFF; + buf[8] = 0x00; // sub-CPL (single fragment) + buf[9] = feat_idx; + buf[10] = function | HIDPP20_SW_ID; + + if(data && len > 0) + { + memcpy(buf + 11, data, len); + } + } + else + { + buf[0] = transport.report_id; + buf[1] = 5 + sub_msg_len; // cplLen: flags(1) + feat(1) + func(1) + hdr(2) + sub + buf[2] = 0x00; // flags + buf[3] = transport.bridge_feat_idx; + buf[4] = 0x10 | HIDPP20_SW_ID; // sendFragment (func 1) + buf[5] = (transport.sub_device_id << 4) | ((sub_msg_len >> 8) & 0x0F); + buf[6] = sub_msg_len & 0xFF; + buf[7] = 0x00; // sub-CPL + buf[8] = feat_idx; + buf[9] = function | HIDPP20_SW_ID; + + if(data && len > 0) + { + memcpy(buf + 10, data, len); + } + } + } + else + { + /*-----------------------------------------------------*\ + | Direct parent device message (no bridge) | + \*-----------------------------------------------------*/ + if(transport.addressed) + { + buf[0] = transport.report_id; + buf[1] = transport.device_address; + buf[2] = 3 + (uint8_t)len; // cplLen + buf[3] = 0x00; // flags + buf[4] = feat_idx; + buf[5] = function | HIDPP20_SW_ID; + + if(data && len > 0) + { + memcpy(buf + 6, data, len); + } + } + else + { + buf[0] = transport.report_id; + buf[1] = 3 + (uint8_t)len; // cplLen: flags(1) + feat(1) + func(1) + data + buf[2] = 0x00; // flags + buf[3] = feat_idx; + buf[4] = function | HIDPP20_SW_ID; + + if(data && len > 0) + { + memcpy(buf + 5, data, len); + } + } + } + + int result; + + if(mutex) + { + std::lock_guard lock(*mutex); + result = hid_write(dev, buf, 64); + } + else + { + result = hid_write(dev, buf, 64); + } + + return result; +} + +int LogitechHIDPP20Controller::ReadCenturionDirect + ( + uint8_t* feat_idx_out, + uint8_t* function_out, + uint8_t* data_out, + size_t data_max, + int timeout_ms + ) +{ + uint8_t buf[64]; + memset(buf, 0, sizeof(buf)); + + /*----------------------------------------------------------*\ + | Track an overall deadline so the bridge ACK + MessageEvent | + | two-read sequence stays within timeout_ms total — without | + | this each read could eat the full budget independently. | + | Drain non-Centurion report IDs within the remaining window | + | rather than bailing on the first non-matching frame. | + \*----------------------------------------------------------*/ + std::chrono::steady_clock::time_point deadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds(timeout_ms); + int drained = 0; + int result = 0; + + while(true) + { + std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); + + if(now >= deadline) + { + return 0; + } + + int remaining_ms = (int)std::chrono::duration_cast( + deadline - now).count(); + + if(remaining_ms <= 0) + { + return 0; + } + + result = hid_read_timeout(dev, buf, sizeof(buf), remaining_ms); + + if(result < 0) + { + return result; + } + + if(result == 0) + { + return 0; + } + + if(buf[0] == transport.report_id) + { + break; + } + + if(++drained > HIDPP20_READ_DRAIN_BUDGET) + { + LOG_DEBUG("%s ReadCenturionDirect: drain budget (%d) exceeded", + LOG_TAG, HIDPP20_READ_DRAIN_BUDGET); + return 0; + } + } + + /*---------------------------------------------------------*\ + | Parse based on transport variant | + \*---------------------------------------------------------*/ + int hdr_offset = transport.addressed ? 1 : 0; // skip device address byte + + uint8_t cpl_len = buf[1 + hdr_offset]; + // uint8_t cpl_flags = buf[2 + hdr_offset]; // for fragmentation support + uint8_t resp_feat = buf[3 + hdr_offset]; + uint8_t resp_func = buf[4 + hdr_offset]; + + if(transport.bridge_feat_idx != 0 && resp_feat == transport.bridge_feat_idx) + { + /*--------------------------------------------------------*\ + | CentPPBridge — distinguish events from command responses | + | | + | Bridge events (e.g. ConnectionStateChangedEvent) have | + | func high nibble = 0x00 (event index 0) and swid = 0. | + | These are NOT wrapped sub-device responses — they are | + | bridge-level notifications. Return as-is so the reader | + | thread can detect them. | + | | + | Command responses follow a two-response pattern: | + | 1. ACK: bridge echoes feat+func with our swid | + | 2. MessageEvent: func=1x, swid=0, wrapped sub-device | + \*--------------------------------------------------------*/ + if((resp_func & 0xF0) == 0x00 && (resp_func & 0x0F) != HIDPP20_SW_ID) + { + /*-------------------------------------------------*\ + | Bridge event — return feat/func/data as-is | + \*-------------------------------------------------*/ + if(feat_idx_out) *feat_idx_out = resp_feat; + if(function_out) *function_out = resp_func; + + if(data_out && data_max > 0) + { + size_t avail = (size_t)(cpl_len > 2 ? cpl_len - 2 : 0); + size_t copy = (avail < data_max) ? avail : data_max; + memcpy(data_out, buf + 5 + hdr_offset, copy); + } + + return result; + } + + if((resp_func & 0x0F) == HIDPP20_SW_ID) + { + /*---------------------------------------------------*\ + | This is the ACK — discard and read the MessageEvent | + | Use the *remaining* window from the overall | + | deadline so the two-read sequence stays bounded, | + | and drain non-Centurion frames within that window. | + \*---------------------------------------------------*/ + while(true) + { + std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); + + if(now >= deadline) + { + return 0; + } + + int remaining_ms = (int)std::chrono::duration_cast( + deadline - now).count(); + + if(remaining_ms <= 0) + { + return 0; + } + + memset(buf, 0, sizeof(buf)); + result = hid_read_timeout(dev, buf, sizeof(buf), remaining_ms); + + if(result < 0) + { + return result; + } + + if(result == 0) + { + return 0; + } + + if(buf[0] == transport.report_id) + { + break; + } + + if(++drained > HIDPP20_READ_DRAIN_BUDGET) + { + LOG_DEBUG("%s ReadCenturionDirect: drain budget (%d) exceeded on bridge MessageEvent", + LOG_TAG, HIDPP20_READ_DRAIN_BUDGET); + return 0; + } + } + + resp_feat = buf[3 + hdr_offset]; + resp_func = buf[4 + hdr_offset]; + + if(resp_feat != transport.bridge_feat_idx) + { + /*---------------------------------------------*\ + | Not a bridge response — return as-is | + \*---------------------------------------------*/ + if(feat_idx_out) *feat_idx_out = resp_feat; + if(function_out) *function_out = resp_func; + + if(data_out && data_max > 0) + { + size_t avail = (size_t)(buf[1 + hdr_offset] > 2 ? buf[1 + hdr_offset] - 2 : 0); + size_t copy = (avail < data_max) ? avail : data_max; + memcpy(data_out, buf + 5 + hdr_offset, copy); + } + + return result; + } + } + + /*-----------------------------------------------------*\ + | MessageEvent — unwrap sub-device response. | + | Bridge params: [devId<<4|lenHi, lenLo, subCPL, | + | subFeatIdx, subFunc|swid, subData...] | + \*-----------------------------------------------------*/ + int sub_offset = 5 + hdr_offset + 3; // past bridge header + resp_feat = buf[sub_offset]; + resp_func = buf[sub_offset + 1]; + + if(feat_idx_out) *feat_idx_out = resp_feat; + if(function_out) *function_out = resp_func; + + if(data_out && data_max > 0) + { + size_t avail = (size_t)(result - sub_offset - 2); + size_t copy = (avail < data_max) ? avail : data_max; + memcpy(data_out, buf + sub_offset + 2, copy); + } + } + else + { + /*-----------------------------------------------------*\ + | Direct response | + \*-----------------------------------------------------*/ + if(feat_idx_out) *feat_idx_out = resp_feat; + if(function_out) *function_out = resp_func; + + if(data_out && data_max > 0) + { + size_t avail = (size_t)(cpl_len > 2 ? cpl_len - 2 : 0); + size_t copy = (avail < data_max) ? avail : data_max; + memcpy(data_out, buf + 5 + hdr_offset, copy); + } + } + + (void)cpl_len; + + return result; +} + +/*---------------------------------------------------------*\ +| Feature Discovery | +\*---------------------------------------------------------*/ + +uint8_t LogitechHIDPP20Controller::GetFeatureIndex(uint16_t feature_page, + const HIDPP20RetryPolicy& policy) +{ + /*-----------------------------------------------------------*\ + | Check cache first — both Centurion bulk and HID++ on-demand | + | lookups store results here. | + \*-----------------------------------------------------------*/ + std::map::const_iterator it = caps.feature_map.find(feature_page); + + if(it != caps.feature_map.end()) + { + return it->second; + } + + /*---------------------------------------------------------*\ + | Centurion bulk enumeration is complete — if a feature | + | isn't in the map, it doesn't exist. No wire query needed. | + \*---------------------------------------------------------*/ + if(caps.feature_map_complete) + { + return 0; + } + + /*---------------------------------------------------------*\ + | Standard HID++: on-demand IRoot query, cache the result. | + \*---------------------------------------------------------*/ + uint8_t send_data[2]; + send_data[0] = (feature_page >> 8) & 0xFF; + send_data[1] = feature_page & 0xFF; + + uint8_t recv_data[16] = {}; + int result = SendAcked(LOGITECH_HIDPP_PAGE_ROOT_IDX, FN_8071_GET_INFO, + send_data, 2, recv_data, sizeof(recv_data), + policy); + + if(result > 0) + { + uint8_t index = recv_data[0]; + uint8_t version = recv_data[2]; + + if(index != 0) + { + caps.feature_map[feature_page] = index; + caps.feature_versions[feature_page] = version; + } + + if(index != 0) + { + LOG_DEBUG("%s Feature 0x%04X V%u -> index 0x%02X", + LOG_TAG, feature_page, version, index); + + if(!FeatureVersionIsObserved(feature_page, version)) + { + LOG_INFO("%s Feature 0x%04X V%u not previously observed — " + "tripwire for version-gated behavior", + LOG_TAG, feature_page, version); + } + } + else + { + LOG_DEBUG("%s Feature 0x%04X not present", LOG_TAG, feature_page); + } + + return index; + } + + /*---------------------------------------------------------*\ + | Cache the miss too so we don't re-query failed lookups | + \*---------------------------------------------------------*/ + caps.feature_map[feature_page] = 0; + + LOG_DEBUG("%s Feature 0x%04X not found", LOG_TAG, feature_page); + return 0; +} + +/*---------------------------------------------------------*\ +| Return the protocol version byte for a feature, or 0 if | +| the feature isn't present in this device's feature set. | +| Populated alongside feature_map during EnumerateFeatures | +| (Centurion bulk) or GetFeatureIndex (standard HID++ | +| on-demand IRoot.GetFeature). | +\*---------------------------------------------------------*/ +uint8_t LogitechHIDPP20Controller::GetFeatureVersion(uint16_t feature_page) const +{ + std::map::const_iterator it = caps.feature_versions.find(feature_page); + + if(it != caps.feature_versions.end()) + { + return it->second; + } + + return 0; +} + +void LogitechHIDPP20Controller::DiscoverDeviceName() +{ + /*---------------------------------------------------------*\ + | Centurion sub-devices use 0x0101 (DeviceName). | + | Standard HID++ uses 0x0005 (DeviceNameType). | + \*---------------------------------------------------------*/ + if(transport.type == HIDPP20_TRANSPORT_CENTURION) + { + /*------------------------------------------------------*\ + | Centurion sub-device: 0x0101 getName returns firmware | + | data, not a readable name. Fall back to a platform- | + | specific lookup that reads the friendly name from the | + | OS's HID enumeration (sysfs HID_NAME on Linux, | + | hid_device_info::product_string on Windows). | + \*------------------------------------------------------*/ + std::string friendly = GetCenturionSubDeviceName(location); + + if(!friendly.empty()) + { + caps.device_name = friendly; + } + else + { + caps.device_name = "Logitech Centurion Device"; + } + + LOG_VERBOSE("%s Device name (Centurion): %s", LOG_TAG, caps.device_name.c_str()); + return; + } + + uint8_t feat_idx = GetFeatureIndex(HIDPP20_FEAT_DEVICE_NAME_TYPE); + + if(feat_idx == 0) + { + caps.device_name = "Logitech HID++ Device"; + return; + } + + uint8_t recv[16] = {}; + int result = SendAcked(feat_idx, LOTITECH_CMD_DEVICE_NAME_TYPE_GET_COUNT, + nullptr, 0, recv, sizeof(recv)); + + if(result <= 0) + { + caps.device_name = "Logitech HID++ Device"; + return; + } + + unsigned int name_length = recv[0]; + caps.device_name.clear(); + + for(unsigned int offset = 0; offset < name_length; offset += 16) + { + uint8_t send_data[1] = { (uint8_t)offset }; + result = SendAcked(feat_idx, LOGITECH_CMD_DEVICE_NAME_TYPE_GET_DEVICE_NAME, + send_data, 1, recv, sizeof(recv)); + + if(result <= 0) + { + break; + } + + unsigned int chunk_len = name_length - offset; + if(chunk_len > 16) + { + chunk_len = 16; + } + + caps.device_name.append((char*)recv, chunk_len); + } + + LOG_VERBOSE("%s Device name: %s", LOG_TAG, caps.device_name.c_str()); +} + +void LogitechHIDPP20Controller::DiscoverDeviceType() +{ + /*----------------------------------------------------------*\ + | Centurion sub-devices don't have 0x0005 (DeviceNameType). | + | Default to unknown — don't assume device type from | + | transport, as Centurion may be used for future devices. | + \*----------------------------------------------------------*/ + if(transport.type == HIDPP20_TRANSPORT_CENTURION) + { + caps.device_type = 0; + return; + } + + uint8_t feat_idx = GetFeatureIndex(HIDPP20_FEAT_DEVICE_NAME_TYPE); + + if(feat_idx == 0) + { + caps.device_type = LOGITECH_DEVICE_TYPE_MOUSE; + return; + } + + uint8_t recv[16] = {}; + int result = SendAcked(feat_idx, LOGITECH_CMD_DEVICE_NAME_TYPE_GET_TYPE, + nullptr, 0, recv, sizeof(recv)); + + if(result > 0) + { + caps.device_type = recv[0]; + LOG_VERBOSE("%s Device type: %d", LOG_TAG, caps.device_type); + } + else + { + caps.device_type = LOGITECH_DEVICE_TYPE_MOUSE; + } +} + +void LogitechHIDPP20Controller::DiscoverTransport() +{ + /*---------------------------------------------------------*\ + | Detect transport type from usage page. | + | 0xFF00/0xFF43: Standard HID++ (0x10/0x11 reports) | + | 0xFFA0+: Centurion (64-byte CPL framing) | + \*---------------------------------------------------------*/ + if(transport.usage_page == 0xFF00 || transport.usage_page == 0xFF43) + { + transport.type = HIDPP20_TRANSPORT_STANDARD; + return; + } + + /*---------------------------------------------------------*\ + | Centurion transport — determine variant by probing. | + | 0x51 = direct (PRO X 2), 0x50 = addressed (G522). The | + | report descriptor would tell us which report IDs exist, | + | but hid_get_report_descriptor is hidapi 0.14.0+ only, so | + | we probe instead: try 0x51 direct first, then fall back | + | to the robust 0x50 device-address sweep. | + \*---------------------------------------------------------*/ + transport.type = HIDPP20_TRANSPORT_CENTURION; + + /*---------------------------------------------------------*\ + | Probe 0x51 (direct). If the device answers a 0x51 frame | + | it speaks the direct variant — no device address needed. | + \*---------------------------------------------------------*/ + transport.report_id = 0x51; + transport.addressed = false; + + uint8_t probe_buf[64] = {}; + probe_buf[0] = 0x51; + probe_buf[1] = 3; + probe_buf[2] = 0x00; + probe_buf[3] = 0x00; + probe_buf[4] = 0x00 | HIDPP20_SW_ID; + + int wr = hid_write(dev, probe_buf, 64); + + if(wr > 0) + { + uint8_t resp_buf[64] = {}; + int rd = hid_read_timeout(dev, resp_buf, sizeof(resp_buf), 500); + + if(rd > 0 && resp_buf[0] == 0x51) + { + LOG_DEBUG("%s Centurion 0x51 (direct) from probe", LOG_TAG); + return; + } + } + + /*---------------------------------------------------------*\ + | No 0x51 reply — assume 0x50 (addressed) and find the | + | device address. | + \*---------------------------------------------------------*/ + transport.report_id = 0x50; + transport.addressed = true; + transport.device_address = 0x00; + + /*-----------------------------------------------------*\ + | Device-address sweep. 0x50 frames carry a device | + | address byte; the device silently drops frames | + | addressed to the wrong ID, so we brute-force probe | + | every candidate with an IRoot fn1 GetProtocolVersion | + | ping. First response wins — real address lives in | + | resp_buf[1] of the reply. Mirrors Solaar's | + | probe_centurion_device_addr; see | + | CENTURION_PROBE_PER_ADDR_TIMEOUT_MS above for timing. | + | | + | Wire format per candidate: | + | [0x50, addr, 0x06, 0x00, 0x00, 0x10, 0x00, 0x00, | + | 0x00, zero-pad to 64] | + | where 0x06 = cpl_length (flags+payload), 0x10 = fn1 | + | GetProtocolVersion with sw_id=0. | + \*-----------------------------------------------------*/ + bool addr_found = false; + unsigned probe_count = 0; + unsigned write_errors = 0; + + for(unsigned addr = 0; addr < 256; addr++) + { + uint8_t sweep_buf[64] = {}; + sweep_buf[0] = 0x50; + sweep_buf[1] = (uint8_t)addr; + sweep_buf[2] = 0x06; + sweep_buf[3] = 0x00; + sweep_buf[4] = 0x00; + sweep_buf[5] = 0x10; + + int swr = hid_write(dev, sweep_buf, 64); + probe_count++; + + if(swr <= 0) + { + write_errors++; + if(write_errors > 3) + { + LOG_DEBUG("%s Centurion 0x50 probe: too many write failures, aborting", LOG_TAG); + break; + } + continue; + } + + uint8_t resp_buf[64] = {}; + int rd = hid_read_timeout(dev, resp_buf, sizeof(resp_buf), + CENTURION_PROBE_PER_ADDR_TIMEOUT_MS); + + if(rd >= 2 && resp_buf[0] == 0x50) + { + transport.device_address = resp_buf[1]; + addr_found = true; + break; + } + } + + if(addr_found) + { + LOG_INFO("%s Centurion 0x50 device_addr=0x%02X (after %u candidates)", + LOG_TAG, transport.device_address, probe_count); + } + else + { + LOG_DEBUG("%s Centurion 0x50 probe: no response from any of 256 candidates", + LOG_TAG); + } +} + +void LogitechHIDPP20Controller::EnumerateFeatures(uint8_t feature_set_idx) +{ + caps.feature_map.clear(); + caps.feature_map_complete = false; + + /*---------------------------------------------------------*\ + | Root (0x0000) is always at index 0 | + \*---------------------------------------------------------*/ + caps.feature_map[0x0000] = 0; + + if(transport.type == HIDPP20_TRANSPORT_CENTURION) + { + /*-----------------------------------------------------*\ + | Centurion sub-device: CenturionFeatureSet fn1 returns | + | ALL features in a single bulk response. | + | [count, (feat_hi, feat_lo, type, version) × count] | + \*-----------------------------------------------------*/ + uint8_t send_data[1] = { 0x00 }; + uint8_t recv_data[60] = {}; + + int result = SendAcked(feature_set_idx, 0x10, + send_data, 1, recv_data, sizeof(recv_data)); + + if(result > 0) + { + uint8_t count = recv_data[0]; + + LOG_DEBUG("%s CenturionFeatureSet: %d features", LOG_TAG, count); + + for(uint8_t i = 0; i < count && (1 + i * 4 + 3) < (int)sizeof(recv_data); i++) + { + int offset = 1 + i * 4; + uint16_t feat_id = ((uint16_t)recv_data[offset] << 8) | recv_data[offset + 1]; + uint8_t feat_type = recv_data[offset + 2]; + uint8_t feat_version = recv_data[offset + 3]; + uint8_t feat_idx = i; // 0-based: bulk includes root at 0 + + caps.feature_map[feat_id] = feat_idx; + caps.feature_versions[feat_id] = feat_version; + + LOG_DEBUG("%s [%2d] Feature 0x%04X V%u type=0x%02X", + LOG_TAG, feat_idx, feat_id, feat_version, feat_type); + + if(!FeatureVersionIsObserved(feat_id, feat_version)) + { + LOG_INFO("%s Feature 0x%04X V%u not previously observed — " + "tripwire for version-gated behavior", + LOG_TAG, feat_id, feat_version); + } + } + + caps.feature_map_complete = true; + } + } + else + { + /*-----------------------------------------------------*\ + | Standard HID++: no bulk query available. Features are | + | looked up on-demand via GetFeatureIndex (IRoot) and | + | cached in the feature map. Nothing to do here. | + \*-----------------------------------------------------*/ + return; + } +} + +void LogitechHIDPP20Controller::DiscoverFirmwareInfo() +{ + /*--------------------------------------------------------------*\ + | Centurion sub-devices use 0x0100 (DeviceInfo) for firmware | + | version and serial. Standard HID++ uses 0x0003 (FirmwareInfo). | + \*--------------------------------------------------------------*/ + if(transport.type == HIDPP20_TRANSPORT_CENTURION) + { + uint8_t dev_info_idx = GetFeatureIndex(HIDPP20_FEAT_CENTURION_DEVICE_INFO); + + if(dev_info_idx == 0) + { + return; + } + + /*------------------------------------------------------*\ + | fn1 getFirmwareVersion(entityIndex=0) — main firmware | + | Response: [fwType, additional, version_hi, version_lo] | + \*------------------------------------------------------*/ + { + uint8_t send_data[1] = { 0x00 }; + uint8_t recv_data[16] = {}; + + int result = SendAcked(dev_info_idx, 0x10, + send_data, 1, recv_data, sizeof(recv_data)); + + if(result > 0) + { + uint16_t version = ((uint16_t)recv_data[2] << 8) | recv_data[3]; + + char ver_str[32]; + snprintf(ver_str, sizeof(ver_str), "%d.%d", + (version >> 8) & 0xFF, version & 0xFF); + + caps.firmware_version = ver_str; + + LOG_DEBUG("%s Firmware (Centurion): %s", LOG_TAG, caps.firmware_version.c_str()); + } + } + + /*-----------------------------------------------------*\ + | fn2 getSerialNumber on 0x0100 (DeviceInfo) | + | Response: [stringLen, serial...] | + \*-----------------------------------------------------*/ + { + uint8_t recv_data[16] = {}; + + int result = SendAcked(dev_info_idx, 0x20, + nullptr, 0, recv_data, sizeof(recv_data)); + + if(result > 0) + { + uint8_t slen = recv_data[0]; + if(slen > 15) slen = 15; + + caps.serial_number = std::string((char*)&recv_data[1], slen); + + LOG_DEBUG("%s Serial (Centurion): %s", LOG_TAG, caps.serial_number.c_str()); + } + } + + return; + } + + uint8_t fw_idx = GetFeatureIndex(HIDPP20_FEAT_FIRMWARE_INFO); + + if(fw_idx == 0) + { + return; + } + + /*------------------------------------------------------------*\ + | fn0 GetEntityCount — entity count, unitId, transport PIDs | + | Response: [count, unitId(4), transport(2), PID1(2), PID2(2)] | + \*------------------------------------------------------------*/ + uint8_t entity_count = 1; + + { + uint8_t recv_data[16] = {}; + int result = SendAcked(fw_idx, 0x00, + nullptr, 0, recv_data, sizeof(recv_data)); + + if(result > 0) + { + entity_count = recv_data[0]; + + /*-------------------------------------------------*\ + | Extract unitId — stable hardware identity across | + | all connection paths (USB, wireless, dongle). | + \*-------------------------------------------------*/ + char uid[16]; + snprintf(uid, sizeof(uid), "%02X%02X%02X%02X", + recv_data[1], recv_data[2], recv_data[3], recv_data[4]); + caps.unit_id = uid; + + caps.pid_wireless = ((uint16_t)recv_data[7] << 8) | recv_data[8]; + caps.pid_wired = ((uint16_t)recv_data[9] << 8) | recv_data[10]; + + /*-------------------------------------------------*\ + | Use unitId as serial if device doesn't report one | + \*-------------------------------------------------*/ + if(caps.serial_number.empty() && caps.unit_id != "00000000") + { + caps.serial_number = caps.unit_id; + } + + LOG_DEBUG("%s unitId=%s PID1=0x%04X PID2=0x%04X", + LOG_TAG, caps.unit_id.c_str(), caps.pid_wireless, caps.pid_wired); + + /*-------------------------------------------------*\ + | Resolve per-model quirks from the PID pair. | + \*-------------------------------------------------*/ + caps.quirks = 0; + + size_t quirk_table_len = sizeof(HIDPP20_DEVICE_QUIRK_TABLE) + / sizeof(HIDPP20_DEVICE_QUIRK_TABLE[0]); + + for(size_t q = 0; q < quirk_table_len; q++) + { + const HIDPP20DeviceQuirkEntry& entry = HIDPP20_DEVICE_QUIRK_TABLE[q]; + + if((entry.pid_wireless != 0 && entry.pid_wireless == caps.pid_wireless) || + (entry.pid_wired != 0 && entry.pid_wired == caps.pid_wired)) + { + caps.quirks |= entry.quirks; + } + } + + if(caps.quirks != 0) + { + LOG_DEBUG("%s Device quirks: 0x%08X", LOG_TAG, caps.quirks); + } + } + } + + /*----------------------------------------------------------*\ + | fn1 GetFwInfo — iterate entities to find main FW (type 0) | + | fwType lower nibble: 0=main, 1=bootloader, 2=HW rev | + | Response: fwType(1), prefix(3), bcdVersion(2), bcdBuild(2) | + \*----------------------------------------------------------*/ + for(uint8_t entity = 0; entity < entity_count && entity < 8; entity++) + { + uint8_t send_data[1] = { entity }; + uint8_t recv_data[16] = {}; + + int result = SendAcked(fw_idx, 0x10, + send_data, 1, recv_data, sizeof(recv_data)); + + if(result <= 0) + { + continue; + } + + uint8_t fw_type = recv_data[0] & 0x0F; + char prefix[4] = { (char)recv_data[1], (char)recv_data[2], (char)recv_data[3], '\0' }; + uint8_t ver_major = recv_data[4]; + uint8_t ver_minor = recv_data[5]; + uint16_t build = ((uint16_t)recv_data[6] << 8) | recv_data[7]; + + char ver_str[64]; + snprintf(ver_str, sizeof(ver_str), "%s %d.%d.%05u", + prefix, ver_major, ver_minor, build); + + LOG_DEBUG("%s Firmware entity %d: type=%d %s", LOG_TAG, entity, fw_type, ver_str); + + if(fw_type == 0) + { + caps.firmware_version = ver_str; + } + } + + if(caps.firmware_version.empty()) + { + LOG_DEBUG("%s No main firmware entity found", LOG_TAG); + } + + /*---------------------------------------------------------*\ + | fn2 GetDeviceSerialNumber — ASCII serial up to 16 bytes | + \*---------------------------------------------------------*/ + { + uint8_t recv_data[16] = {}; + + int result = SendAcked(fw_idx, 0x20, + nullptr, 0, recv_data, sizeof(recv_data)); + + if(result > 0) + { + char serial[17] = {}; + memcpy(serial, recv_data, 16); + serial[16] = '\0'; + + /*-------------------------------------------------*\ + | Trim trailing nulls/spaces | + \*-------------------------------------------------*/ + for(int i = 15; i >= 0; i--) + { + if(serial[i] == '\0' || serial[i] == ' ') + { + serial[i] = '\0'; + } + else + { + break; + } + } + + if(serial[0] != '\0') + { + caps.serial_number = serial; + } + + LOG_DEBUG("%s Serial: %s", LOG_TAG, caps.serial_number.c_str()); + } + } +} + +void LogitechHIDPP20Controller::DiscoverRGBEffects() +{ + /*---------------------------------------------------------*\ + | Try 0x8071 first, then 0x0600 (Centurion), then 0x8070 | + \*---------------------------------------------------------*/ + caps.idx_rgb_effects = GetFeatureIndex(HIDPP20_FEAT_RGB_EFFECTS); + caps.rgb_feature_page = HIDPP20_FEAT_RGB_EFFECTS; + + if(caps.idx_rgb_effects == 0) + { + caps.idx_rgb_effects = GetFeatureIndex(HIDPP20_FEAT_CENTURION_RGB); + caps.rgb_feature_page = HIDPP20_FEAT_CENTURION_RGB; + } + + if(caps.idx_rgb_effects == 0) + { + caps.idx_rgb_effects = GetFeatureIndex(HIDPP20_FEAT_COLOR_LED_EFFECTS); + caps.rgb_feature_page = HIDPP20_FEAT_COLOR_LED_EFFECTS; + } + + if(caps.idx_rgb_effects == 0) + { + caps.has_zone_effects = false; + return; + } + + /*------------------------------------------------------------*\ + | Resolve function IDs based on which feature was found. | + | 0x8071 and 0x0600 share the same function layout. | + | 0x8070 has different function numbers and SW control format. | + \*------------------------------------------------------------*/ + if(caps.rgb_feature_page == HIDPP20_FEAT_COLOR_LED_EFFECTS) + { + caps.fn_set_effect = 0x30; + caps.fn_sw_control = 0x80; + caps.fn_pwr_config = 0; + caps.fn_pwr_mode = 0; + caps.has_power_mgmt = false; + caps.sw_control_simple = true; + } + else + { + caps.fn_set_effect = 0x10; + caps.fn_sw_control = 0x50; + caps.fn_pwr_config = 0x70; + caps.fn_pwr_mode = 0x80; + caps.has_power_mgmt = true; + caps.sw_control_simple = false; + } + + /*---------------------------------------------------------*\ + | GetInfo: discover cluster count | + | 0x8071: data = [0xFF, 0xFF, 0x00] | + | 0x8070: data = [] (empty) | + \*---------------------------------------------------------*/ + uint8_t data[3] = { 0xFF, 0xFF, 0x00 }; + size_t data_len = (caps.rgb_feature_page == HIDPP20_FEAT_RGB_EFFECTS) ? 3 : 0; + + blankFAPmessage response; + int result = SendAckedIntoFAP(caps.idx_rgb_effects, FN_8071_GET_INFO, + data, data_len, response); + + if(result <= 0) + { + caps.has_zone_effects = false; + return; + } + + unsigned int cluster_count; + + if(caps.rgb_feature_page == HIDPP20_FEAT_RGB_EFFECTS) + { + cluster_count = response.data[2]; + + /*-----------------------------------------------------*\ + | 0x8071 GetInfo response layout: | + | byte 2 numRgbZones | + | bytes 3-4 extendedCapabilities (BE16) | + | bytes 5-6 effectBlockCount (BE16) | + | byte 7 supportedClusterIndex | + | Logging the extra fields makes it easy to spot a | + | device whose enumerated effect list looks too short | + | relative to what it claims it can do. | + \*-----------------------------------------------------*/ + uint16_t ext_caps = ((uint16_t)response.data[3] << 8) | response.data[4]; + uint16_t effect_blocks = ((uint16_t)response.data[5] << 8) | response.data[6]; + uint8_t supported_idx = response.data[7]; + + LOG_INFO("%s RGBEffects 0x8071 V%u GetInfo: zones=%u extCaps=0x%04X effectBlocks=%u supportedClusterIdx=%u", + LOG_TAG, GetFeatureVersion(caps.rgb_feature_page), + cluster_count, ext_caps, effect_blocks, supported_idx); + } + else + { + cluster_count = response.data[0]; + LOG_INFO("%s RGB feature page=0x%04X V%u cluster_count=%u", + LOG_TAG, caps.rgb_feature_page, + GetFeatureVersion(caps.rgb_feature_page), cluster_count); + } + + /*---------------------------------------------------------*\ + | GetRgbClusterInfo for each cluster | + \*---------------------------------------------------------*/ + for(unsigned int i = 0; i < cluster_count; i++) + { + HIDPP20ZoneCluster cluster; + cluster.index = i; + + if(caps.rgb_feature_page == HIDPP20_FEAT_RGB_EFFECTS) + { + uint8_t query[2] = { (uint8_t)i, 0xFF }; + result = SendAckedIntoFAP(caps.idx_rgb_effects, FN_8071_GET_INFO, + query, 2, response); + } + else + { + uint8_t query[2] = { (uint8_t)i, 0x00 }; + result = SendAckedIntoFAP(caps.idx_rgb_effects, LOGITECH_CMD_RGB_EFFECTS_GET_INFO, + query, 2, response); + } + + if(result <= 0) + { + continue; + } + + if(caps.rgb_feature_page == HIDPP20_FEAT_RGB_EFFECTS) + { + cluster.location = (response.data[2] << 8) | response.data[3]; + cluster.effect_count = response.data[4]; + } + else + { + cluster.location = (response.data[1] << 8) | response.data[2]; + cluster.effect_count = response.data[3]; + } + + LOG_INFO("%s Cluster %d: location=0x%04X effects=%d", + LOG_TAG, i, cluster.location, cluster.effect_count); + + /*------------------------------------------------------*\ + | GetEffectInfo for each effect in this cluster | + \*------------------------------------------------------*/ + for(unsigned int j = 0; j < cluster.effect_count; j++) + { + HIDPP20Effect effect; + effect.index = j; + + uint8_t eff_query[4] = { (uint8_t)i, (uint8_t)j, 0x00, 0x00 }; + uint8_t eff_fn = (caps.rgb_feature_page == HIDPP20_FEAT_RGB_EFFECTS) + ? FN_8071_GET_INFO : LOGITECH_FP8070_EFFECT_INFO; + result = SendAckedIntoFAP(caps.idx_rgb_effects, eff_fn, + eff_query, 4, response); + + if(result <= 0) + { + continue; + } + + effect.effect_id = (response.data[2] << 8) | response.data[3]; + effect.capabilities = (response.data[4] << 8) | response.data[5]; + effect.default_period = (response.data[6] << 8) | response.data[7]; + + LOG_INFO("%s Effect %d: id=0x%04X caps=0x%04X default_period=%dms", + LOG_TAG, j, effect.effect_id, effect.capabilities, effect.default_period); + + cluster.effects.push_back(effect); + } + + caps.zone_clusters.push_back(cluster); + } + + caps.has_zone_effects = !caps.zone_clusters.empty(); + + /*---------------------------------------------------------*\ + | Probe for device-firmware effect cards. Only defined on | + | the 0x8071 RGBEffects path — 0x8070 and 0x0600 don't | + | expose GetEffectSpecificInfo in the same form. | + \*---------------------------------------------------------*/ + DiscoverEffectCards(); +} + +void LogitechHIDPP20Controller::DiscoverEffectCards() +{ + /*---------------------------------------------------------*\ + | Probes the device for the presence of firmware-resident | + | effect cards via 0x8071 fn0 GetEffectSpecificInfo. On | + | devices that have them (observed on G502 X PLUS), every | + | valid card returns a device-wide template byte pair at a | + | fixed position in page 1 of the response — the vendor app | + | reads those bytes and echoes them into the per-key prep | + | call's `SetEffectByIndex` params[6..7]. Our | + | implementation does the same. | + | | + | Request format for GetEffectSpecificInfo (0x8071 fn0): | + | [0xFF, effectIdHi, 0x01, effectIdLo, pageIndex] | + | | + | Response layout in blankFAPmessage::data[] terms (i.e. | + | starting AFTER the 4-byte HID++ header | + | report_id/dev_idx/feat_idx/func_byte): | + | | + | data[0..4] 5-byte prefix | + | [0] 0xFF echo of subfn marker | + | [1] echo of effectIdHi | + | [2] 0x01 echo of static constant | + | [3] 0x00 static zero (NOT an echo of effectIdLo) | + | [4] 0x00 static zero (NOT an echo of pageIndex) | + | data[5..15] 11-byte page payload | + | [5..6] header (0x00 0x00) | + | [7..8] firmware card ID (BE16; differs per card) | + | [9] pad | + | [10..11] device-wide template bytes (our target) | + | [12..15] trailing zeros | + | | + | Devices without effect cards return InvalidArgument for | + | any effectIdLo; we detect that as a non-positive result | + | and leave caps.has_effect_cards = false so the per-key | + | prep falls back to the Static-pass-through path. | + \*---------------------------------------------------------*/ + caps.has_effect_cards = false; + caps.effect_card_template[0] = 0; + caps.effect_card_template[1] = 0; + + if(caps.idx_rgb_effects == 0 || + caps.rgb_feature_page != HIDPP20_FEAT_RGB_EFFECTS || + !device_online.load()) + { + return; + } + + /*----------------------------------------------------------*\ + | Query card at effectIdLo=0, page 1. Any valid card works — | + | the template bytes are device-wide and identical across | + | every card on the device — so using card 0 is simplest. | + \*----------------------------------------------------------*/ + uint8_t query[5] = { 0xFF, 0x00, 0x01, 0x00, 0x01 }; + blankFAPmessage response; + int result = SendAckedIntoFAP( + caps.idx_rgb_effects, + FN_8071_GET_INFO, + query, sizeof(query), + response, + HIDPP20_POLICY_PROBE); + + if(result <= 0) + { + LOG_DEBUG("%s DiscoverEffectCards: no effect cards on this device " + "(result=%d)", LOG_TAG, result); + return; + } + + caps.has_effect_cards = true; + caps.effect_card_template[0] = response.data[10]; + caps.effect_card_template[1] = response.data[11]; + + LOG_INFO("%s Effect cards present: template bytes = 0x%02X 0x%02X " + "(card firmware_id=0x%02X%02X, full data[0..15] = " + "%02X %02X %02X %02X %02X %02X %02X %02X " + "%02X %02X %02X %02X %02X %02X %02X %02X)", + LOG_TAG, + caps.effect_card_template[0], caps.effect_card_template[1], + response.data[7], response.data[8], + response.data[0], response.data[1], response.data[2], + response.data[3], response.data[4], response.data[5], + response.data[6], response.data[7], response.data[8], + response.data[9], response.data[10], response.data[11], + response.data[12], response.data[13], response.data[14], + response.data[15]); +} + +/*---------------------------------------------------------*\ +| Feature 0x0620 Headset RGB Hostmode (Centurion G522 / | +| PRO X 2). Separate feature from 0x8071/0x0600/0x8070 — | +| no effect cards, no SetSWControl, no power management. | +| | +| Zone enumeration is best-effort from fn1 GetRGBZoneInfo. | +| Falls back to {0x00, 0x01} (two earcups) if decode fails. | +\*---------------------------------------------------------*/ +void LogitechHIDPP20Controller::DiscoverHeadsetRGBHostmode() +{ + caps.idx_headset_rgb_hostmode = GetFeatureIndex(HIDPP20_FEAT_HEADSET_RGB_HOSTMODE); + + if(caps.idx_headset_rgb_hostmode == 0) + { + return; + } + + LOG_INFO("%s 0x0620 V%u Headset RGB Hostmode present at feature index %u", + LOG_TAG, + GetFeatureVersion(HIDPP20_FEAT_HEADSET_RGB_HOSTMODE), + caps.idx_headset_rgb_hostmode); + + /*---------------------------------------------------------*\ + | fn1 GetRGBZoneInfo — empty request, returns a zone list. | + | Exact packing is not fully pinned down by the protocol | + | doc; log the raw response so a tester's log is enough to | + | refine the decoder. | + \*---------------------------------------------------------*/ + blankFAPmessage response; + int result = SendAckedIntoFAP(caps.idx_headset_rgb_hostmode, + FN_0620_GET_RGB_ZONE_INFO, + nullptr, 0, response); + + caps.headset_rgb_hostmode_zone_ids.clear(); + + if(result > 0) + { + LOG_INFO("%s 0x0620 fn1 GetRGBZoneInfo raw: " + "%02X %02X %02X %02X %02X %02X %02X %02X " + "%02X %02X %02X %02X %02X %02X %02X %02X", + LOG_TAG, + response.data[0], response.data[1], response.data[2], + response.data[3], response.data[4], response.data[5], + response.data[6], response.data[7], response.data[8], + response.data[9], response.data[10], response.data[11], + response.data[12], response.data[13], response.data[14], + response.data[15]); + + /*------------------------------------------------------*\ + | First-pass decode: byte 0 = zone count, bytes 1..N = | + | zone IDs. Bounds-check against the 16-byte data | + | window. Refine once we see real G522 output. | + \*------------------------------------------------------*/ + uint8_t zone_count = response.data[0]; + + if(zone_count > 0 && zone_count <= 15) + { + for(uint8_t i = 0; i < zone_count; i++) + { + caps.headset_rgb_hostmode_zone_ids.push_back(response.data[1 + i]); + } + } + } + else + { + LOG_DEBUG("%s 0x0620 fn1 GetRGBZoneInfo failed (result=%d)", + LOG_TAG, result); + } + + if(caps.headset_rgb_hostmode_zone_ids.empty()) + { + LOG_INFO("%s 0x0620 zone decode produced 0 zones — falling back to " + "{0x00, 0x01} (two-earcup layout)", LOG_TAG); + caps.headset_rgb_hostmode_zone_ids.push_back(0x00); + caps.headset_rgb_hostmode_zone_ids.push_back(0x01); + } + + /*---------------------------------------------------------*\ + | Synthesize a single zone cluster so the existing | + | RGBController zone UI lights up with no special-casing. | + | The 0x0620 path is static-color-only; no effect cards, no | + | per-key. One cluster, one LED per discovered zone. | + \*---------------------------------------------------------*/ + HIDPP20ZoneCluster cluster; + cluster.index = 0; + cluster.location = 0; + cluster.effect_count = 0; + caps.zone_clusters.clear(); + caps.zone_clusters.push_back(cluster); + + caps.is_headset_rgb_hostmode = true; + caps.has_zone_effects = true; + caps.rgb_feature_page = HIDPP20_FEAT_HEADSET_RGB_HOSTMODE; + + /*---------------------------------------------------------*\ + | Pin device type to HEADSET. 0x0620 presence is a headset | + | signal and Centurion sub-devices otherwise show type=0. | + | DiscoverDeviceType ran earlier in the probe sequence, so | + | pin it here where we have the evidence. | + \*---------------------------------------------------------*/ + caps.device_type = LOGITECH_DEVICE_TYPE_HEADSET; + + LOG_INFO("%s 0x0620 ready: %zu zone(s), transient (FrameEnd 0x01) mode", + LOG_TAG, caps.headset_rgb_hostmode_zone_ids.size()); +} + +void LogitechHIDPP20Controller::DiscoverPerKeyZones() +{ + /*---------------------------------------------------------*\ + | Try 0x8081 first, fall back to 0x8080 | + \*---------------------------------------------------------*/ + caps.idx_perkey_v2 = GetFeatureIndex(HIDPP20_FEAT_PER_KEY_LIGHTING_V2); + + if(caps.idx_perkey_v2 == 0) + { + caps.idx_perkey_v1 = GetFeatureIndex(HIDPP20_FEAT_PER_KEY_LIGHTING_V1); + } + + uint8_t perkey_idx = (caps.idx_perkey_v2 != 0) ? caps.idx_perkey_v2 : caps.idx_perkey_v1; + + if(perkey_idx == 0) + { + caps.has_perkey = false; + return; + } + + /*----------------------------------------------------------*\ + | Paginated GetInfo enumeration. | + | | + | typeOfInfo is a page index, not a redundant probe. Per | + | the 0x8081 spec the device's zone space is up to 336 IDs | + | organized as three pages of 112 bits each: | + | | + | zone_id = (page * 112) + (byte * 8) + bit | + | | + | An earlier version of this code only queried page 0 on | + | the assumption that all pages echoed the same data. That | + | was wrong — G515 TKL happened to concentrate its zones | + | in page 0 so the bug was invisible, but devices with | + | G-keys, lightbars, media, or logo LEDs report those | + | zones in pages 1 and 2 and were being silently dropped. | + \*----------------------------------------------------------*/ + caps.perkey_zone_ids.clear(); + + size_t page_counts[3] = { 0, 0, 0 }; + + for(uint8_t page = 0; page < 3; page++) + { + /*------------------------------------------------------*\ + | Request body: uint16 BE typeOfInfo + 1 pad byte. | + | Short report carries the 3 bytes at buf[4..6], so | + | { 0x00, page, 0x00 } places page in the low byte of | + | the BE field. | + \*------------------------------------------------------*/ + uint8_t query[3] = { 0x00, page, 0x00 }; + blankFAPmessage response; + int result = SendAckedIntoFAP(perkey_idx, FN_8081_GET_INFO, + query, 3, response); + + if(result <= 0) + { + continue; + } + + /*------------------------------------------------------*\ + | Parse 14-byte bitmap (bytes 2..15 of the response). | + | LSB-first bit order within each byte. Skip zone 0 on | + | page 0 (matches prior behavior; zone 0 is not used). | + \*------------------------------------------------------*/ + const uint8_t* bitmap = response.data + 2; + int start_bit = (page == 0) ? 1 : 0; + + for(int bit_in_page = start_bit; bit_in_page < 112; bit_in_page++) + { + int byte_idx = bit_in_page / 8; + int bit_idx = bit_in_page % 8; + + if(bitmap[byte_idx] & (1 << bit_idx)) + { + uint16_t zone_id = (uint16_t)(page * 112 + bit_in_page); + + /*----------------------------------------------*\ + | Wire protocol 0x8081 Set* functions take a | + | uint8_t zone ID. Zones >255 from the bitmap | + | formula can't actually be addressed — drop | + | them so we don't expose phantom LEDs. | + \*----------------------------------------------*/ + if(zone_id > 255) + { + LOG_WARNING("%s Per-key GetInfo page %u reported " + "unreachable zone %u (wire protocol " + "caps zones at 255); ignoring", + LOG_TAG, page, zone_id); + continue; + } + + caps.perkey_zone_ids.push_back(zone_id); + page_counts[page]++; + } + } + } + + caps.has_perkey = !caps.perkey_zone_ids.empty(); + + /*----------------------------------------------------------*\ + | Detect numpad presence from zone bitmask. | + | Numpad zones are 80-96 in Solaar's KEYCODES numbering. | + \*----------------------------------------------------------*/ + caps.has_numpad = false; + + for(uint16_t zid : caps.perkey_zone_ids) + { + if(zid >= 80 && zid <= 96) + { + caps.has_numpad = true; + break; + } + } + + LOG_VERBOSE("%s Per-key zones discovered: %zu total " + "(page0=%zu, page1=%zu, page2=%zu, numpad=%s)", + LOG_TAG, caps.perkey_zone_ids.size(), + page_counts[0], page_counts[1], page_counts[2], + caps.has_numpad ? "yes" : "no"); +} + +void LogitechHIDPP20Controller::DiscoverKeyboardLayout() +{ + uint8_t idx = GetFeatureIndex(HIDPP20_FEAT_KEYBOARD_LAYOUT); + + if(idx == 0) + { + caps.keyboard_layout_code = 0; + return; + } + + uint8_t recv_data[16] = {}; + int result = SendAcked(idx, 0x00, nullptr, 0, recv_data, sizeof(recv_data)); + + if(result > 0) + { + caps.keyboard_layout_code = recv_data[0]; + LOG_DEBUG("%s Keyboard layout code: %d", LOG_TAG, caps.keyboard_layout_code); + } + else + { + caps.keyboard_layout_code = 0; + } +} + +/*---------------------------------------------------------*\ +| Probe / Initialize / Shutdown | +\*---------------------------------------------------------*/ + +bool LogitechHIDPP20Controller::Probe() +{ + LOG_DEBUG("%s Probing device at %s (index=0x%02X)", + LOG_TAG, location.c_str(), device_index); + + /*-----------------------------------------------------------*\ + | Detect transport type from usage page before anything else. | + | Centurion devices need different framing for all commands. | + \*-----------------------------------------------------------*/ + DiscoverTransport(); + + /*----------------------------------------------------------*\ + | Flush any queued HID reports before probing. | + | The device may have unsolicited notifications (battery, | + | button events, etc.) sitting in the read buffer. | + \*----------------------------------------------------------*/ + { + uint8_t flush_buf[64]; + int flushed = 0; + + while(flushed < 20) + { + int r = hid_read_timeout(dev, flush_buf, sizeof(flush_buf), 0); + + if(r <= 0) + { + break; + } + + flushed++; + } + + if(flushed > 0) + { + LOG_DEBUG("%s Flushed %d queued reports", LOG_TAG, flushed); + } + } + + /*----------------------------------------------------------*\ + | Test IRoot by looking up a known feature. | + | | + | Standard HID++: look up FeatureSet (0x0001) — must exist. | + | Centurion dongle: look up CentPPBridge (0x0003) — the | + | dongle doesn't have FeatureSet, but must have the | + | bridge to reach the sub-device. | + | | + | Retry up to 3 times — wireless devices behind a shared | + | receiver can return stale responses. | + \*----------------------------------------------------------*/ + uint8_t test_idx = 0; + + if(transport.type == HIDPP20_TRANSPORT_CENTURION) + { + /*------------------------------------------------------*\ + | Centurion: try CentPPBridge (0x0003) first for dongle. | + | If not found, try FeatureSet (0x0001) for wired/direct | + | connection where the device IS the endpoint. | + | | + | This is the "is anyone there?" check — use the fast- | + | fail probe policy so non-Centurion or unreachable | + | devices bail in ~500ms instead of ~6s. Once we have a | + | positive response, subsequent discovery uses reliable. | + \*------------------------------------------------------*/ + test_idx = GetFeatureIndex(HIDPP20_FEAT_CENTPPBRIDGE, HIDPP20_POLICY_PROBE); + + if(test_idx != 0) + { + transport.bridge_feat_idx = test_idx; + transport.sub_device_id = 0; + + LOG_DEBUG("%s CentPPBridge at index %d — routing to sub-device", + LOG_TAG, test_idx); + + /*---------------------------------------------------*\ + | Pre-check sub-device availability via | + | getConnectionInfo (CentPPBridge fn0). The vendor | + | app does this and refuses to call sendFragment | + | when MTU=0. | + | | + | Response format (from protocol doc line 910-917): | + | Byte 0: high nibble = connection type/state | + | low nibble + Byte 1 = sub-device data | + | length / MTU | + | Bytes 2+: sub-device descriptors | + | | + | If MTU == 0, no sub-device is connected. Calling | + | sendFragment in that state triggers an undocumented | + | error code 0x0B and wastes the full retry budget. | + | Skip enumeration and let the dongle-watcher path | + | take over until ConnectionStateChangedEvent fires. | + | | + | Bridge is confirmed responsive at this point — | + | use reliable policy for the MTU check. | + \*---------------------------------------------------*/ + uint8_t mtu_recv[16] = {}; + int mtu_result = SendAcked(test_idx, 0x00, + nullptr, 0, + mtu_recv, sizeof(mtu_recv)); + + if(mtu_result > 0) + { + transport.bridge_mtu = + ((uint16_t)(mtu_recv[0] & 0x0F) << 8) | mtu_recv[1]; + + LOG_DEBUG("%s CentPPBridge MTU=%u (%s)", + LOG_TAG, transport.bridge_mtu, + transport.bridge_mtu > 0 ? "sub-device present" + : "no sub-device"); + + if(transport.bridge_mtu == 0) + { + /*----------------------------------------*\ + | No sub-device — skip enumeration. Mark | + | the feature map complete so on-demand | + | lookups don't hit the wire. The dongle | + | will be registered as a watcher and the | + | sub-device will be probed when | + | ConnectionStateChangedEvent fires. | + \*----------------------------------------*/ + caps.feature_map_complete = true; + DiscoverDeviceName(); + return true; + } + } + else + { + LOG_DEBUG("%s CentPPBridge getConnectionInfo failed (result=%d)", + LOG_TAG, mtu_result); + } + } + else + { + LOG_DEBUG("%s No CentPPBridge — Centurion direct connection", LOG_TAG); + test_idx = GetFeatureIndex(HIDPP20_FEAT_FEATURE_SET, HIDPP20_POLICY_PROBE); + } + } + else + { + /*-------------------------------------------------------*\ + | Standard HID++: probe FeatureSet (0x0001) — fast-fail. | + | The probe policy already includes its own retry; the | + | outer loop is preserved for buffer-flushing behavior | + | between attempts. | + \*-------------------------------------------------------*/ + for(int attempt = 0; attempt < 3 && test_idx == 0; attempt++) + { + if(attempt > 0) + { + uint8_t retry_buf[64]; + + while(hid_read_timeout(dev, retry_buf, sizeof(retry_buf), 10) > 0) + { + } + + LOG_DEBUG("%s IRoot retry %d at %s", LOG_TAG, attempt + 1, location.c_str()); + } + + test_idx = GetFeatureIndex(HIDPP20_FEAT_FEATURE_SET, HIDPP20_POLICY_PROBE); + } + } + + if(test_idx == 0) + { + LOG_DEBUG("%s IRoot probe failed at %s — device does not respond", + LOG_TAG, location.c_str()); + return false; + } + + /*----------------------------------------------------------*\ + | If retries were needed, flush delayed responses from | + | failed attempts before continuing with discovery. | + \*----------------------------------------------------------*/ + { + uint8_t post_buf[64]; + + while(hid_read_timeout(dev, post_buf, sizeof(post_buf), 10) > 0) + { + } + } + + /*-----------------------------------------------------------*\ + | Enumerate all features in bulk. For standard HID++, uses | + | FeatureSet GetCount + GetFeatureId loop. For Centurion | + | sub-devices, uses bulk GetFeatureId (single response). | + | After this, GetFeatureIndex uses the map — no wire traffic. | + \*-----------------------------------------------------------*/ + { + /*------------------------------------------------------*\ + | For Centurion bridged, FeatureSet is at index 1 on the | + | sub-device. For standard HID++, test_idx is the | + | FeatureSet index from the IRoot probe. | + \*------------------------------------------------------*/ + uint8_t fs_idx = (transport.type == HIDPP20_TRANSPORT_CENTURION && + transport.bridge_feat_idx != 0) + ? 1 // CenturionFeatureSet always at index 1 on sub-device + : test_idx; + + EnumerateFeatures(fs_idx); + + /*------------------------------------------------------*\ + | If bridged and bulk enumeration failed, the sub-device | + | isn't reachable (e.g., headset off or on USB cable). | + | Mark map as complete so lookups don't hit the wire. | + | The device will be discovered with no features — it | + | can be re-probed when the sub-device comes online. | + \*------------------------------------------------------*/ + if(transport.bridge_feat_idx != 0 && !caps.feature_map_complete) + { + LOG_DEBUG("%s Sub-device not reachable through bridge — dongle only", + LOG_TAG); + caps.feature_map_complete = true; + } + } + + /*----------------------------------------------------------*\ + | Discover device identity. | + | On Centurion with bridge, this now queries the sub-device | + | (headset) through the bridge, not the dongle. | + \*----------------------------------------------------------*/ + DiscoverDeviceName(); + log_tag = "[LogitechHID++ " + caps.device_name + "]"; + DiscoverDeviceType(); + DiscoverFirmwareInfo(); + + /*---------------------------------------------------------*\ + | Discover profile management features | + \*---------------------------------------------------------*/ + caps.idx_profile_management = GetFeatureIndex(HIDPP20_FEAT_PROFILE_MANAGEMENT); + caps.idx_onboard_profiles = GetFeatureIndex(HIDPP20_FEAT_ONBOARD_PROFILES); + caps.idx_disable_keys_by_usage = GetFeatureIndex(HIDPP20_FEAT_DISABLE_KEYS_BY_USAGE); + + /*---------------------------------------------------------*\ + | Discover RGB capabilities | + \*---------------------------------------------------------*/ + DiscoverRGBEffects(); + if(caps.idx_rgb_effects == 0) + { + DiscoverHeadsetRGBHostmode(); + } + DiscoverPerKeyZones(); + DiscoverKeyboardLayout(); + + /*---------------------------------------------------------*\ + | Probe WirelessStatus (0x1D4B) for reconnect detection. | + | Lightspeed devices behind kernel-managed receivers send | + | WirelessStatus events when they reconnect after power | + | cycle. Cache the feature index so the reader thread can | + | detect these events without sending commands. | + \*---------------------------------------------------------*/ + caps.idx_wireless_status = GetFeatureIndex(HIDPP20_FEAT_WIRELESS_STATUS); + + if(!caps.has_zone_effects && !caps.has_perkey) + { + LOG_DEBUG("%s %s: no RGB features found", LOG_TAG, caps.device_name.c_str()); + + /*------------------------------------------------------*\ + | Centurion dongles with bridge stay alive to watch for | + | sub-device connection events, even without RGB. | + \*------------------------------------------------------*/ + if(transport.bridge_feat_idx != 0) + { + return true; + } + + return false; + } + + LOG_VERBOSE("%s %s: zones=%zu perkey=%zu", + LOG_TAG, caps.device_name.c_str(), + caps.zone_clusters.size(), caps.perkey_zone_ids.size()); + + return true; +} + +void LogitechHIDPP20Controller::Initialize() +{ + /*-----------------------------------------------------------*\ + | No device state changes here — let firmware effects keep | + | running until DeviceUpdateMode claims control with real | + | colors ready via ClaimSWControlIfNeeded(). | + \*-----------------------------------------------------------*/ + init_generation++; + initialized = true; +} + +void LogitechHIDPP20Controller::Shutdown() +{ + if(!initialized) + { + return; + } + + StopPowerManager(); + + /*---------------------------------------------------------*\ + | Release SW control | + \*---------------------------------------------------------*/ + if(caps.idx_rgb_effects != 0) + { + SetSWControl(0, 0); + } + + /*---------------------------------------------------------*\ + | Restore firmware mode | + \*---------------------------------------------------------*/ + if(caps.idx_profile_management != 0) + { + uint8_t data[1] = { 0x03 }; + blankFAPmessage response; + SendAckedIntoFAP(caps.idx_profile_management, FN_8101_GET_SET_MODE, + data, 1, response); + } + else if(caps.idx_onboard_profiles != 0) + { + uint8_t data[1] = { 0x01 }; + blankFAPmessage response; + SendAckedIntoFAP(caps.idx_onboard_profiles, FN_8100_SET_ONBOARD_MODE, + data, 1, response); + } + + /*---------------------------------------------------------*\ + | Release 0x0620 Headset RGB hostmode claim. Best-effort; | + | mirrors the SetHostMode() additive branch. | + \*---------------------------------------------------------*/ + if(caps.idx_headset_rgb_hostmode != 0) + { + uint8_t off = 0x00; + blankFAPmessage release_response; + SendAckedIntoFAP(caps.idx_headset_rgb_hostmode, + FN_0620_SET_HOST_MODE_STATE, + &off, 1, release_response); + } + + initialized = false; +} + +/*---------------------------------------------------------*\ +| Accessors | +\*---------------------------------------------------------*/ + +const HIDPP20DeviceCapabilities& LogitechHIDPP20Controller::GetCapabilities() const +{ + return caps; +} + +std::string LogitechHIDPP20Controller::GetDeviceLocation() +{ + return "HID: " + location; +} + +std::string LogitechHIDPP20Controller::GetSerialString() +{ + return caps.serial_number; +} + +uint32_t LogitechHIDPP20Controller::GetInitGeneration() const +{ + return init_generation; +} + +/*---------------------------------------------------------*\ +| SW Control and Power | +\*---------------------------------------------------------*/ + +int LogitechHIDPP20Controller::SetSWControl(uint8_t mode, uint8_t flags) +{ + if(caps.idx_rgb_effects == 0) + { + return 0; + } + + blankFAPmessage response; + int result; + + if(caps.sw_control_simple) + { + /*------------------------------------------------------*\ + | 0x8070: simple [enabled(bool), persist(bool)] | + \*------------------------------------------------------*/ + uint8_t data[2] = { (uint8_t)(mode > 0 ? 0x01 : 0x00), 0x00 }; + result = SendAckedIntoFAP(caps.idx_rgb_effects, caps.fn_sw_control, + data, 2, response); + } + else + { + /*------------------------------------------------------*\ + | 0x8071/0x0600: [0x01(set), mode, flags] | + \*------------------------------------------------------*/ + uint8_t data[3] = { 0x01, mode, flags }; + result = SendAckedIntoFAP(caps.idx_rgb_effects, caps.fn_sw_control, + data, 3, response); + } + + LOG_DEBUG("%s SetSWControl mode=%d flags=0x%02X result=%d", + LOG_TAG, mode, flags, result); + + return result; +} + +void LogitechHIDPP20Controller::SetRGBPowerMode(uint8_t mode) +{ + if(caps.idx_rgb_effects == 0 || !caps.has_power_mgmt) + { + return; + } + + uint8_t data[2] = { 0x01, mode }; + blankFAPmessage response; + SendAckedIntoFAP(caps.idx_rgb_effects, caps.fn_pwr_mode, + data, 2, response); + + LOG_DEBUG("%s SetRGBPowerMode mode=%d", LOG_TAG, mode); +} + +void LogitechHIDPP20Controller::SetHostMode() +{ + if(caps.idx_profile_management != 0) + { + uint8_t data[1] = { 0x05 }; + blankFAPmessage response; + SendAckedIntoFAP(caps.idx_profile_management, FN_8101_GET_SET_MODE, + data, 1, response); + + LOG_DEBUG("%s ProfileManagement set to host mode", LOG_TAG); + } + else if(caps.idx_onboard_profiles != 0) + { + /*-------------------------------------------------------*\ + | Observed vendor-app wire pattern on G502 X PLUS: | + | unconditional SetOnboardMode(host) immediately | + | followed by a GetOnboardMode verify read. The vendor | + | app never reads first — it writes fn1 with 0x02 then | + | re-queries fn2, ignoring the response value (no retry | + | logic, no branching on it). The verify appears to be a | + | state-settle / sync point rather than a check, but | + | since we don't know its firmware-side effect, we mirror | + | it. | + | | + | An earlier revision of this function added a pre-Set | + | GetOnboardMode guard to skip the write when already | + | in host mode. That deviated from the observed wire | + | behavior, so it has been removed. | + \*-------------------------------------------------------*/ + uint8_t set_data[1] = { 0x02 }; + blankFAPmessage set_response; + SendAckedIntoFAP(caps.idx_onboard_profiles, FN_8100_SET_ONBOARD_MODE, + set_data, 1, set_response); + + blankFAPmessage verify_response; + SendAckedIntoFAP(caps.idx_onboard_profiles, FN_8100_GET_ONBOARD_MODE, + nullptr, 0, verify_response); + + LOG_DEBUG("%s OnboardProfiles set to host mode, verify=0x%02X", + LOG_TAG, verify_response.data[0]); + } + + /*-------------------------------------------------------*\ + | 0x0620 Headset RGB hostmode claim. Additive — a headset | + | exposing 0x0620 typically won't also have 0x8100/0x8101 | + | but we don't assume mutual exclusion. Sticky claim, not | + | re-issued per write; wake path re-enters SetHostMode | + | after reconnect which reinstates it for free. | + \*-------------------------------------------------------*/ + if(caps.idx_headset_rgb_hostmode != 0) + { + uint8_t on = 0x01; + blankFAPmessage claim_response; + SendAckedIntoFAP(caps.idx_headset_rgb_hostmode, + FN_0620_SET_HOST_MODE_STATE, + &on, 1, claim_response); + + LOG_DEBUG("%s 0x0620 SetHostModeState(1) sent", LOG_TAG); + } +} + +bool LogitechHIDPP20Controller::ClaimSWControlIfNeeded() +{ + if(sw_control_claimed) + { + return true; + } + + if(caps.idx_rgb_effects == 0 || !device_online.load()) + { + return false; + } + + /*----------------------------------------------------------*\ + | Two-phase claim to avoid the onboard→host transition | + | flash (warm-white ~3000K, ~50ms) visible on G502 X PLUS. | + | | + | The SW Control `flags` bits are not "Zone/Power/Effect" | + | as the overview labels them — derived from a G502 wire | + | capture, bit 0 = effect control, bit 1 = power management, | + | bit 2 = NV config. Setting the effect bit suspends the | + | firmware's autonomous effect engine, and anything the host | + | hasn't explicitly painted since that moment shows as the | + | firmware's default LED buffer — on the G502 X PLUS that | + | default is warm-white. | + | | + | The observed vendor-app behavior paints the G502 with | + | flags=6 (power+NV, NOT effect) the entire time, so the | + | firmware effect engine keeps rendering the onboard | + | profile's output right up to the moment SetEffectByIndex | + | replaces it — no visible gap. | + | | + | We can't just stay on flags=6 forever: the idle/wake | + | state machine in OnUserActivity uses flags=5/3 as its | + | active/idle signals and needs those specific values for | + | the firmware to generate the right onUserActivity events. | + | So we claim at flags=6, let the first per-key frame paint | + | through the transition, then upgrade to flags=5 only | + | after the per-key layer is active — at that point per-key | + | masks any zone output anyway, so the 6→5 transition is | + | invisible. | + | | + | The previous sequence wrote `(3,7)` then `(3,5)` to mimic | + | the vendor app's *keyboard first-contact* behavior. That | + | was right for the initial G515 bring-up but wrong for the | + | mouse — on the G502 X PLUS the effect bit at claim time | + | is the root cause of the startup flash. | + | | + | The vendor-app claim sequence does two WritePowerConfig | + | calls around SetHostMode, writing (a) its profile's sleep | + | value then (b) that value minus the firmware off-ramp. | + | We don't write timers at all: we don't have a profile we | + | want to impose on the device, and our host-side StartSleep | + | trigger already fires SetRgbPowerMode(3) explicitly at the | + | moment we want the fade to begin. | + \*----------------------------------------------------------*/ + int claim_result = SetSWControl(3, 6); + + if(claim_result <= 0) + { + LOG_DEBUG("%s SW control claim failed (SetSWControl(3,6) result=%d)", + LOG_TAG, claim_result); + return false; + } + + /*---------------------------------------------------------*\ + | Keyboard-family handshake on feature 0x4522 | + | (DisableKeysByUsage). G815 / G915 / G Pro send this fn3 + | + | fn1 empty-payload pair between SetSWControl and the first | + | mode write. Feature-gated inside — no-op on G502 / G515. | + \*---------------------------------------------------------*/ + DoDisableKeysByUsageHandshake(); + + SetRGBPowerMode(1); + WritePowerConfig(idle_timeout_s, sleep_timeout_s); + SetHostMode(); + WritePowerConfig(idle_timeout_s, sleep_timeout_s); + + written_idle_s = idle_timeout_s; + written_sleep_s = sleep_timeout_s; + + sw_control_claimed = true; + sw_control_needs_upgrade_to_5 = true; + + LOG_DEBUG("%s Claimed SW control at flags=6 " + "(effect engine still autonomous until first per-key frame)", + LOG_TAG); + return true; +} + +void LogitechHIDPP20Controller::UpgradeSwControlAfterFirstPaint() +{ + /*---------------------------------------------------------*\ + | Called by RGBController_LogitechHIDPP20::DeviceUpdateLEDs | + | immediately after the first successful PerKeyFrameEnd of | + | a newly-claimed session. At this point the per-key buffer | + | is populated with real host colors, so the per-key layer | + | masks the zone layer and the 6→5 transition no longer | + | exposes the firmware's default LED buffer. Upgrading to | + | flags=5 puts the device into the "active steady state" | + | that OnUserActivity expects for idle detection events. | + \*---------------------------------------------------------*/ + if(!sw_control_needs_upgrade_to_5) + { + return; + } + + if(caps.idx_rgb_effects == 0 || !device_online.load()) + { + sw_control_needs_upgrade_to_5 = false; + return; + } + + int result = SetSWControl(3, 5); + + if(result > 0) + { + sw_control_needs_upgrade_to_5 = false; + LOG_DEBUG("%s Upgraded SW control to flags=5 " + "(per-key layer now masks zone layer)", LOG_TAG); + } + else + { + LOG_DEBUG("%s SW control upgrade to flags=5 failed (result=%d)", + LOG_TAG, result); + /* Leave the flag set so the next frame will retry. */ + } +} + +void LogitechHIDPP20Controller::DoDisableKeysByUsageHandshake() +{ + /*----------------------------------------------------------*\ + | G815 / G915 / G Pro keyboards send this two-call | + | handshake on feature 0x4522 (DisableKeysByUsage) before | + | any mode change or per-key write. The original OpenRGB | + | G815 + G915 controllers both do it in their BeginModeSet | + | and InitializeDirect paths. Both payloads are empty — | + | bare function calls — suggesting they're state reads | + | used as a firmware sync point, not actual disable-keys | + | writes (those would require a keyset in the payload). | + | | + | Feature-gated: caps.idx_disable_keys_by_usage is only | + | non-zero on devices that enumerate 0x4522. G502 and G515 | + | do not enumerate it, so this is a no-op on those. | + \*----------------------------------------------------------*/ + if(caps.idx_disable_keys_by_usage == 0 || !device_online.load()) + { + return; + } + + blankFAPmessage response; + SendAckedIntoFAP(caps.idx_disable_keys_by_usage, 0x30, + nullptr, 0, response, HIDPP20_POLICY_PROBE); + SendAckedIntoFAP(caps.idx_disable_keys_by_usage, 0x10, + nullptr, 0, response, HIDPP20_POLICY_PROBE); + + LOG_DEBUG("%s 0x4522 DisableKeysByUsage handshake sent (fn3 + fn1)", LOG_TAG); +} + +/*---------------------------------------------------------*\ +| Observed per-key prep sequence | +| | +| Two SetEffectByIndex calls cloned byte-for-byte from a | +| wire capture of the vendor app talking to a G502 X PLUS | +| (wired-ish connection via Lightspeed receiver). The two | +| frames are: | +| | +| Frame 2297 (17.348s, ~262ms after SetOnboardMode(02)): | +| 1101091a ff 02 00 00 00 00 00 00 20 64 00 00 01 … | +| RgbEffects.SetEffectByIndex | +| cluster=0xFF (all clusters) | +| effectIdx=0x02 (Breathing on G502's enumerated set) | +| params=[00 00 00 00 00 00 20 64 00 00] (10 bytes) | +| — positions [6]=0x20, [7]=0x64 are non-zero. The | +| Breathing effect parameter layout documented in | +| the protocol reference has period/brightness in | +| those slots, but the exact meaning of these two | +| values in this context is NOT understood. The | +| vendor app sends them verbatim on every claim; we | +| mirror. | +| persist=0x01 | +| | +| Frame 2321 (17.443s, ~95ms after frame 2297): | +| 1101091a ff 04 00 00 00 00 00 00 00 00 00 00 01 … | +| RgbEffects.SetEffectByIndex | +| cluster=0xFF | +| effectIdx=0x04 — OUT OF RANGE on G502 X PLUS (the | +| device only enumerates effects 0..3 via | +| GetEffectInfo). Likely a "custom / direct mode" | +| slot the firmware accepts but does not advertise | +| through the normal enumeration. | +| params=[00 × 10] | +| persist=0x01 | +| | +| The function we call is caps.fn_set_effect (0x10 on | +| 0x8071, 0x30 on 0x8070, same as SetZoneEffect uses). | +| | +| We do NOT attempt to derive these values from the effect | +| param layout tables because we don't understand what | +| they mean. They're observed-working bytes from the wire | +| capture and that's the contract. If this prep sequence | +| later turns out to work on other devices, the gating in | +| DeviceUpdateLEDs can be loosened. | +\*---------------------------------------------------------*/ + +void LogitechHIDPP20Controller::DoObservedPerKeyPrep() +{ + if(caps.idx_rgb_effects == 0 || !device_online.load()) + { + return; + } + + /*----------------------------------------------------------*\ + | Prep1: SetEffectByIndex(cluster=0xFF, effectIdx=2, params) | + | with the device-wide template bytes at params[6..7]. | + | | + | The template bytes are discovered at feature-discovery | + | time via GetEffectSpecificInfo on any firmware effect | + | card; the vendor app does the same read-then-echo | + | pattern, and on a G502 X PLUS the read value is 0x20 0x64 | + | across every card. We don't know what those bytes mean | + | semantically — just that the device expects to see them | + | echoed back verbatim in this position when priming the | + | firmware effect engine for per-key takeover. | + \*----------------------------------------------------------*/ + uint8_t prep1[16] = + { + 0xFF, 0x02, /* cluster, effectIdx */ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /* params[0..5] */ + caps.effect_card_template[0], /* params[6] — device */ + caps.effect_card_template[1], /* params[7] — device */ + 0x00, 0x00, /* params[8..9] */ + 0x01, /* persist */ + 0x00, 0x00, 0x00 /* padding */ + }; + blankFAPmessage prep1_resp; + SendAckedIntoFAP(caps.idx_rgb_effects, caps.fn_set_effect, + prep1, 16, prep1_resp); + + /*---------------------------------------------------------*\ + | Prep2: SetEffectByIndex with effectIdx set to the first | + | out-of-range slot above the last enumerated effect, all | + | params zero. | + | | + | On the G502 X PLUS (4 enumerated effects: 0..3) this | + | means effectIdx=4 — matches the value in pcap frame 2321. | + | On other devices, effectIdx is parameterized by effect | + | count so the same "first OOR slot" semantic holds. | + | | + | The RE thread's working theory is that this is a firmware | + | "custom/direct mode" slot the effect engine accepts but | + | doesn't advertise through GetEffectInfo. Without that | + | slot being written, the per-key pipeline doesn't enter | + | cleanly and the firmware exposes its default LED state | + | during the claim→paint window (the 3000K warm-white flash | + | we previously observed on cold starts). | + \*---------------------------------------------------------*/ + uint8_t num_effects = 0; + + if(!caps.zone_clusters.empty()) + { + size_t count = caps.zone_clusters[0].effects.size(); + num_effects = (count > 0xFFu) ? 0xFFu : (uint8_t)count; + } + + uint8_t prep2[16] = + { + 0xFF, num_effects, /* cluster, first OOR slot */ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /* params[0..5] */ + 0x00, 0x00, 0x00, 0x00, /* params[6..9] */ + 0x01, /* persist */ + 0x00, 0x00, 0x00 /* padding */ + }; + blankFAPmessage prep2_resp; + SendAckedIntoFAP(caps.idx_rgb_effects, caps.fn_set_effect, + prep2, 16, prep2_resp); + + LOG_DEBUG("%s DoObservedPerKeyPrep: prep1 template=0x%02X%02X " + "prep2 idx=%u (OOR slot above %u enumerated effects)", + LOG_TAG, + caps.effect_card_template[0], caps.effect_card_template[1], + num_effects, num_effects); +} + +void LogitechHIDPP20Controller::DoKeyboardFamilyPerKeyPrep() +{ + /*---------------------------------------------------------*\ + | G815 / G915 / G Pro per-key takeover prep, cloned from | + | the InitializeDirect sequence in their legacy OpenRGB | + | controllers. Three steps after the claim-time 0x4522 | + | handshake (which fires from ClaimSWControlIfNeeded): | + | | + | 1. For each enumerated cluster, SetEffectByIndex with | + | effectIdx=0 (Off) and persist=1. This deactivates | + | the firmware effect engine per-cluster — different | + | from the G515 static-black fallback, which leaves | + | the effect engine running with a black static color. | + | | + | 2. Send a primer SetIndividualRgbZones write covering | + | one zone (the first enumerated) at black. G915 uses | + | Escape specifically; we use the first enumerated | + | zone for portability. | + | | + | 3. FrameEnd, so the primer write commits and the | + | per-key layer becomes the visible output. | + | | + | Gate (caller's responsibility): feature 0x4522 present | + | AND per-key V2 feature present. G502 / G515 fail the | + | 0x4522 side; older keyboards without 0x8081 fail the | + | per-key side. | + \*---------------------------------------------------------*/ + if(caps.idx_rgb_effects == 0 || caps.idx_perkey_v2 == 0 || !device_online.load()) + { + return; + } + + for(size_t i = 0; i < caps.zone_clusters.size(); i++) + { + uint8_t cluster_off[16] = + { + caps.zone_clusters[i].index, 0x00, /* cluster, effectIdx=0 (Off) */ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x01, /* persist */ + 0x00, 0x00, 0x00 + }; + blankFAPmessage cluster_resp; + SendAckedIntoFAP(caps.idx_rgb_effects, caps.fn_set_effect, + cluster_off, 16, cluster_resp); + } + + if(caps.perkey_zone_ids.empty()) + { + LOG_DEBUG("%s DoKeyboardFamilyPerKeyPrep: no per-key zones enumerated, " + "skipping primer key", LOG_TAG); + return; + } + + uint8_t primer_zone = (uint8_t)(caps.perkey_zone_ids[0] & 0xFF); + uint8_t primer[4] = { primer_zone, 0x00, 0x00, 0x00 }; + + std::vector primer_zones; + primer_zones.push_back(primer_zone); + SendPerKeyData(caps.idx_perkey_v2, FN_8081_SET_INDIVIDUAL, + primer, 4, primer_zones); + + PerKeyFrameEnd(); + + LOG_DEBUG("%s DoKeyboardFamilyPerKeyPrep: %zu clusters -> Off, " + "primer zone=0x%02X, FrameEnd committed", + LOG_TAG, caps.zone_clusters.size(), primer_zone); +} + +/*---------------------------------------------------------*\ +| Retry-paint scheduling | +| | +| Called by RGBController_LogitechHIDPP20::DeviceUpdateLEDs | +| when a full pass completes with `acked_zones.size() != | +| attempted_zones.size()` (partial commit). The retry | +| re-runs a whole DeviceUpdateLEDs cycle from the power | +| thread so the uncommitted zones (marked | +| HIDPP20_UNCOMMITTED in sent_colors) get another shot. | +| | +| Streaming animation frames also call ScheduleRetryPaint | +| on partial commit, but the next animation frame almost | +| always CancelRetryPaint()s before the deadline fires, | +| so the retry is a free no-op in the streaming path. | +| The retry only actually fires when no follow-up frame | +| arrives — which matches our two problem cases: | +| 1. First frame after a reconnect-transient claim | +| (Direct mode, no animation timer). | +| 2. Last frame of an animation that then stops. | +\*---------------------------------------------------------*/ + +void LogitechHIDPP20Controller::ScheduleRetryPaint() +{ + size_t max_attempts = + sizeof(HIDPP20_REPAINT_RETRY_BACKOFF_MS) / sizeof(uint16_t); + + uint8_t attempt = retry_paint_attempt_.load(); + + if(attempt >= max_attempts) + { + /*-----------------------------------------------------*\ + | Retry budget exhausted. Give up for this sequence — | + | the next fresh failure (after a full_commit clears | + | the attempt counter) will start from attempt 0. | + \*-----------------------------------------------------*/ + retry_paint_deadline_.store(std::chrono::steady_clock::time_point{}); + LOG_DEBUG("%s retry paint budget exhausted (%zu attempts)", + LOG_TAG, max_attempts); + return; + } + + uint16_t delay_ms = HIDPP20_REPAINT_RETRY_BACKOFF_MS[attempt]; + std::chrono::steady_clock::time_point deadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds(delay_ms); + + retry_paint_deadline_.store(deadline); + + LOG_DEBUG("%s retry paint scheduled attempt=%u delay=%ums", + LOG_TAG, attempt, delay_ms); +} + +void LogitechHIDPP20Controller::CancelRetryPaint() +{ + retry_paint_deadline_.store(std::chrono::steady_clock::time_point{}); + retry_paint_attempt_.store(0); +} + +void LogitechHIDPP20Controller::TickRetryPaintIfPending() +{ + /*---------------------------------------------------------*\ + | Called from the power thread's main loop each tick. | + | Checks the retry deadline and fires the repaint callback | + | when it expires. The callback runs DeviceUpdateLEDs on | + | the power thread's context — not recursively from inside | + | another DeviceUpdateLEDs call. | + \*---------------------------------------------------------*/ + std::chrono::steady_clock::time_point deadline = retry_paint_deadline_.load(); + + if(deadline == std::chrono::steady_clock::time_point{}) + { + return; + } + + if(std::chrono::steady_clock::now() < deadline) + { + return; + } + + /*---------------------------------------------------------*\ + | Clear the deadline before firing so a concurrent | + | ScheduleRetryPaint (from a different thread) doesn't | + | double-fire on the same tick. Advance the attempt counter | + | so the next ScheduleRetryPaint (if this retry also fails) | + | picks the next backoff slot. | + \*---------------------------------------------------------*/ + retry_paint_deadline_.store(std::chrono::steady_clock::time_point{}); + retry_paint_attempt_.fetch_add(1); + + LOG_DEBUG("%s retry paint firing", LOG_TAG); + + if(request_repaint_fn) + { + request_repaint_fn(); + } +} + +/*---------------------------------------------------------*\ +| Per-key lighting (0x8081) | +\*---------------------------------------------------------*/ + +void LogitechHIDPP20Controller::SetPerKeyColors + ( + const std::vector>& zone_colors + ) +{ + if(!device_online.load()) return; + + uint8_t perkey_idx = (caps.idx_perkey_v2 != 0) ? caps.idx_perkey_v2 : caps.idx_perkey_v1; + + if(perkey_idx == 0) + { + return; + } + + /*---------------------------------------------------------*\ + | Batch into SetIndividualRgbZones (fn1): 4 entries/packet | + | Each entry = [zone_id, R, G, B]. Track the zones in each | + | batch so PerKeyFrameEnd can report which committed. | + \*---------------------------------------------------------*/ + uint8_t data[16]; + std::vector batch_zones; + int count = 0; + + for(size_t i = 0; i < zone_colors.size(); i++) + { + int offset = count * 4; + data[offset + 0] = (uint8_t)zone_colors[i].first; + data[offset + 1] = RGBGetRValue(zone_colors[i].second); + data[offset + 2] = RGBGetGValue(zone_colors[i].second); + data[offset + 3] = RGBGetBValue(zone_colors[i].second); + batch_zones.push_back((uint8_t)zone_colors[i].first); + count++; + + if(count == 4 || i == zone_colors.size() - 1) + { + SendPerKeyData(perkey_idx, FN_8081_SET_INDIVIDUAL, + data, count * 4, batch_zones); + memset(data, 0, sizeof(data)); + batch_zones.clear(); + count = 0; + } + } +} + +void LogitechHIDPP20Controller::SetAllPerKeyColor(RGBColor color) +{ + if(!device_online.load()) return; + + uint8_t perkey_idx = (caps.idx_perkey_v2 != 0) ? caps.idx_perkey_v2 : caps.idx_perkey_v1; + + if(perkey_idx == 0) + { + return; + } + + uint8_t r = RGBGetRValue(color); + uint8_t g = RGBGetGValue(color); + uint8_t b = RGBGetBValue(color); + + /*----------------------------------------------------------*\ + | Use SetRangeRgbZones (fn5): [start, end, R, G, B] × 3 | + | per packet. Sets all zones in a contiguous range to one | + | color. Gaps in zone IDs are silently ignored by firmware. | + | For uniform color this is far more efficient than fn6: | + | 1-2 packets vs 8 packets for 94 zones. | + \*----------------------------------------------------------*/ + uint8_t min_zone = 255, max_zone = 0; + + for(uint16_t zid : caps.perkey_zone_ids) + { + if(zid > 0 && zid <= 255) + { + if((uint8_t)zid < min_zone) min_zone = (uint8_t)zid; + if((uint8_t)zid > max_zone) max_zone = (uint8_t)zid; + } + } + + if(min_zone <= max_zone) + { + uint8_t data[5] = { min_zone, max_zone, r, g, b }; + std::vector batch_zones; + + for(uint16_t zid : caps.perkey_zone_ids) + { + if(zid >= min_zone && zid <= max_zone) + { + batch_zones.push_back((uint8_t)zid); + } + } + + SendPerKeyData(perkey_idx, FN_8081_SET_RANGE, data, 5, batch_zones); + } +} + +void LogitechHIDPP20Controller::SendPerKeyData + ( + uint8_t perkey_idx, + uint8_t function, + const uint8_t* data, + size_t len, + const std::vector& zone_ids + ) +{ + /*-----------------------------------------------------------*\ + | Truly fire-and-forget. Push the packet onto the wire, | + | record the zones it covers in outstanding_writes, and | + | return. PerKeyFrameEnd will drain the response queue at | + | end-of-frame and FIFO-match each ACK back to the | + | corresponding outstanding entry. | + | | + | The retry/backoff machinery is intentionally NOT used | + | here — when a streaming frame fails, we don't want to | + | delay the next frame retrying old data. The carry-over | + | of uncommitted zones via sent_colors[i]=HIDPP20_UNCOMMITTED | + | naturally ensures missed keys land in the next frame. | + \*-----------------------------------------------------------*/ + int send_result = SendMessage(perkey_idx, function, data, len); + + if(send_result < 0) + { + LOG_DEBUG("%s SendPerKeyData wire send failed (result=%d) func=0x%02X", + LOG_TAG, send_result, function); + /* Still record the outstanding entry — its zones will */ + /* be reported as unacked, which is correct. */ + } + + OutstandingPerKeyWrite entry; + entry.function = function; + entry.zone_ids = zone_ids; + outstanding_writes.push_back(std::move(entry)); +} + +PerKeyFrameResult LogitechHIDPP20Controller::PerKeyFrameEnd() +{ + PerKeyFrameResult result; + result.frame_end_acked = false; + + /*---------------------------------------------------------*\ + | Build attempted_zones from the outstanding writes list | + | up front so the caller can use it for both the success | + | and failure paths. | + \*---------------------------------------------------------*/ + for(size_t w = 0; w < outstanding_writes.size(); w++) + { + const std::vector& zone_ids = outstanding_writes[w].zone_ids; + + for(size_t z = 0; z < zone_ids.size(); z++) + { + result.attempted_zones.push_back(zone_ids[z]); + } + } + + if(!device_online.load()) + { + outstanding_writes.clear(); + return result; + } + + uint8_t perkey_idx = (caps.idx_perkey_v2 != 0) ? caps.idx_perkey_v2 : caps.idx_perkey_v1; + + if(perkey_idx == 0) + { + outstanding_writes.clear(); + return result; + } + + /*---------------------------------------------------------*\ + | Send FrameEnd directly. No retry, no backoff: a streaming | + | frame failure means the next frame's delta will pick up | + | the missed keys, and we don't want to delay that frame. | + | | + | Format (matches observed wire capture): LONG message | + | (0x11), 16 bytes of zeros. Firmware expects long-format | + | FrameEnd | + | — short-format hits intermittent BUSY. | + \*---------------------------------------------------------*/ + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + + uint8_t data[16] = {}; + int send_result = SendMessage(perkey_idx, FN_8081_FRAME_END, data, sizeof(data)); + + if(send_result < 0) + { + LOG_DEBUG("%s FrameEnd wire send failed (result=%d)", LOG_TAG, send_result); + outstanding_writes.clear(); + return result; + } + + /*---------------------------------------------------------*\ + | Drain responses in FIFO order until we either see the | + | FrameEnd ACK or run out the wait budget. Each per-key | + | write response is matched (by feature + function high | + | nibble) to the head of outstanding_writes; matched zones | + | go into acked_zones. The FrameEnd response itself is the | + | terminating event. | + | | + | Wait budget: 300ms. Generous enough to absorb the slow | + | batch-ACK behavior we've seen on G515 (~700ms p99) for | + | dense per-key frames, but won't actually consume that | + | much time on healthy devices — the loop exits the moment | + | the FrameEnd response shows up. | + \*---------------------------------------------------------*/ + std::chrono::steady_clock::time_point deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(300); + size_t outstanding_idx = 0; + int busy_retries = 0; + + while(true) + { + std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); + if(now >= deadline) + { + LOG_DEBUG("%s FrameEnd timed out waiting for ACK (matched %zu/%zu writes)", + LOG_TAG, outstanding_idx, outstanding_writes.size()); + break; + } + + int remaining = (int)std::chrono::duration_cast( + deadline - now).count(); + if(remaining <= 0) + { + break; + } + + uint8_t resp_feat = 0; + uint8_t resp_func = 0; + uint8_t resp_data[60] = {}; + + int rd = ReadMessage(&resp_feat, &resp_func, + resp_data, sizeof(resp_data), + remaining); + + if(rd < 0) + { + LOG_DEBUG("%s FrameEnd read error (result=%d)", LOG_TAG, rd); + break; + } + + if(rd == 0) + { + /* timeout */ + LOG_DEBUG("%s FrameEnd timed out waiting for ACK (matched %zu/%zu writes)", + LOG_TAG, outstanding_idx, outstanding_writes.size()); + break; + } + + /*-----------------------------------------------------*\ + | HID++ error frame: feat=0xFF, func=err_feat, | + | data[0]=err_func, data[1]=err_code. | + | | + | The case we care about is BUSY (0x08) for our | + | FrameEnd: the firmware is still draining the per-key | + | write queue and asks us to re-send. Without this we | + | hang on the deadline waiting for an ACK that never | + | comes, since BUSY-rejected commands are not queued. | + | | + | Re-send with a tight budget — 3 retries, 30ms gap. | + | If BUSY persists past that, give up for this frame | + | and let delta carry-over handle it next frame. | + \*-----------------------------------------------------*/ + if(resp_feat == 0xFF) + { + uint8_t err_feat = resp_func; + uint8_t err_func_byte = resp_data[0]; + uint8_t err_code = resp_data[1]; + + bool is_our_frame_end = + (err_feat == perkey_idx) && + ((err_func_byte & 0xF0) == FN_8081_FRAME_END); + + if(is_our_frame_end) + { + size_t max_busy_retries = + sizeof(HIDPP20_FRAME_END_BUSY_BACKOFF_MS) / sizeof(uint16_t); + + if(err_code == 0x08 && (size_t)busy_retries < max_busy_retries) + { + uint16_t delay_ms = HIDPP20_FRAME_END_BUSY_BACKOFF_MS[busy_retries]; + busy_retries++; + LOG_TRACE("%s FrameEnd BUSY, re-sending (retry %d, delay %ums)", + LOG_TAG, busy_retries, delay_ms); + std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms)); + SendMessage(perkey_idx, FN_8081_FRAME_END, data, sizeof(data)); + continue; + } + + /* Non-BUSY error or out of retries — frame committed=false */ + LOG_DEBUG("%s FrameEnd error 0x%02X (retries=%d)", + LOG_TAG, err_code, busy_retries); + break; + } + + /* Error for an unrelated request — discard and keep reading */ + continue; + } + + /*-----------------------------------------------------*\ + | Discard frames that aren't from our perkey feature. | + \*-----------------------------------------------------*/ + if(resp_feat != perkey_idx) + { + continue; + } + + uint8_t resp_func_hi = resp_func & 0xF0; + + /*-----------------------------------------------------*\ + | FrameEnd response — terminator. | + \*-----------------------------------------------------*/ + if(resp_func_hi == FN_8081_FRAME_END) + { + result.frame_end_acked = true; + break; + } + + /*-----------------------------------------------------*\ + | Per-key write response. Match against the next | + | outstanding entry by function high nibble. If the | + | head doesn't match (a write was dropped on the wire | + | or the firmware is responding out of order), skip | + | unmatched heads — those entries' zones will be left | + | out of acked_zones and treated as uncommitted. | + \*-----------------------------------------------------*/ + while(outstanding_idx < outstanding_writes.size() && + outstanding_writes[outstanding_idx].function != resp_func_hi) + { + outstanding_idx++; + } + + if(outstanding_idx >= outstanding_writes.size()) + { + /* No matching outstanding write — stale or unexpected response */ + continue; + } + + for(uint8_t z : outstanding_writes[outstanding_idx].zone_ids) + { + result.acked_zones.push_back(z); + } + outstanding_idx++; + } + + /*---------------------------------------------------------*\ + | Deep-sleep detection. If FrameEnd failed (no ACK) while | + | we're in the SLEEPING state, the device may have finished | + | its firmware fade and entered deep sleep. Track | + | consecutive failures; once we hit the threshold, suppress | + | further frame sends until Wake() clears the flag. | + | | + | A successful ACK resets the counter — transient BUSY | + | bursts during the fade don't accumulate. | + \*---------------------------------------------------------*/ + if(result.frame_end_acked) + { + consecutive_frame_end_failures.store(0); + } + else if(power_state == HIDPP20_POWER_SLEEPING) + { + int failures = consecutive_frame_end_failures.fetch_add(1) + 1; + + if(failures >= HIDPP20_DEEP_SLEEP_FAILURE_THRESHOLD && !deep_sleep.load()) + { + deep_sleep.store(true); + LOG_DEBUG("%s Device entered deep sleep (%d consecutive FrameEnd failures)", + LOG_TAG, failures); + } + } + + outstanding_writes.clear(); + return result; +} + +/*---------------------------------------------------------*\ +| Zone effects (0x8071 / 0x8070) | +\*---------------------------------------------------------*/ + +void LogitechHIDPP20Controller::SetZoneEffect + ( + uint8_t cluster_idx, + uint8_t effect_idx, + uint16_t effect_id, + unsigned char r, + unsigned char g, + unsigned char b, + uint16_t period, + unsigned char brightness, + unsigned char direction, + bool persist + ) +{ + if(caps.idx_rgb_effects == 0 || !device_online.load()) + { + return; + } + + /*---------------------------------------------------------*\ + | SetEffectByIndex (fn1 on 0x8071, fn3 on 0x8070) | + | 0x8071/0x0600: [cluster, effect_idx, 10-byte params, | + | persist at [12]] | + | 0x8070: [zone, effect_idx, 10-byte params, | + | persist at [12] (Bit 2-3 Power, | + | Bit 1-0 Persistence)] | + \*---------------------------------------------------------*/ + uint8_t data[16]; + memset(data, 0, sizeof(data)); + + data[0] = cluster_idx; + data[1] = effect_idx; + + /*----------------------------------------------------------*\ + | Build 10-byte params (data[2..11]) per effect type | + | Layouts from protocol docs and observed wire captures | + \*----------------------------------------------------------*/ + switch(effect_id) + { + case 0x0001: // Static + data[2] = r; + data[3] = g; + data[4] = b; + /*-----------------------------------------------------*\ + | "Fixed color" marker — only set when there's an | + | actual color. All-black means "Off / pass-through to | + | per-key buffer", which uses byte 5 = 0x00 instead. | + \*-----------------------------------------------------*/ + if(r != 0 || g != 0 || b != 0) + { + data[5] = 0x02; + } + break; + + case 0x000A: // Breathing + /*------------------------------------------------------*\ + | Effect param layout (10 bytes, indices into data[]): | + | data[2..4] = R, G, B | + | data[5..6] = periodHi, periodLo (BE16 milliseconds) | + | data[7] = 0 | + | data[8] = brightness 0..100 | + \*------------------------------------------------------*/ + data[2] = r; + data[3] = g; + data[4] = b; + data[5] = (period >> 8) & 0xFF; + data[6] = period & 0xFF; + data[8] = brightness; + break; + + case 0x0003: // Color Cycle / Spectrum + /*------------------------------------------------------*\ + | Effect param layout (10 bytes, indices into data[]): | + | data[7..8] = periodHi, periodLo (BE16 milliseconds) | + | data[9] = brightness 0..100 | + \*------------------------------------------------------*/ + data[7] = (period >> 8) & 0xFF; + data[8] = period & 0xFF; + data[9] = brightness; + break; + + case 0x0004: // Color Wave + data[3] = (period > 0) ? (uint8_t)(period / 100) : 50; + data[8] = 0x01; // pattern + data[9] = 0x00; // waveform + data[11] = 0x01; // direction + break; + + case 0x000B: // Ripple + data[2] = r; + data[3] = g; + data[4] = b; + data[6] = (period >> 8) & 0xFF; + data[7] = period & 0xFF; + break; + + case 0x0015: // Cycle (saturation variant) + /*------------------------------------------------------*\ + | Saturation-bearing variant of 0x0003. Param block | + | (10 bytes, indices into data[]): | + | data[3] = saturation 0..255 (hardcoded full) | + | data[8..9] = periodHi, periodLo (BE16 milliseconds) | + | data[10] = intensity 0..100 | + | Layout from Solaar LEDEffects 0x15 (saturation@1, | + | period@6, intensity@8 in the param block). | + \*------------------------------------------------------*/ + data[3] = 0xFF; + data[8] = (period >> 8) & 0xFF; + data[9] = period & 0xFF; + data[10] = brightness; + break; + + case 0x0016: // Wave (saturation variant) + /*------------------------------------------------------*\ + | Saturation-bearing variant of 0x0004. Param block: | + | data[3] = saturation 0..255 (hardcoded full) | + | data[8..9] = periodHi, periodLo (BE16 milliseconds) | + | data[10] = intensity 0..100 | + | data[11] = direction (Logitech wire value) | + | Layout from Solaar LEDEffects 0x16 (saturation@1, | + | period@6, intensity@8, direction@9). The caller maps | + | OpenRGB's 6 direction slots to the wire values. | + \*------------------------------------------------------*/ + data[3] = 0xFF; + data[8] = (period >> 8) & 0xFF; + data[9] = period & 0xFF; + data[10] = brightness; + data[11] = direction; + break; + + case 0x0017: // Ripple (saturation variant) + /*------------------------------------------------------*\ + | Saturation-bearing variant of 0x000B. Param block: | + | data[2..4] = R, G, B | + | data[5] = saturation 0..255 (hardcoded full) | + | data[8..9] = periodHi, periodLo (BE16 milliseconds) | + | Layout from Solaar LEDEffects 0x17 (color@0, | + | saturation@3, period@6). No intensity param. | + \*------------------------------------------------------*/ + data[2] = r; + data[3] = g; + data[4] = b; + data[5] = 0xFF; + data[8] = (period >> 8) & 0xFF; + data[9] = period & 0xFF; + break; + + default: // Unknown — best-effort + data[2] = r; + data[3] = g; + data[4] = b; + data[5] = (period >> 8) & 0xFF; + data[6] = period & 0xFF; + break; + } + + /*------------------------------------------------------*\ + | 16-byte payload for all pages; persist at byte[12]. | + | 0x8070 and 0x8071/0x0600 share the same byte position | + | per LogitechProtocolCommon setMode convention. | + \*------------------------------------------------------*/ + data[12] = persist ? 0x01 : 0x00; + + LOG_DEBUG("%s SetEffect cluster=%u idx=%u id=0x%04X " + "data=[%02X %02X %02X %02X %02X %02X %02X %02X " + "%02X %02X %02X %02X %02X %02X %02X %02X]", + LOG_TAG, cluster_idx, effect_idx, effect_id, + data[0], data[1], data[2], data[3], + data[4], data[5], data[6], data[7], + data[8], data[9], data[10], data[11], + data[12], data[13], data[14], data[15]); + + blankFAPmessage response; + SendAckedIntoFAP(caps.idx_rgb_effects, caps.fn_set_effect, + data, 16, response); +} + +/*---------------------------------------------------------*\ +| Feature 0x0620 Headset RGB Hostmode — static color write. | +| | +| Claim is sticky from SetHostMode(); this function only | +| writes colors + FrameEnd. Picks fn5 SetRgbZonesSingleValue| +| when all zones share a color, else fn2 | +| SetIndividualRgbZones. FrameEnd byte 0 is always 0x01 | +| (transient) — 0x02 was tested and does not work on G522 | +| firmware. | +\*---------------------------------------------------------*/ +void LogitechHIDPP20Controller::SetHeadsetRGBHostmodeColors + ( + const std::vector& zone_colors + ) +{ + if(caps.idx_headset_rgb_hostmode == 0 || !device_online.load()) + { + return; + } + + const std::vector& zones = caps.headset_rgb_hostmode_zone_ids; + if(zones.empty()) + { + return; + } + + /*---------------------------------------------------------*\ + | If fewer colors than zones, fill the tail with the last | + | provided color. If zero colors, nothing to write. | + \*---------------------------------------------------------*/ + if(zone_colors.empty()) + { + return; + } + + /*---------------------------------------------------------*\ + | Uniformity check: same color across every zone? | + \*---------------------------------------------------------*/ + RGBColor first = zone_colors[0]; + bool all_same = true; + for(size_t i = 1; i < zones.size(); i++) + { + RGBColor c = (i < zone_colors.size()) ? zone_colors[i] : zone_colors.back(); + if(c != first) + { + all_same = false; + break; + } + } + + uint8_t payload[16]; + size_t payload_len = 0; + uint8_t function = 0; + blankFAPmessage response; + + if(all_same) + { + /*------------------------------------------------------*\ + | fn5 SetRgbZonesSingleValue: [R, G, B, count, zones...] | + \*------------------------------------------------------*/ + function = FN_0620_SET_RGB_ZONES_SINGLE_VALUE; + payload[0] = RGBGetRValue(first); + payload[1] = RGBGetGValue(first); + payload[2] = RGBGetBValue(first); + payload[3] = (uint8_t)zones.size(); + + size_t n = zones.size(); + if(n > sizeof(payload) - 4) n = sizeof(payload) - 4; + for(size_t i = 0; i < n; i++) + { + payload[4 + i] = zones[i]; + } + payload_len = 4 + n; + } + else + { + /*------------------------------------------------------*\ + | fn2 SetIndividualRgbZones: [zone, R, G, B] × N | + | Each entry is 4 bytes; 16-byte payload fits 4 entries. | + \*------------------------------------------------------*/ + function = FN_0620_SET_INDIVIDUAL_RGB_ZONES; + size_t n = zones.size(); + if(n > sizeof(payload) / 4) n = sizeof(payload) / 4; + for(size_t i = 0; i < n; i++) + { + RGBColor c = (i < zone_colors.size()) ? zone_colors[i] : zone_colors.back(); + payload[i * 4 + 0] = zones[i]; + payload[i * 4 + 1] = RGBGetRValue(c); + payload[i * 4 + 2] = RGBGetGValue(c); + payload[i * 4 + 3] = RGBGetBValue(c); + } + payload_len = n * 4; + } + + SendAckedIntoFAP(caps.idx_headset_rgb_hostmode, function, + payload, payload_len, response); + + /*---------------------------------------------------------*\ + | fn6 FrameEnd — byte 0 = 0x01 (transient commit). Never | + | 0x00 (silently discarded) and never 0x02 (tested broken | + | on G522 firmware). | + \*---------------------------------------------------------*/ + uint8_t frame_end[4] = { 0x01, 0x00, 0x00, 0x00 }; + SendAckedIntoFAP(caps.idx_headset_rgb_hostmode, FN_0620_FRAME_END, + frame_end, sizeof(frame_end), response); + + LOG_TRACE("%s 0x0620 wrote %zu zone(s), fn=0x%02X, FrameEnd[0x01]", + LOG_TAG, zones.size(), function); +} + +/*---------------------------------------------------------*\ +| Power management (idle/dim/sleep/wake) | +| | +| Matches Solaar's RGBPowerManager state machine: | +| ACTIVE → DIMMING → IDLE → SLEEPING | +| | +| Uses firmware onUserActivity events from 0x8071 for | +| idle/active detection. SW control flags cycle: | +| 7 (init) → 5 (active, monitor idle) → | +| 3 (idle, monitor active) → 5 (wake) | +\*---------------------------------------------------------*/ + +void LogitechHIDPP20Controller::SetRepaintCallback(std::function repaint) +{ + request_repaint_fn = repaint; +} + +void LogitechHIDPP20Controller::SetReapplyActiveModeCallback(std::function cb) +{ + reapply_active_mode_fn = cb; +} + +void LogitechHIDPP20Controller::SetRegisterCallback(std::function cb) +{ + register_controller_fn = cb; +} + +HIDPP20PowerState LogitechHIDPP20Controller::GetPowerState() const +{ + return power_state; +} + +int LogitechHIDPP20Controller::GetDimBrightness() const +{ + return dim_brightness_pct.load(); +} + +bool LogitechHIDPP20Controller::HasBridge() const +{ + return transport.bridge_feat_idx != 0; +} + +bool LogitechHIDPP20Controller::IsOnline() const +{ + return device_online.load(); +} + +bool LogitechHIDPP20Controller::IsDeepSleep() const +{ + return deep_sleep.load(); +} + +void LogitechHIDPP20Controller::ReprobeSubDevice() +{ + /*----------------------------------------------------------*\ + | Called by power thread when a sub-device connects through | + | the Centurion bridge. The reader thread is running, so all | + | commands go through SendAndRead → ReadFromQueue. | + | | + | We clear the sub-device feature cache and re-discover | + | everything. The bridge_feat_idx and dongle name are kept. | + \*----------------------------------------------------------*/ + LOG_DEBUG("%s Re-probing sub-device through bridge", LOG_TAG); + + /*----------------------------------------------------------*\ + | Give the sub-device a moment to settle after connection | + | before sending commands through the bridge. | + \*----------------------------------------------------------*/ + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + + FlushResponseQueue(); + + /*----------------------------------------------------------*\ + | Clear sub-device feature map but keep bridge index. | + | This forces fresh lookups through the bridge. | + \*----------------------------------------------------------*/ + caps.feature_map.clear(); + caps.feature_map_complete = false; + caps.has_zone_effects = false; + caps.has_perkey = false; + caps.has_effect_cards = false; + caps.effect_card_template[0] = 0; + caps.effect_card_template[1] = 0; + caps.zone_clusters.clear(); + caps.perkey_zone_ids.clear(); + caps.idx_rgb_effects = 0; + caps.idx_perkey_v2 = 0; + caps.idx_perkey_v1 = 0; + caps.idx_profile_management = 0; + caps.idx_onboard_profiles = 0; + caps.idx_disable_keys_by_usage = 0; + caps.fn_set_effect = 0; + caps.fn_sw_control = 0; + caps.fn_pwr_config = 0; + caps.fn_pwr_mode = 0; + caps.has_power_mgmt = false; + caps.sw_control_simple = false; + caps.nv_sleep_ramp_known = false; + caps.nv_sleep_ramp_enabled = false; + caps.nv_sleep_ramp_seconds = 0; + + /*---------------------------------------------------------*\ + | Re-populate feature map. CenturionFeatureSet is always at | + | index 1 on the sub-device. | + \*---------------------------------------------------------*/ + EnumerateFeatures(1); + + if(!caps.feature_map_complete) + { + LOG_DEBUG("%s Sub-device not reachable after connect event", LOG_TAG); + caps.feature_map_complete = true; + return; + } + + /*---------------------------------------------------------*\ + | Discover sub-device identity if not already known. | + \*---------------------------------------------------------*/ + if(caps.device_name.empty() || caps.device_name.find("PRO X 2") == std::string::npos) + { + /*------------------------------------------------------*\ + | Dongle may have a sysfs-derived name; get real name | + | from the sub-device now that it's reachable. | + \*------------------------------------------------------*/ + std::string old_name = caps.device_name; + DiscoverDeviceName(); + + if(caps.device_name != old_name) + { + log_tag = "[LogitechHID++ " + caps.device_name + "]"; + } + } + + DiscoverDeviceType(); + DiscoverFirmwareInfo(); + + /*---------------------------------------------------------*\ + | Discover RGB features | + \*---------------------------------------------------------*/ + caps.idx_profile_management = GetFeatureIndex(HIDPP20_FEAT_PROFILE_MANAGEMENT); + caps.idx_onboard_profiles = GetFeatureIndex(HIDPP20_FEAT_ONBOARD_PROFILES); + caps.idx_disable_keys_by_usage = GetFeatureIndex(HIDPP20_FEAT_DISABLE_KEYS_BY_USAGE); + + DiscoverRGBEffects(); + if(caps.idx_rgb_effects == 0) + { + DiscoverHeadsetRGBHostmode(); + } + DiscoverPerKeyZones(); + DiscoverKeyboardLayout(); + + if(!caps.has_zone_effects && !caps.has_perkey) + { + LOG_DEBUG("%s Sub-device has no RGB features", LOG_TAG); + return; + } + + LOG_INFO("%s Sub-device probed: zones=%zu perkey=%zu", + LOG_TAG, caps.zone_clusters.size(), caps.perkey_zone_ids.size()); + + /*---------------------------------------------------------*\ + | Create and register RGBController for the sub-device. | + \*---------------------------------------------------------*/ + Initialize(); + + if(register_controller_fn) + { + RGBController_LogitechHIDPP20* rgb = new RGBController_LogitechHIDPP20(this); + register_controller_fn(rgb); + + LOG_INFO("%s Registered RGB controller for sub-device", LOG_TAG); + } +} + +void LogitechHIDPP20Controller::ReconnectDevice() +{ + /*----------------------------------------------------------*\ + | Called by power thread when a WirelessStatus reconnect | + | event arrives. Race the firmware boot animation: hammer | + | the SW-control claim + per-key push on a fast-backoff | + | schedule until the claim ACKs (matches the vendor app, | + | which lands control in ~50ms). | + | | + | Previously this code split work across the two firmware | + | events (reconnect=1/config_needed=1 then config_needed=0) | + | and only pushed once per event. The first push raced the | + | boot, the second only fired after the animation finished, | + | and a failed claim left sw_control_claimed=true so the | + | 10s firmware watchdog dropped us back to onboard mode. | + \*----------------------------------------------------------*/ + LOG_DEBUG("%s Reconnecting device", LOG_TAG); + + FlushResponseQueue(); + + bool first_event = !device_online.load(); + + if(first_event) + { + device_online.store(true); + consecutive_timeouts.store(0); + frame_counter = 0; + + { + std::lock_guard lock(power_mutex); + dim_brightness_pct.store(100); + power_state = HIDPP20_POWER_ACTIVE; + } + } + + /*----------------------------------------------------------*\ + | Always invalidate any stale claim so the loop below runs | + | the full claim sequence on each attempt until it sticks. | + \*----------------------------------------------------------*/ + sw_control_claimed = false; + sw_control_needs_upgrade_to_5 = false; + retry_paint_deadline_.store(std::chrono::steady_clock::time_point{}); + retry_paint_attempt_.store(0); + + bool claimed = false; + size_t attempt_count = sizeof(HIDPP20_RECLAIM_BACKOFF_MS) / sizeof(uint16_t); + + for(size_t i = 0; i < attempt_count; i++) + { + if(HIDPP20_RECLAIM_BACKOFF_MS[i] > 0) + { + std::this_thread::sleep_for( + std::chrono::milliseconds(HIDPP20_RECLAIM_BACKOFF_MS[i])); + } + + if(!device_online.load()) + { + return; + } + + if(reapply_active_mode_fn && reapply_active_mode_fn()) + { + claimed = true; + LOG_INFO("%s Device reconnected — SW control reclaimed (attempt %zu/%zu)", + LOG_TAG, i + 1, attempt_count); + break; + } + } + + if(!claimed) + { + LOG_WARNING("%s Device reconnected — SW control reclaim failed after %zu attempts", + LOG_TAG, attempt_count); + } + + if(first_event && caps.has_power_mgmt) + { + ReadFirmwareTimers(); + ReadNvSleepRampConfig(); + } +} + +void LogitechHIDPP20Controller::RediscoverFeatures() +{ + /*---------------------------------------------------------*\ + | Clear the cached HID++ feature map and all index/ | + | function-byte derivations, then re-run the standard | + | discovery sequence on the current hid_device handle. | + | | + | Used in two situations: | + | - FullReprobe (a previously-unreachable device just | + | became reachable; reader thread is already running) | + | - SwapHIDHandle (USB/wireless path migration; the new | + | path's HID++ feature map may have completely | + | different feature indices than the old one — observed | + | on the G515 LS TKL where wireless RGBEffects sits at | + | idx 0x09 but the USB path puts it elsewhere, causing | + | INVALID_FEATURE_INDEX (0x07) errors on every cached | + | idx_rgb_effects/fn_set_effect/etc. lookup). | + | | + | Caller is responsible for state that lives outside the | + | feature map (sw_control_claimed, frame counters, online | + | flag, threads). | + \*---------------------------------------------------------*/ + caps.feature_map.clear(); + caps.feature_map_complete = false; + caps.has_zone_effects = false; + caps.has_perkey = false; + caps.has_effect_cards = false; + caps.effect_card_template[0] = 0; + caps.effect_card_template[1] = 0; + caps.zone_clusters.clear(); + caps.perkey_zone_ids.clear(); + caps.idx_rgb_effects = 0; + caps.idx_perkey_v2 = 0; + caps.idx_perkey_v1 = 0; + caps.idx_wireless_status = 0; + caps.idx_profile_management = 0; + caps.idx_onboard_profiles = 0; + caps.idx_disable_keys_by_usage = 0; + caps.fn_set_effect = 0; + caps.fn_sw_control = 0; + caps.fn_pwr_config = 0; + caps.fn_pwr_mode = 0; + caps.has_power_mgmt = false; + caps.sw_control_simple = false; + caps.nv_sleep_ramp_known = false; + caps.nv_sleep_ramp_enabled = false; + caps.nv_sleep_ramp_seconds = 0; + + /*---------------------------------------------------------*\ + | idx_unified_battery lives outside caps (discovered lazily | + | by QueryExternalPower on first use) so it isn't cleared | + | by the caps reset above. Clear it here too so the next | + | QueryExternalPower call re-probes on the new path — the | + | old path's feature index may not exist, or may map to a | + | different feature entirely, on the new map. | + \*---------------------------------------------------------*/ + idx_unified_battery = 0; + last_power_raw = 0xFFFF; + + /*---------------------------------------------------------*\ + | Force ApplyPowerSavingProfile's dedup to re-emit its | + | "Idle management: ..." line on the next call so a path | + | transition always produces a full state confirmation in | + | the log, symmetric with the QueryExternalPower re-log. | + | Inverting ps_last_logged_external guarantees the boolean | + | comparison trips regardless of the current power state. | + \*---------------------------------------------------------*/ + ps_last_logged_pct = -1; + ps_last_logged_idle = -1; + ps_last_logged_sleep = -1; + ps_last_logged_external = !ps_on_external_power; + + /*---------------------------------------------------------*\ + | Standard HID++ features are looked up on-demand — no bulk | + | enumeration needed. Just re-discover everything. | + \*---------------------------------------------------------*/ + DiscoverDeviceName(); + log_tag = "[LogitechHID++ " + caps.device_name + "]"; + DiscoverDeviceType(); + DiscoverFirmwareInfo(); + + caps.idx_profile_management = GetFeatureIndex(HIDPP20_FEAT_PROFILE_MANAGEMENT); + caps.idx_onboard_profiles = GetFeatureIndex(HIDPP20_FEAT_ONBOARD_PROFILES); + caps.idx_wireless_status = GetFeatureIndex(HIDPP20_FEAT_WIRELESS_STATUS); + caps.idx_disable_keys_by_usage = GetFeatureIndex(HIDPP20_FEAT_DISABLE_KEYS_BY_USAGE); + + DiscoverRGBEffects(); + if(caps.idx_rgb_effects == 0) + { + DiscoverHeadsetRGBHostmode(); + } + DiscoverPerKeyZones(); + DiscoverKeyboardLayout(); +} + +void LogitechHIDPP20Controller::FullReprobe() +{ + /*----------------------------------------------------------*\ + | Called by power thread when a failed-probe device becomes | + | reachable. Like ReprobeSubDevice but for non-bridge | + | standard HID++ devices. Reader thread is running. | + \*----------------------------------------------------------*/ + LOG_DEBUG("%s Full re-probe of previously unreachable device", LOG_TAG); + + FlushResponseQueue(); + RediscoverFeatures(); + + if(!caps.has_zone_effects && !caps.has_perkey) + { + LOG_DEBUG("%s Device has no RGB features after re-probe", LOG_TAG); + return; + } + + LOG_INFO("%s Re-probe complete: zones=%zu perkey=%zu", + LOG_TAG, caps.zone_clusters.size(), caps.perkey_zone_ids.size()); + + device_online.store(true); + consecutive_timeouts.store(0); + watcher_mode.store(false); + Initialize(); + + if(register_controller_fn) + { + RGBController_LogitechHIDPP20* rgb = new RGBController_LogitechHIDPP20(this); + register_controller_fn(rgb); + LOG_INFO("%s Registered RGB controller after re-probe", LOG_TAG); + } +} + +void LogitechHIDPP20Controller::StartProbeWatcher() +{ + /*---------------------------------------------------------*\ + | Start reader + power threads in watcher mode for a device | + | that failed initial probe. The power thread periodically | + | retries IRoot until the device becomes reachable. | + \*---------------------------------------------------------*/ + if(reader_running) + { + return; + } + + watcher_mode.store(true); + device_online.store(false); + + pending_connection = 0; + reader_running = true; + reader_thread = new std::thread(&LogitechHIDPP20Controller::ReaderThreadFunc, this); + + power_thread_running = true; + power_thread = new std::thread(&LogitechHIDPP20Controller::PowerThreadFunc, this); + + LOG_DEBUG("%s Probe watcher started (retrying every 5s)", LOG_TAG); +} + +/*---------------------------------------------------------*\ +| ScanForDevice and GetCenturionSubDeviceName live in | +| LogitechHIDPP20Controller_Linux.cpp / | +| LogitechHIDPP20Controller_Windows_MacOS.cpp. Both are | +| the only parts of this controller that touch platform- | +| specific HID enumeration details (sysfs vs hidapi | +| serial_number). | +\*---------------------------------------------------------*/ + +void LogitechHIDPP20Controller::SwapHIDHandle + ( + hid_device* new_dev, + const std::string& new_path + ) +{ + /*----------------------------------------------------------*\ + | Stop reader/power threads, swap HID handle, restart. | + | Called from the power thread — we can't join ourselves, | + | so we stop the reader, swap, and flag for restart. | + | | + | Actually, we're called from the power thread's scan loop. | + | The reader thread is running. We need to: | + | 1. Stop the reader thread | + | 2. Close old handle, set new one | + | 3. Restart reader thread | + | 4. Reinit device | + | The power thread keeps running throughout. | + \*----------------------------------------------------------*/ + + /*----------------------------------------------------------*\ + | Stop reader thread | + \*----------------------------------------------------------*/ + reader_running = false; + + if(reader_thread && reader_thread->joinable()) + { + reader_thread->join(); + } + + delete reader_thread; + reader_thread = nullptr; + + /*---------------------------------------------------------*\ + | Swap HID handle | + \*---------------------------------------------------------*/ + hid_close(dev); + dev = new_dev; + location = new_path; + log_tag = "[LogitechHID++ " + caps.device_name + "]"; + + /*----------------------------------------------------------*\ + | Re-discover transport (might change between Centurion and | + | standard HID++ if device switches connection types). | + \*----------------------------------------------------------*/ + DiscoverTransport(); + + /*---------------------------------------------------------*\ + | Reset state for new connection | + \*---------------------------------------------------------*/ + FlushResponseQueue(); + device_online.store(true); + consecutive_timeouts.store(0); + sw_control_claimed = false; + sw_control_needs_upgrade_to_5 = false; + retry_paint_deadline_.store(std::chrono::steady_clock::time_point{}); + retry_paint_attempt_.store(0); + frame_counter = 0; + + { + std::lock_guard lock(power_mutex); + dim_brightness_pct.store(100); + power_state = HIDPP20_POWER_ACTIVE; + } + + /*----------------------------------------------------------*\ + | Restart reader thread on new handle | + \*----------------------------------------------------------*/ + reader_running = true; + reader_thread = new std::thread(&LogitechHIDPP20Controller::ReaderThreadFunc, this); + + /*--------------------------------------------------------------*\ + | Re-discover the HID++ feature map on the new path. The | + | wireless dongle path and the USB-direct path expose | + | DIFFERENT feature index assignments for the same logical | + | features — observed on the G515 LS TKL where wireless | + | RGBEffects sits at idx 0x09 but the USB path puts it | + | elsewhere. Without this rediscovery, the cached idx_* | + | values from the old path point at the wrong features on | + | the new one and every reclaim call returns HID++ error | + | 0x07 INVALID_FEATURE_INDEX. | + | | + | Must run after the reader thread is restarted (the | + | discovery uses queue-backed reads) and before the | + | reapply_active_mode_fn callback (so the SW control claim sees | + | correct indices). | + \*--------------------------------------------------------------*/ + RediscoverFeatures(); + + /*---------------------------------------------------------*\ + | Reinit device with colors | + \*---------------------------------------------------------*/ + if(reapply_active_mode_fn) + { + reapply_active_mode_fn(); + } + + if(caps.has_power_mgmt) + { + ReadFirmwareTimers(); + ReadNvSleepRampConfig(); + QueryExternalPower(); + ApplyPowerSavingProfile(); + } + + LOG_INFO("%s Device reconnected on new path — colors restored", LOG_TAG); +} + +void LogitechHIDPP20Controller::StartEventWatcher() +{ + /*------------------------------------------------------------*\ + | Start reader thread only (no power thread) to watch for | + | connection events on Centurion dongles without sub-devices. | + | When ConnectionStateChangedEvent arrives, pending_connection | + | is set for the power thread — but since there's no power | + | thread, we need a minimal processing loop. | + \*------------------------------------------------------------*/ + if(reader_running) + { + return; + } + + pending_connection = 0; + reader_running = true; + reader_thread = new std::thread(&LogitechHIDPP20Controller::ReaderThreadFunc, this); + + /*----------------------------------------------------------*\ + | Start power thread to process connection events. | + | It won't do dim/sleep (no RGB) but it handles | + | pending_connection for sub-device re-probe. | + \*----------------------------------------------------------*/ + power_thread_running = true; + power_thread = new std::thread(&LogitechHIDPP20Controller::PowerThreadFunc, this); + + LOG_DEBUG("%s Event watcher started (watching for sub-device)", LOG_TAG); +} + +void LogitechHIDPP20Controller::StartPowerManager() +{ + if(caps.idx_rgb_effects == 0 || !caps.has_power_mgmt) + { + return; + } + + if(reader_running) + { + return; + } + + ReadFirmwareTimers(); + ReadNvSleepRampConfig(); + ReadActiveProfileSector(); + + LogitechHIDPP20IdleSettings::instance()->load(); + QueryExternalPower(); + ApplyPowerSavingProfile(); + + /*-----------------------------------------------------------*\ + | Seed the periodic idle-settings poll clock so the first | + | tick of the power thread's 500ms re-read happens one | + | interval from now, not immediately (we just applied above). | + \*-----------------------------------------------------------*/ + last_idle_poll = std::chrono::steady_clock::now(); + + /*----------------------------------------------------------*\ + | Don't claim SW control here. The device runs its firmware | + | effect (or saved hardware profile) until DeviceUpdateLEDs | + | is called for the first time, at which point claim + push | + | happen atomically. | + | | + | Reader and power threads still start so we can detect | + | migration events (USB plug-in) and process activity events | + | once SW control is eventually claimed. | + \*----------------------------------------------------------*/ + power_state = HIDPP20_POWER_ACTIVE; + pending_activity = -1; + + reader_running = true; + reader_thread = new std::thread(&LogitechHIDPP20Controller::ReaderThreadFunc, this); + + power_thread_running = true; + power_thread = new std::thread(&LogitechHIDPP20Controller::PowerThreadFunc, this); + + LOG_DEBUG("%s Power manager started (idle=%us sleep=%us)", + LOG_TAG, idle_timeout_s, sleep_timeout_s); +} + +void LogitechHIDPP20Controller::StopPowerManager() +{ + if(!reader_running && !power_thread_running) + { + return; + } + + /*----------------------------------------------------------*\ + | Stop power thread first (it may be waiting on the queue) | + \*----------------------------------------------------------*/ + power_thread_running = false; + response_cv.notify_all(); + + if(power_thread && power_thread->joinable()) + { + power_thread->join(); + } + + delete power_thread; + power_thread = nullptr; + + /*----------------------------------------------------------*\ + | Then stop reader thread | + \*----------------------------------------------------------*/ + reader_running = false; + + if(reader_thread && reader_thread->joinable()) + { + reader_thread->join(); + } + + delete reader_thread; + reader_thread = nullptr; + + /*----------------------------------------------------------*\ + | Wake if we were dimmed/sleeping so Shutdown() can | + | cleanly release SW control. | + \*----------------------------------------------------------*/ + if(power_state != HIDPP20_POWER_ACTIVE) + { + Wake(); + } + + LOG_DEBUG("%s Power manager stopped", LOG_TAG); +} + +void LogitechHIDPP20Controller::ReaderThreadFunc() +{ + /*----------------------------------------------------------*\ + | Sole HID reader. NEVER sends commands — that would | + | deadlock (we'd wait on our own queue for the response). | + | Events are flagged via atomic for the power thread. | + \*----------------------------------------------------------*/ + while(reader_running.load()) + { + uint8_t feat = 0, func = 0; + uint8_t data[60] = {}; + int result = ReadHIDDirect(&feat, &func, data, sizeof(data), 50); + + if(result < 0) + { + /*--------------------------------------------------*\ + | HID read error — device handle is invalid (device | + | physically removed). Mark offline and sleep to | + | avoid spinning. | + \*--------------------------------------------------*/ + if(device_online.load()) + { + LOG_DEBUG("%s HID read error — device removed", LOG_TAG); + device_online.store(false); + } + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + continue; + } + + if(result > 0) + { + /*--------------------------------------------------*\ + | Check for firmware events first. | + | Events are flagged for the power thread and NOT | + | added to the response queue — they aren't command | + | responses and would pollute the queue. | + \*--------------------------------------------------*/ + if(caps.idx_rgb_effects != 0 && + feat == caps.idx_rgb_effects && + (func & 0xF0) == 0x10 && + (func & 0x0F) != HIDPP20_SW_ID) + { + pending_activity.store((int)data[0]); + continue; + } + + /*--------------------------------------------------*\ + | CentPPBridge event 0: ConnectionStateChangedEvent | + | Sub-device connected or disconnected from dongle. | + \*--------------------------------------------------*/ + if(transport.bridge_feat_idx != 0 && + feat == transport.bridge_feat_idx && + (func & 0xF0) == 0x00 && + (func & 0x0F) != HIDPP20_SW_ID) + { + /*--------------------------------------------------*\ + | ConnectionStateChangedEvent payload: | + | data[0] = ? (always 0 in observed packets) | + | data[1] = number of connected sub-devices | + | data[2] = ? | + | Connect: [00 01 00], Disconnect: [00 00 00] | + \*--------------------------------------------------*/ + uint8_t num_devices = data[1]; + LOG_DEBUG("%s Bridge ConnectionStateChanged: %d sub-device(s) (data: %02X %02X %02X)", + LOG_TAG, num_devices, data[0], data[1], data[2]); + /*-------------------------------------------------*\ + | Store +1 for connected, -1 for disconnected. | + | Power thread checks sign to decide action. | + \*-------------------------------------------------*/ + pending_connection.store(num_devices > 0 ? 1 : -1); + continue; + } + + /*---------------------------------------------------*\ + | Feature 0x1D4B event 0: WirelessStatus | + | Device reconnected after power cycle. | + | Use cached map lookup only — reader thread must | + | never send commands (deadlock risk). | + \*---------------------------------------------------*/ + { + std::map::const_iterator it = caps.feature_map.find(0x1D4B); + uint8_t ws_idx = (it != caps.feature_map.end()) ? it->second : 0; + + if(ws_idx != 0 && feat == ws_idx && + (func & 0xF0) == 0x00 && + (func & 0x0F) != HIDPP20_SW_ID) + { + uint8_t reconnect = data[0]; + uint8_t config_needed = data[1]; + + LOG_DEBUG("%s WirelessStatus event: reconnect=%d config_needed=%d", + LOG_TAG, reconnect, config_needed); + + /*--------------------------------------------------*\ + | Forward both events to the power thread. Each | + | call into ReconnectDevice runs the fast-backoff | + | reclaim loop, so the second event acts as a | + | belt-and-suspenders re-claim once the firmware | + | boot fully settles. | + \*--------------------------------------------------*/ + pending_connection.store(1); + + continue; + } + } + + /*---------------------------------------------------*\ + | HID++1.0 Device Connection notifications from the | + | Lightspeed receiver. sub_id 0x40 = Device | + | Disconnection, 0x41 = Device Connection Status. | + | Either one is a signal that the paired device's | + | preferred path just changed — typically because | + | the user plugged in (or unplugged) the USB cable | + | on the device itself. | + | | + | These arrive on the paired device's hidraw with | + | device_index=0x01 (not the receiver's own | + | endpoint) because the kernel dj-receiver driver | + | routes them to the device's virtual hidraw. We | + | see them because our reader is attached to that | + | hidraw. | + | | + | Flag a force path-check for the power thread. It | + | will run ScanForDevice(true) which bypasses the | + | device_online gate so the scan can find a | + | different-PID migration candidate even while the | + | current path hasn't failed yet. If no such | + | candidate exists (same path, false alarm), the | + | scan is a no-op. | + | | + | This is how we catch the wireless→USB transition: | + | the notification fires BEFORE the firmware fully | + | switches its data flow, giving us a window to | + | migrate proactively. The reverse direction | + | (USB→wireless) is already handled via the USB fd | + | becoming invalid on cable unplug. | + \*---------------------------------------------------*/ + if(feat == 0x40 || feat == 0x41) + { + /*--------------------------------------------------*\ + | Set 75 retries × 200ms = ~15 second window. The | + | keyboard's USB HID++ interface (page 0xFF00) can | + | take 10+ seconds to appear after the boot HID | + | interface — the first DJ notification fires when | + | the boot interface comes up, but hid_enumerate | + | won't return the HID++ interface until the kernel | + | finishes setting up all three interfaces. Until | + | the path-check clears, the device is dark/ | + | uncontrolled, so we poll at the same fast cadence | + | as offline recovery (200ms) to minimize how long | + | the user sees default firmware behavior. | + | | + | A subsequent DJ notification before timeout resets | + | the counter to 75, extending the retry window. | + \*--------------------------------------------------*/ + LOG_DEBUG("%s LogitechHID++1.0 connection notification sub_id=0x%02X " + "flags=0x%02X (path change — forcing scan retries)", + LOG_TAG, feat, func); + pending_path_check.store(75); + continue; + } + + /*----------------------------------------------------*\ + | Only queue responses to OUR commands. | + | Our commands use HIDPP20_SW_ID (0x0A) in the low | + | nibble. Firmware-generated messages (battery, | + | sync, etc.) use SW_ID 0 — drop those silently. | + | Error responses (feat=0xFF) are always queued. | + \*----------------------------------------------------*/ + if(feat != 0xFF && (func & 0x0F) != HIDPP20_SW_ID) + { + continue; + } + { + std::lock_guard lock(response_mutex); + HIDPP20RawMessage msg; + msg.feat = feat; + msg.func = func; + msg.result = result; + memcpy(msg.data, data, sizeof(msg.data)); + response_queue.push_back(msg); + } + response_cv.notify_all(); + } + } +} + +void LogitechHIDPP20Controller::PowerThreadFunc() +{ + /*----------------------------------------------------------*\ + | Handles power state machine and sends commands. | + | Reads responses from the queue (filled by reader thread). | + \*----------------------------------------------------------*/ + std::chrono::steady_clock::time_point last_probe_time = std::chrono::steady_clock::now(); + + while(power_thread_running.load()) + { + /*------------------------------------------------------*\ + | Watcher mode: device failed initial probe. Retry | + | IRoot every 5 seconds until device becomes reachable. | + \*------------------------------------------------------*/ + if(watcher_mode.load()) + { + std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); + + if(now - last_probe_time >= std::chrono::seconds(5)) + { + last_probe_time = now; + + uint8_t test_idx = GetFeatureIndex(HIDPP20_FEAT_FEATURE_SET); + + if(test_idx != 0) + { + LOG_INFO("%s Device became reachable — initiating full probe", LOG_TAG); + FullReprobe(); + } + } + + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + continue; + } + + /*------------------------------------------------------*\ + | 1. Check for pending firmware events | + \*------------------------------------------------------*/ + int activity = pending_activity.exchange(-1); + + if(activity >= 0) + { + std::lock_guard lock(power_mutex); + OnUserActivity((uint8_t)activity); + } + + /*------------------------------------------------------*\ + | 1b. Check for connection state changes | + \*------------------------------------------------------*/ + int connection = pending_connection.exchange(0); + + if(connection > 0) + { + if(HasBridge()) + { + ReprobeSubDevice(); + } + else + { + ReconnectDevice(); + } + } + else if(connection < 0) + { + LOG_DEBUG("%s Device disconnected", LOG_TAG); + device_online.store(false); + } + + /*-------------------------------------------------------*\ + | 1c. Reactive scan for connection migration. | + | | + | Three modes feed this loop: | + | a) device_online == true, no path-check pending | + | -> 2s idle interval, scan is a no-op | + | b) device_online == false | + | -> 200ms fast interval, scan tries to reclaim | + | c) pending_path_check > 0 (DJ notification fired) | + | -> 200ms fast interval, force-scan bypasses | + | the online gate to find a different-PID | + | migration candidate; counter decrements per | + | failed attempt and clears on success | + | | + | The retry counter exists because the keyboard's USB | + | HID++ interface can take 10+ seconds to enumerate | + | after the boot HID interface comes up — the first DJ | + | notification fires too early to find anything. 75 * | + | 200ms = ~15s window, plenty for the slowest observed | + | enumeration. | + \*-------------------------------------------------------*/ + if(!caps.unit_id.empty()) + { + std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); + int path_retries = pending_path_check.load(); + bool online = device_online.load(); + + std::chrono::milliseconds interval = (online && path_retries == 0) + ? std::chrono::milliseconds(2000) + : std::chrono::milliseconds(200); + + if(now - last_probe_time >= interval) + { + last_probe_time = now; + + bool force = (path_retries > 0) || !online; + bool success = ScanForDevice(force); + + if(path_retries > 0) + { + if(success) + { + pending_path_check.store(0); + } + else + { + pending_path_check.fetch_sub(1); + } + } + } + } + + /*------------------------------------------------------*\ + | 2. Power management timing | + \*------------------------------------------------------*/ + { + std::lock_guard lock(power_mutex); + + switch(power_state) + { + case HIDPP20_POWER_DIMMING: + { + std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); + if(now >= next_dim_time) + { + DimRampStep(); + next_dim_time = now + std::chrono::milliseconds(DIM_INTERVAL_MS); + } + break; + } + + case HIDPP20_POWER_IDLE: + /*---------------------------------------------------*\ + | Poll dim brightness target — if the user is | + | dragging the slider, ps_dim_target_pct updates | + | in-memory and we pick it up here on the next 50ms | + | tick without any callback/repaint chain. | + | | + | Gated on ps_dim_enabled: a profile (or the default | + | unconfigured fallback) can enter IDLE state via | + | the skip-dim path in OnUserActivity, and we must | + | not dim in that case — only sleep when the deadline | + | hits. | + \*---------------------------------------------------*/ + if(ps_dim_enabled && + dim_brightness_pct.load() != ps_dim_target_pct) + { + dim_brightness_pct.store(ps_dim_target_pct); + + if(request_repaint_fn) + { + request_repaint_fn(); + } + } + + if(sleep_timeout_s > 0 && ps_sleep_enabled && + std::chrono::steady_clock::now() >= sleep_deadline) + { + StartSleep(); + } + break; + + default: + break; + } + } + + /*------------------------------------------------------*\ + | Fast poll of idle settings + external-power flag. | + | QueryExternalPower is a single HID++ 0x1004 GetStatus | + | call — cheap on wire and lets ApplyPowerSavingProfile | + | pick between the on_battery and plugged_in profiles | + | within half a second of a power-source transition. | + | The idle-settings reload itself is purely in-memory. | + \*------------------------------------------------------*/ + if(caps.has_power_mgmt) + { + std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); + if(now - last_idle_poll >= std::chrono::milliseconds(500)) + { + last_idle_poll = now; + QueryExternalPower(); + ApplyPowerSavingProfile(); + } + } + + /*------------------------------------------------------*\ + | Fire any pending retry-paint whose deadline has come | + | due. The callback runs DeviceUpdateLEDs on this | + | thread's context, not recursively inside another call. | + \*------------------------------------------------------*/ + TickRetryPaintIfPending(); + + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } +} + +bool LogitechHIDPP20Controller::IsCurrentlyWireless() const +{ + return wireless; +} + +bool LogitechHIDPP20Controller::QueryWirelessStatus() +{ + if(caps.idx_wireless_status == 0) + { + LOG_DEBUG("%s QueryWirelessStatus: feature not present", LOG_TAG); + return false; + } + + uint8_t send_data[1] = {0}; + uint8_t recv_data[16] = {}; + uint8_t hidpp_err = 0; + + int result = SendAcked(caps.idx_wireless_status, 0, + send_data, 0, recv_data, sizeof(recv_data), + HIDPP20_POLICY_PROBE, &hidpp_err); + + LOG_DEBUG("%s QueryWirelessStatus: result=%d err=0x%02X " + "data=[%02X %02X %02X %02X %02X %02X]", + LOG_TAG, result, hidpp_err, + recv_data[0], recv_data[1], recv_data[2], + recv_data[3], recv_data[4], recv_data[5]); + + if(result <= 0) + { + return false; + } + + return true; +} + +bool LogitechHIDPP20Controller::QueryExternalPower() +{ + /*----------------------------------------------------------*\ + | Query HID++ 2.0 feature 0x1004 (UnifiedBattery) fn1 | + | GetStatus and determine whether the device is drawing | + | external power. | + | | + | Response layout: | + | byte 2: Charging Status | + | 0 = Discharging | + | 1 = Charging (wired) | + | 2 = Charging (slow) | + | 3 = Complete | + | 4 = Error | + | 5 = Wireless Charging | + | byte 3: External Power Status | + | 0 = no external power | + | non-zero = external power present | + | | + | We consider the device externally powered if EITHER byte | + | is non-zero: some devices leave byte 3 at 0 whenever they | + | are actively charging and rely on byte 2 alone to signal | + | the wired state. The pre-refactor QueryOnBattery used the | + | same OR semantic (expressed from the on-battery side) and | + | was known to work across the Logitech lineup. | + | | + | Updates ps_on_external_power and returns the new value. | + | On failure returns the cached value without touching it. | + \*----------------------------------------------------------*/ + if(caps.idx_rgb_effects == 0 || !caps.has_power_mgmt) + { + return ps_on_external_power; + } + + if(idx_unified_battery == 0) + { + idx_unified_battery = GetFeatureIndex(HIDPP20_FEAT_UNIFIED_BATTERY, + HIDPP20_POLICY_PROBE); + + if(idx_unified_battery == 0) + { + /*-----------------------------------------------------*\ + | Device doesn't expose UnifiedBattery. Wired-only | + | devices (no battery) report the feature absent; we | + | treat them as permanently externally powered. | + \*-----------------------------------------------------*/ + ps_on_external_power = true; + return ps_on_external_power; + } + } + + uint8_t send_data[1] = {0}; + uint8_t recv_data[16] = {}; + + int result = SendAcked(idx_unified_battery, 0x10, + send_data, 0, recv_data, sizeof(recv_data), + HIDPP20_POLICY_PROBE); + + if(result <= 0) + { + LOG_TRACE("%s QueryExternalPower: GetStatus failed (result=%d) — using cached", + LOG_TAG, result); + return ps_on_external_power; + } + + uint8_t charge_status = recv_data[2]; + uint8_t external_power = recv_data[3]; + + ps_on_external_power = (charge_status != 0) || (external_power != 0); + + uint16_t raw = ((uint16_t)charge_status << 8) | external_power; + if(raw != last_power_raw) + { + last_power_raw = raw; + LOG_TRACE("%s QueryExternalPower: charge_status=%u external_power=%u -> %s", + LOG_TAG, charge_status, external_power, + ps_on_external_power ? "external" : "battery"); + } + + return ps_on_external_power; +} + +void LogitechHIDPP20Controller::ApplyPowerSavingProfile() +{ + /*----------------------------------------------------------*\ + | Re-read the JSON every invocation. This is a cheap | + | in-memory SettingsManager hash lookup + a handful of | + | field copies — safe to do on every 500ms power-thread | + | tick. Any write from the plugin (or a manual JSON edit) | + | therefore applies within one poll interval without any | + | cross-boundary signalling. | + \*----------------------------------------------------------*/ + LogitechHIDPP20IdleSettings* settings = LogitechHIDPP20IdleSettings::instance(); + settings->load(); + + bool prev_dim = ps_dim_enabled; + bool prev_sleep = ps_sleep_enabled; + + /*----------------------------------------------------------*\ + | Start from the firmware-timer baseline. Both the | + | configured and unconfigured paths return to these if | + | they don't explicitly override, so a profile that sets | + | idle_timeout_s does not leave a stale value behind after | + | the user resets to an empty config. | + \*----------------------------------------------------------*/ + idle_timeout_s = fw_idle_timeout_s; + sleep_timeout_s = fw_sleep_timeout_s; + + if(!settings->isConfigured()) + { + /*---------------------------------------------------------*\ + | Unconfigured: no plugin in use. We still hold SW control | + | so firmware will NOT dim or sleep autonomously — it only | + | emits idle events and expects the host to act. Run a | + | basic default profile ourselves: no dim on idle (OpenRGB | + | users generally expect lights to stay on), but still go | + | to sleep at the firmware-configured timeout. | + \*---------------------------------------------------------*/ + ps_dim_enabled = false; + ps_dim_target_pct = DIM_TARGET_PCT; + ps_sleep_enabled = true; + + /*------------------------------------------------------*\ + | Restore firmware defaults on the device if we | + | previously wrote custom values from a plugin profile. | + \*------------------------------------------------------*/ + if(written_idle_s != fw_idle_timeout_s || written_sleep_s != fw_sleep_timeout_s) + { + WritePowerConfig(fw_idle_timeout_s, fw_sleep_timeout_s); + written_idle_s = fw_idle_timeout_s; + written_sleep_s = fw_sleep_timeout_s; + } + + if(prev_dim != ps_dim_enabled || prev_sleep != ps_sleep_enabled || + ps_last_logged_pct != ps_dim_target_pct || + ps_last_logged_idle != (int)idle_timeout_s || + ps_last_logged_sleep != (int)sleep_timeout_s || + ps_last_logged_external != ps_on_external_power) + { + ps_last_logged_pct = ps_dim_target_pct; + ps_last_logged_idle = idle_timeout_s; + ps_last_logged_sleep = sleep_timeout_s; + ps_last_logged_external = ps_on_external_power; + LOG_DEBUG("%s Idle management: defaults (dim=off, firmware sleep=%us)", + LOG_TAG, sleep_timeout_s); + } + return; + } + + /*---------------------------------------------------------*\ + | Configured: pick the active profile based on whether the | + | device is currently externally powered. ps_on_external_ | + | power is refreshed by QueryExternalPower() on the same | + | 500 ms power-thread poll that calls us. | + \*---------------------------------------------------------*/ + const LogitechHIDPP20IdleProfile& profile = ps_on_external_power + ? settings->pluggedIn() + : settings->onBattery(); + + ps_dim_enabled = profile.dim_when_idle; + ps_dim_target_pct = profile.dim_when_idle ? profile.dim_brightness : DIM_TARGET_PCT; + ps_sleep_enabled = profile.allow_sleep; + + if(profile.dim_when_idle) + { + idle_timeout_s = (uint16_t)profile.idle_timeout_s; + } + /* else: idle_timeout_s stays at fw_idle_timeout_s from above */ + + if(profile.allow_sleep) + { + sleep_timeout_s = (uint16_t)profile.sleep_timeout_s; + } + else + { + /*------------------------------------------------------*\ + | Signal "don't sleep" to the state machine. The IDLE | + | branch of PowerThreadFunc gates on sleep_timeout_s>0. | + \*------------------------------------------------------*/ + sleep_timeout_s = 0; + } + + /*----------------------------------------------------------*\ + | Write our timer values to the device RAM so the firmware's | + | idle detection aligns with our host-side state machine. | + | Only writes when values actually change to avoid spamming | + | the bus on every 500ms poll tick. | + \*----------------------------------------------------------*/ + if(idle_timeout_s != written_idle_s || sleep_timeout_s != written_sleep_s) + { + WritePowerConfig(idle_timeout_s, sleep_timeout_s); + written_idle_s = idle_timeout_s; + written_sleep_s = sleep_timeout_s; + } + + if(prev_dim != ps_dim_enabled || prev_sleep != ps_sleep_enabled || + ps_last_logged_pct != ps_dim_target_pct || + ps_last_logged_idle != (int)idle_timeout_s || + ps_last_logged_sleep != (int)sleep_timeout_s || + ps_last_logged_external != ps_on_external_power) + { + ps_last_logged_pct = ps_dim_target_pct; + ps_last_logged_idle = idle_timeout_s; + ps_last_logged_sleep = sleep_timeout_s; + ps_last_logged_external = ps_on_external_power; + LOG_DEBUG("%s Idle management: power=%s dim=%s(%d%%) idle=%us sleep=%s(%us)", + LOG_TAG, + ps_on_external_power ? "external" : "battery", + ps_dim_enabled ? "on" : "off", ps_dim_target_pct, + idle_timeout_s, + ps_sleep_enabled ? "on" : "off", sleep_timeout_s); + } +} + +void LogitechHIDPP20Controller::FlushResponseQueue() +{ + std::lock_guard lock(response_mutex); + response_queue.clear(); +} + +void LogitechHIDPP20Controller::DispatchEvent + ( + uint8_t feat, + uint8_t func, + const uint8_t* data + ) +{ + if(caps.idx_rgb_effects == 0 || data == nullptr) + { + return; + } + + /*------------------------------------------------------------*\ + | onUserActivity = event 1 on RGB Effects (0x8071) | + | Event function byte: (1 << 4) | fw_swid | + | Our commands use HIDPP20_SW_ID (0x0A); firmware events use | + | a different sw_id (typically 0). | + \*------------------------------------------------------------*/ + if(feat == caps.idx_rgb_effects && + (func & 0xF0) == 0x10 && + (func & 0x0F) != HIDPP20_SW_ID) + { + OnUserActivity(data[0]); + } +} + +void LogitechHIDPP20Controller::OnUserActivity(uint8_t activity_type) +{ + /*----------------------------------------------------------*\ + | power_mutex must already be held by the caller. | + \*----------------------------------------------------------*/ + + if(activity_type == 0) + { + /*------------------------------------------------------*\ + | IDLE event — firmware detected inactivity. | + | Only act if we're currently ACTIVE. | + | Firmware sends a burst of ~8 events; ignore repeats. | + \*------------------------------------------------------*/ + if(power_state != HIDPP20_POWER_ACTIVE) + { + return; + } + + if(!ps_dim_enabled && !ps_sleep_enabled) + { + return; + } + + LOG_DEBUG("%s onUserActivity: IDLE — starting dim", LOG_TAG); + + /*------------------------------------------------------*\ + | Flush stale per-key ACKs before sending commands | + \*------------------------------------------------------*/ + FlushResponseQueue(); + + /*------------------------------------------------------*\ + | Switch to flags=3 (ZONE|POWER): release EFFECT to | + | firmware, monitor for user activity (keypress). | + \*------------------------------------------------------*/ + SetSWControl(3, 3); + + if(!ps_dim_enabled) + { + power_state = HIDPP20_POWER_IDLE; + + uint16_t sleep_delay = (sleep_timeout_s > idle_timeout_s) + ? (sleep_timeout_s - idle_timeout_s) : 0; + sleep_deadline = std::chrono::steady_clock::now() + + std::chrono::seconds(sleep_delay); + + LOG_DEBUG("%s Dim disabled — skipping to IDLE (sleep in %us)", + LOG_TAG, sleep_delay); + } + else + { + StartDimRamp(); + } + } + else + { + /*------------------------------------------------------*\ + | ACTIVE event — user resumed typing. | + | Only act if we're NOT already active. | + \*------------------------------------------------------*/ + if(power_state == HIDPP20_POWER_ACTIVE) + { + return; + } + + LOG_DEBUG("%s onUserActivity: ACTIVE — waking", LOG_TAG); + Wake(); + } +} + +void LogitechHIDPP20Controller::StartDimRamp() +{ + /*----------------------------------------------------------*\ + | Start the brightness ramp from 100% to DIM_TARGET_PCT. | + | The actual dimming happens in DeviceUpdateLEDs — it reads | + | dim_brightness_pct and scales the color buffer output. | + | This is our own host-side animation, independent of the | + | firmware's sleep-ramp timer. | + \*----------------------------------------------------------*/ + dim_step = 0; + next_dim_time = std::chrono::steady_clock::now(); + power_state = HIDPP20_POWER_DIMMING; + + LOG_DEBUG("%s Dim ramp started (100%% -> %d%%)", LOG_TAG, ps_dim_target_pct); +} + +void LogitechHIDPP20Controller::DimRampStep() +{ + /*----------------------------------------------------------*\ + | power_mutex must already be held by the caller. | + | Adjusts brightness and requests a repaint so | + | DeviceUpdateLEDs pushes the dimmed colors. | + \*----------------------------------------------------------*/ + if(power_state != HIDPP20_POWER_DIMMING) + { + return; + } + + dim_step++; + + int target = ps_dim_target_pct; + int brightness = 100 - ((100 - target) * dim_step / DIM_STEPS); + + if(brightness < target) + { + brightness = target; + } + + dim_brightness_pct.store(brightness); + + /*----------------------------------------------------------*\ + | Request repaint so DeviceUpdateLEDs applies the new | + | brightness. For animations this is redundant (the | + | animation loop already calls it), but for static colors | + | this is the only way to push the dimmed output. | + | | + | Do NOT bump init_generation here — that would clear | + | sent_colors and make the next DeviceUpdateLEDs treat the | + | frame as a first-push, firing the SetZoneEffect(0xFF, | + | static black, persist=true) prep call. On mice that flash | + | as a brief black-out per dim step. Delta tracking already | + | handles the changed brightness correctly: snapshot is the | + | scaled output, sent_colors holds the previously scaled | + | frame, and the diff catches every pixel that moved. | + \*----------------------------------------------------------*/ + if(request_repaint_fn) + { + request_repaint_fn(); + } + + /*----------------------------------------------------------*\ + | Check if dim ramp is complete | + \*----------------------------------------------------------*/ + if(dim_step >= DIM_STEPS) + { + power_state = HIDPP20_POWER_IDLE; + + /*------------------------------------------------------*\ + | Pull the sleep deadline forward by the firmware's | + | off-ramp duration so the firmware fade *ends* at the | + | user-configured sleep_timeout_s. Without this we'd | + | be late by nv_sleep_ramp_seconds (30s on G515). | + \*------------------------------------------------------*/ + uint16_t effective_sleep = sleep_timeout_s; + + if(caps.nv_sleep_ramp_known && caps.nv_sleep_ramp_enabled + && caps.nv_sleep_ramp_seconds < sleep_timeout_s) + { + effective_sleep -= caps.nv_sleep_ramp_seconds; + } + + uint16_t sleep_delay = (effective_sleep > idle_timeout_s) + ? (effective_sleep - idle_timeout_s) : 0; + + sleep_deadline = std::chrono::steady_clock::now() + + std::chrono::seconds(sleep_delay); + + LOG_DEBUG("%s Dim complete — IDLE (sleep in %us, effective_sleep=%us)", + LOG_TAG, sleep_delay, effective_sleep); + } +} + +void LogitechHIDPP20Controller::StartSleep() +{ + /*----------------------------------------------------------*\ + | SetRgbPowerMode(3) = firmware-managed fade to off. | + | The firmware handles the fade internally. | + | | + | Set power_state BEFORE sending the command so that | + | DeviceUpdateLEDs sees SLEEPING and stops pushing frames | + | before the sleep command hits the wire. Suppression is | + | the safe default: a write arriving after SetRgbPowerMode | + | (3) can otherwise wake the device and cancel the sleep. | + | Devices carrying FADE_ACCEPTS_WRITES opt out of | + | suppression — their firmware tolerates writes during the | + | fade. | + \*----------------------------------------------------------*/ + LOG_DEBUG("%s Entering sleep (SetRgbPowerMode 3)", LOG_TAG); + + power_state = HIDPP20_POWER_SLEEPING; + + uint8_t data[3] = { 0x01, 0x03, 0x00 }; + blankFAPmessage response; + int result = SendAckedIntoFAP(caps.idx_rgb_effects, caps.fn_pwr_mode, + data, 3, response); + + if(result <= 0) + { + LOG_DEBUG("%s SetRgbPowerMode(3) failed after retries (result=%d), " + "reverting to IDLE", LOG_TAG, result); + power_state = HIDPP20_POWER_IDLE; + } +} + +void LogitechHIDPP20Controller::Wake() +{ + /*----------------------------------------------------------*\ + | Called from OnUserActivity(1) when the firmware tells us | + | the device has seen user input. Works for DIMMING, IDLE, | + | and SLEEPING states uniformly — the only wrinkle is that | + | SLEEPING means we previously sent SetRgbPowerMode(3) to | + | initiate the fade, so we have to explicitly cancel it | + | with SetRgbPowerMode(1) first. | + | | + | Per the 0x8071 protocol lifecycle, a proper wake is: power | + | mode 1 (if we were sleeping), then SetSWControl(3,5) to | + | re-claim | + | rendering from the firmware's idle-monitor mode, then | + | re-push the current lighting state at full brightness. | + | | + | The re-push uses request_repaint_fn (DeviceUpdateLEDs) | + | NOT reapply_active_mode_fn (which re-runs the full claim | + | + per-key prep sequence). Wake is NOT a reconnect — the | + | device handle, feature map, SW control claim, and per-key | + | prep are all still intact. Re-running the claim would | + | briefly reset the zone effect layer and flash the firmware | + | default colors for ~50ms before per-key takes back over. | + | | + | power_mutex must already be held by the caller. | + \*----------------------------------------------------------*/ + HIDPP20PowerState prev = power_state; + + FlushResponseQueue(); + + if(prev == HIDPP20_POWER_SLEEPING) + { + /*------------------------------------------------------*\ + | Cancel the firmware's fade-to-off. SW control is still | + | ours; this is not a reconnect. The device stays on the | + | same hidraw handle, same feature map, same claim. | + \*------------------------------------------------------*/ + SetRGBPowerMode(1); + } + + SetSWControl(3, 5); + dim_brightness_pct.store(100); + deep_sleep.store(false); + consecutive_frame_end_failures.store(0); + power_state = HIDPP20_POWER_ACTIVE; + + LOG_DEBUG("%s Woke from state %d", LOG_TAG, prev); + + /*----------------------------------------------------------*\ + | Re-push the current lighting state at full brightness. | + | | + | We use request_repaint_fn (lightweight: just calls | + | DeviceUpdateLEDs) NOT reapply_active_mode_fn (heavyweight: | + | re-runs ClaimSWControlIfNeeded → SetOnboardMode → per-key | + | prep sequence → DeviceUpdateMode). On a wake-from-dim/idle | + | the device is still in host mode, SW control is still | + | claimed, and the per-key prep has already been established | + | — all we need is a fresh paint at restored brightness. | + | | + | The brightness was restored to 100% above | + | (dim_brightness_pct.store(100)), so DeviceUpdateLEDs will | + | apply the full-brightness multiplier to the snapshot. | + | Since sent_colors was recorded at the previous (dimmed) | + | brightness, the delta detects a change on every zone and | + | pushes a full frame naturally — no sent_colors.clear() | + | needed. | + | | + | ReapplyActiveMode (the heavyweight path) is reserved for | + | reconnects where the device was fully re-enumerated and | + | needs the complete claim + prep + mode re-establishment. | + \*----------------------------------------------------------*/ + wake_full_repaint_pending_.store(true); + + if(request_repaint_fn) + { + request_repaint_fn(); + } +} + +bool LogitechHIDPP20Controller::ConsumeWakeFullRepaint() +{ + return wake_full_repaint_pending_.exchange(false); +} + +void LogitechHIDPP20Controller::ReadFirmwareTimers() +{ + if(caps.idx_rgb_effects == 0 || !caps.has_power_mgmt) + { + return; + } + + /*----------------------------------------------------------*\ + | GetRgbPowerModeConfig (fn7, sub-function 0x00 = get) | + | Response: [echo], idle_hi, idle_lo, sleep_hi, sleep_lo | + \*----------------------------------------------------------*/ + uint8_t send_data[1] = { 0x00 }; + uint8_t recv_data[16] = {}; + + int result = SendAndReceive(caps.idx_rgb_effects, caps.fn_pwr_config, + send_data, 1, recv_data, sizeof(recv_data)); + + if(result > 0) + { + uint16_t idle = ((uint16_t)recv_data[3] << 8) | recv_data[4]; + uint16_t sleep = ((uint16_t)recv_data[5] << 8) | recv_data[6]; + + if(idle > 0) + { + idle_timeout_s = idle; + fw_idle_timeout_s = idle; + } + + if(sleep > 0) + { + sleep_timeout_s = sleep; + fw_sleep_timeout_s = sleep; + } + + written_idle_s = idle; + written_sleep_s = sleep; + + LOG_TRACE("%s Firmware timers: idle=%us sleep=%us", LOG_TAG, idle_timeout_s, sleep_timeout_s); + } + else + { + written_idle_s = idle_timeout_s; + written_sleep_s = sleep_timeout_s; + + LOG_DEBUG("%s Failed to read firmware timers, using defaults (idle=%us sleep=%us)", + LOG_TAG, idle_timeout_s, sleep_timeout_s); + } +} + +void LogitechHIDPP20Controller::ReadNvSleepRampConfig() +{ + /*----------------------------------------------------------*\ + | RGBEffects fn3 NV_CONFIG (0x30) read of capability 0x0020 | + | (Off Ramp / Sleep Transition). | + | | + | Wire format (matches observed wire capture): | + | request: short msg, data = [0x00, cap_hi, cap_lo] | + | where 0x00 = sub-function GET | + | response: long msg, data = [echo (3 bytes), enabled, | + | ramp_seconds, ...] | + | | + | G515 default observed from vendor app: enabled=0x01, | + | seconds=0x1E (= 30 seconds dim ramp before sleep). | + \*----------------------------------------------------------*/ + if(caps.idx_rgb_effects == 0 || !caps.has_power_mgmt) + { + return; + } + + uint8_t send_data[3] = { 0x00, 0x00, 0x20 }; + uint8_t recv_data[16] = {}; + + int result = SendAndReceive(caps.idx_rgb_effects, FN_8071_NV_CONFIG, + send_data, sizeof(send_data), + recv_data, sizeof(recv_data)); + + if(result <= 0) + { + LOG_DEBUG("%s NvConfig 0x0020 read failed (result=%d)", LOG_TAG, result); + return; + } + + if(recv_data[0] != 0x00 || recv_data[1] != 0x00 || recv_data[2] != 0x20) + { + LOG_DEBUG("%s NvConfig 0x0020 read: unexpected echo %02X %02X %02X", + LOG_TAG, recv_data[0], recv_data[1], recv_data[2]); + return; + } + + caps.nv_sleep_ramp_enabled = (recv_data[3] != 0); + caps.nv_sleep_ramp_seconds = recv_data[4]; + caps.nv_sleep_ramp_known = true; + + LOG_DEBUG("%s NvConfig 0x0020 (sleep ramp): enabled=%d ramp=%us " + "raw=[%02X %02X %02X %02X %02X %02X %02X %02X]", + LOG_TAG, + (int)caps.nv_sleep_ramp_enabled, + (unsigned)caps.nv_sleep_ramp_seconds, + recv_data[3], recv_data[4], recv_data[5], recv_data[6], + recv_data[7], recv_data[8], recv_data[9], recv_data[10]); +} + +void LogitechHIDPP20Controller::WritePowerConfig(uint16_t idle_s, uint16_t sleep_s) +{ + /*----------------------------------------------------------*\ + | SetRgbPowerModeConfig (fn7, sub-function 0x01 = set) | + | Wire format (long message, 16 bytes payload, matches the | + | GET response layout at the same offsets): | + | [0x01, 0x00, 0x00, idle_hi, idle_lo, sleep_hi, sleep_lo, | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] | + | | + | These are the firmware's *runtime* power timers — the | + | values reset on power cycle but persist across SW control | + | release/reclaim, so we need to write them ourselves on | + | every claim to be safe. | + \*----------------------------------------------------------*/ + if(caps.idx_rgb_effects == 0 || !caps.has_power_mgmt) + { + return; + } + + uint8_t data[16] = {}; + data[0] = 0x01; // sub-function: SET + data[3] = (uint8_t)((idle_s >> 8) & 0xFF); + data[4] = (uint8_t)( idle_s & 0xFF); + data[5] = (uint8_t)((sleep_s >> 8) & 0xFF); + data[6] = (uint8_t)( sleep_s & 0xFF); + + blankFAPmessage response; + SendAckedIntoFAP(caps.idx_rgb_effects, caps.fn_pwr_config, + data, sizeof(data), response); + + LOG_DEBUG("%s WritePowerConfig: idle=%us sleep=%us", LOG_TAG, idle_s, sleep_s); +} + +void LogitechHIDPP20Controller::ReadActiveProfileSector() +{ + /*----------------------------------------------------------*\ + | Diagnostic-only read of the active profile sector via | + | ProfileManagement (0x8101) load + paged readBuffer. | + | | + | This sector is the canonical storage for persisted device | + | state on G-series devices: idle/sleep timers, baseline | + | RGB effect, FKC enable, and more. The HID++ feature | + | endpoints (0x8071, 0x8081, 0x1B05, ...) are mostly status | + | hooks; the configuration database lives here. We don't | + | act on the contents — just log them so we can see what | + | the device thinks its persisted state is. | + | | + | Wire format mirrors observed wire capture (load followed | + | by 7× readBuffer): | + | load: long msg, [partition=0x01, sector=0x01, | + | size_hi=0x00, size_lo=0x63, | + | padding to 16 bytes] | + | readBuffer: short msg, [offset_hi, offset_lo, 0] | + | returns long msg with 16 bytes of data | + | | + | Sector size 0x63 = 99 bytes is what the vendor app | + | requested for the G515 active profile. Other devices may | + | differ — we | + | hardcode it for now since this is diagnostic-only. | + \*----------------------------------------------------------*/ + if(caps.idx_profile_management == 0) + { + return; + } + + constexpr uint16_t SECTOR_SIZE = 0x63; // 99 bytes + constexpr uint16_t PAGE_SIZE = 16; + + /*---------------------------------------------------------*\ + | Step 1: load the sector into the device's read buffer | + \*---------------------------------------------------------*/ + uint8_t load_data[16] = {}; + load_data[0] = 0x01; // partition: NVS/flash + load_data[1] = 0x01; // sector: active profile + load_data[2] = (uint8_t)((SECTOR_SIZE >> 8) & 0xFF); // size hi + load_data[3] = (uint8_t)( SECTOR_SIZE & 0xFF); // size lo + + blankFAPmessage load_resp; + int load_result = SendAckedIntoFAP(caps.idx_profile_management, FN_8101_LOAD, + load_data, sizeof(load_data), load_resp); + + if(load_result <= 0) + { + LOG_DEBUG("%s ProfileSector load failed (result=%d)", LOG_TAG, load_result); + return; + } + + /*---------------------------------------------------------*\ + | Step 2: page the sector out 16 bytes at a time | + \*---------------------------------------------------------*/ + uint8_t sector_buf[SECTOR_SIZE] = {}; + + for(uint16_t offset = 0; offset < SECTOR_SIZE; offset += PAGE_SIZE) + { + uint8_t read_req[3] = { + (uint8_t)((offset >> 8) & 0xFF), + (uint8_t)( offset & 0xFF), + 0x00 + }; + uint8_t page_resp[20] = {}; + + int result = SendAndReceive(caps.idx_profile_management, FN_8101_READBUFFER, + read_req, sizeof(read_req), + page_resp, sizeof(page_resp)); + + if(result <= 0) + { + LOG_DEBUG("%s ProfileSector readBuffer offset=0x%04X failed (result=%d)", + LOG_TAG, (unsigned)offset, result); + return; + } + + size_t copy_len = (offset + PAGE_SIZE > SECTOR_SIZE) + ? (size_t)(SECTOR_SIZE - offset) + : PAGE_SIZE; + memcpy(sector_buf + offset, page_resp, copy_len); + } + + /*----------------------------------------------------------*\ + | Step 3: log as a hexdump, one row per 16 bytes | + \*----------------------------------------------------------*/ + LOG_DEBUG("%s ProfileSector partition=NVS sector=1 size=%u bytes:", + LOG_TAG, (unsigned)SECTOR_SIZE); + + for(uint16_t row = 0; row < SECTOR_SIZE; row += PAGE_SIZE) + { + size_t row_len = (row + PAGE_SIZE > SECTOR_SIZE) + ? (size_t)(SECTOR_SIZE - row) + : PAGE_SIZE; + + char hex[64] = {}; + char* p = hex; + for(size_t i = 0; i < row_len; i++) + { + snprintf(p, 4, "%02X ", sector_buf[row + i]); + p += 3; + } + + LOG_DEBUG("%s %04X: %s", LOG_TAG, (unsigned)row, hex); + } +} + diff --git a/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller.h b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller.h new file mode 100644 index 000000000..060f2ea6f --- /dev/null +++ b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller.h @@ -0,0 +1,874 @@ +/*---------------------------------------------------------*\ +| LogitechHIDPP20Controller.h | +| | +| Unified Logitech HID++ 2.0 controller | +| | +| Uses feature discovery (IRoot 0x0000) to dynamically | +| determine device capabilities and adapt to any HID++ | +| 2.0 device with RGB lighting support. | +| | +| This file is part of the OpenRGB project | +| SPDX-License-Identifier: GPL-2.0-or-later | +\*---------------------------------------------------------*/ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "RGBController.h" +#include "LogitechProtocolCommon.h" + +/*-----------------------------------------------------*\ +| HID++ 2.0 Feature Page IDs | +\*-----------------------------------------------------*/ +#define HIDPP20_FEAT_IROOT 0x0000 +#define HIDPP20_FEAT_FEATURE_SET 0x0001 +#define HIDPP20_FEAT_DEVICE_NAME_TYPE 0x0005 +#define HIDPP20_FEAT_ONBOARD_PROFILES 0x8100 +#define HIDPP20_FEAT_PROFILE_MANAGEMENT 0x8101 +#define HIDPP20_FEAT_FIRMWARE_INFO 0x0003 +#define HIDPP20_FEAT_CENTPPBRIDGE 0x0003 /* same ID, different meaning on Centurion */ +#define HIDPP20_FEAT_COLOR_LED_EFFECTS 0x8070 +#define HIDPP20_FEAT_RGB_EFFECTS 0x8071 +#define HIDPP20_FEAT_PER_KEY_LIGHTING_V1 0x8080 +#define HIDPP20_FEAT_PER_KEY_LIGHTING_V2 0x8081 +#define HIDPP20_FEAT_KEYBOARD_LAYOUT 0x4540 +#define HIDPP20_FEAT_DISABLE_KEYS_BY_USAGE 0x4522 +#define HIDPP20_FEAT_CENTURION_RGB 0x0600 +#define HIDPP20_FEAT_HEADSET_RGB_HOSTMODE 0x0620 +#define HIDPP20_FEAT_CENTURION_DEVICE_INFO 0x0100 +#define HIDPP20_FEAT_CENTURION_DEVICE_NAME 0x0101 +#define HIDPP20_FEAT_UNIFIED_BATTERY 0x1004 +#define HIDPP20_FEAT_WIRELESS_STATUS 0x1D4B + +/*-----------------------------------------------------*\ +| HID++ 2.0 Function IDs (byte 3 high nibble) | +| Function ID is shifted left 4 bits, low nibble = swID | +\*-----------------------------------------------------*/ +/*-----------------------------------------------------*\ +| HID++ Software ID — identifies our responses. | +| Must avoid: 0x00 (firmware), 0x01 (vendor app), | +| 0x02-0x0F (Solaar cycles these). | +| There are only 16 values (4-bit field), and all are | +| claimed. We pick a fixed value and will coordinate | +| with Solaar to exclude it from its cycle. | +\*-----------------------------------------------------*/ +#define HIDPP20_SW_ID 0x07 + +/*-----------------------------------------------------*\ +| Feature 0x8071 functions | +\*-----------------------------------------------------*/ +#define FN_8071_GET_INFO 0x00 +#define FN_8071_SET_EFFECT 0x10 +#define FN_8071_SET_PATTERN 0x20 +#define FN_8071_NV_CONFIG 0x30 +#define FN_8071_BIN_INFO 0x40 +#define FN_8071_SW_CONTROL 0x50 +#define FN_8071_SYNC 0x60 +#define FN_8071_PWR_CONFIG 0x70 +#define FN_8071_PWR_MODE 0x80 + +/*-----------------------------------------------------*\ +| Feature 0x8081 functions | +\*-----------------------------------------------------*/ +#define FN_8081_GET_INFO 0x00 +#define FN_8081_SET_INDIVIDUAL 0x10 +#define FN_8081_SET_CONSECUTIVE 0x20 +#define FN_8081_SET_DELTA_5BIT 0x30 +#define FN_8081_SET_DELTA_4BIT 0x40 +#define FN_8081_SET_RANGE 0x50 +#define FN_8081_SET_SINGLE_VALUE 0x60 +#define FN_8081_FRAME_END 0x70 + +/*-----------------------------------------------------*\ +| Feature 0x0620 functions (headset RGB hostmode) | +\*-----------------------------------------------------*/ +#define FN_0620_GET_INFO 0x00 +#define FN_0620_GET_RGB_ZONE_INFO 0x10 +#define FN_0620_SET_INDIVIDUAL_RGB_ZONES 0x20 +#define FN_0620_SET_CONSECUTIVE_RGB_ZONES 0x30 +#define FN_0620_SET_RANGE_RGB_ZONES 0x40 +#define FN_0620_SET_RGB_ZONES_SINGLE_VALUE 0x50 +#define FN_0620_FRAME_END 0x60 +#define FN_0620_GET_HOST_MODE_STATE 0x70 +#define FN_0620_SET_HOST_MODE_STATE 0x80 + +/*-----------------------------------------------------*\ +| Feature 0x8100 functions | +\*-----------------------------------------------------*/ +#define FN_8100_SET_ONBOARD_MODE 0x10 +#define FN_8100_GET_ONBOARD_MODE 0x20 + +/*-----------------------------------------------------*\ +| Feature 0x8101 functions | +\*-----------------------------------------------------*/ +#define FN_8101_GET_SET_MODE 0x60 +#define FN_8101_LOAD 0x80 // load(partition, sector, size) +#define FN_8101_READBUFFER 0xC0 // readBuffer(offset) + +/*-----------------------------------------------------*\ +| Zone cluster effect entry | +\*-----------------------------------------------------*/ +struct HIDPP20Effect +{ + uint8_t index; + uint16_t effect_id; + uint16_t capabilities; + uint16_t default_period; +}; + +/*-----------------------------------------------------*\ +| Zone cluster info from 0x8071 GetRgbClusterInfo | +\*-----------------------------------------------------*/ +struct HIDPP20ZoneCluster +{ + uint8_t index; + uint16_t location; + uint8_t effect_count; + std::vector effects; +}; + +/*-----------------------------------------------------*\ +| Per-model device quirks — behavioral differences that | +| can't be detected via feature probing. | +\*-----------------------------------------------------*/ +enum HIDPP20DeviceQuirks : uint32_t +{ + HIDPP20_QUIRK_NONE = 0, + HIDPP20_QUIRK_FADE_ACCEPTS_WRITES = (1 << 0), // firmware accepts host frames during sleep fade without waking +}; + +struct HIDPP20DeviceQuirkEntry +{ + uint16_t pid_wireless; + uint16_t pid_wired; + uint32_t quirks; +}; + +/*---------------------------------------------------------*\ +| Default: suppress frames while SLEEPING. Safe everywhere | +| — it cannot wake a device that treats writes as activity. | +| Devices listed here opt out of suppression because their | +| firmware accepts writes during the fade without | +| cancelling sleep. | +\*---------------------------------------------------------*/ +static constexpr HIDPP20DeviceQuirkEntry HIDPP20_DEVICE_QUIRK_TABLE[] = +{ + { 0x40B4, 0xC355, HIDPP20_QUIRK_FADE_ACCEPTS_WRITES }, // G515 LS TKL +}; + +/*-----------------------------------------------------*\ +| Device capabilities discovered via feature probing | +\*-----------------------------------------------------*/ +struct HIDPP20DeviceCapabilities +{ + std::string device_name; + uint8_t device_type; + std::string firmware_version; + std::string serial_number; + std::string unit_id; // stable hardware ID (from FirmwareInfo fn0) + uint16_t pid_wireless; // wireless virtual PID (from FirmwareInfo fn0) + uint16_t pid_wired; // wired USB PID (from FirmwareInfo fn0) + uint32_t quirks; // resolved from HIDPP20_DEVICE_QUIRK_TABLE after PID discovery + + /*--------------------------------------------------*\ + | Complete feature map (feature_id → runtime index) | + | Built once by EnumerateFeatures, used by all | + | subsequent GetFeatureIndex lookups (no wire). | + \*--------------------------------------------------*/ + std::map feature_map; + std::map feature_versions; /* feature_id -> version byte */ + bool feature_map_complete; // true after bulk enumeration + + /*-------------------------------------------------*\ + | Feature indices (0 = not supported) | + \*-------------------------------------------------*/ + uint8_t idx_onboard_profiles; + uint8_t idx_profile_management; + uint8_t idx_rgb_effects; + uint8_t idx_headset_rgb_hostmode; /* 0x0620 — Centurion headset RGB */ + uint8_t idx_perkey_v2; + uint8_t idx_perkey_v1; + uint8_t idx_wireless_status; + uint8_t idx_disable_keys_by_usage; /* 0x4522 — keyboard-family handshake */ + uint16_t rgb_feature_page; + + /*--------------------------------------------------*\ + | Resolved function IDs for the RGB effects feature | + | Varies between 0x8070, 0x8071, 0x0600 | + \*--------------------------------------------------*/ + uint8_t fn_set_effect; + uint8_t fn_sw_control; + uint8_t fn_pwr_config; + uint8_t fn_pwr_mode; + bool has_power_mgmt; + bool sw_control_simple; + + /*--------------------------------------------------*\ + | Persistent NV settings read from RGBEffects fn3 | + | (FN_8071_NV_CONFIG). Capability 0x0020 is the | + | sleep ramp / off-ramp transition (enabled + | + | ramp_seconds). | + \*--------------------------------------------------*/ + bool nv_sleep_ramp_known; + bool nv_sleep_ramp_enabled; + uint8_t nv_sleep_ramp_seconds; + + /*---------------------------------------------------*\ + | Device-firmware effect cards (0x8071 fn0 | + | GetEffectSpecificInfo). Populated by | + | DiscoverEffectCards at feature-discovery time. | + | | + | has_effect_cards — probe returned a valid response | + | for firmware card 0 page 1 (no InvalidArgument). | + | effect_card_template[0..1] — device-wide constant | + | bytes read from that response at data[10..11]. | + | Echoed back into prep1 of DoObservedPerKeyPrep | + | at SetEffectByIndex params[6..7]. | + | | + | Gate for the observed per-key prep is now | + | has_effect_cards — replaces the earlier | + | "effects.size() < 5" heuristic, which was a proxy | + | that correlated with "has cards" on the devices we | + | happened to know but had no principled meaning. | + \*---------------------------------------------------*/ + bool has_effect_cards; + uint8_t effect_card_template[2]; + + /*-------------------------------------------------*\ + | Discovered zone and LED data | + \*-------------------------------------------------*/ + std::vector zone_clusters; + std::vector perkey_zone_ids; + std::vector headset_rgb_hostmode_zone_ids; /* 0x0620 fn1 result */ + bool has_perkey; + bool has_zone_effects; + bool is_headset_rgb_hostmode; /* 0x0620 path selected */ + bool has_numpad; + uint8_t keyboard_layout_code; +}; + +/*------------------------------------------------------*\ +| Transport type — determines wire framing | +\*------------------------------------------------------*/ +enum HIDPP20TransportType +{ + HIDPP20_TRANSPORT_STANDARD, // 0xFF00/0xFF43: report IDs 0x10/0x11, 7/20 bytes + HIDPP20_TRANSPORT_CENTURION // 0xFFA0: report ID 0x51 or 0x50, 64 bytes, + // with CPL framing and CentPPBridge sub-device routing +}; + +/*------------------------------------------------------*\ +| Transport layer — abstracts wire format differences | +| | +| Standard HID++ and Centurion both carry the same | +| feature/function/data payload, but with different | +| report framing. This struct holds transport state | +| so SendMessage/ReadMessage can adapt. | +\*------------------------------------------------------*/ +struct HIDPP20Transport +{ + HIDPP20TransportType type; + uint16_t usage_page; // 0xFF00, 0xFF43, or 0xFFA0 + uint8_t report_id; // 0x10/0x11 for standard, 0x51/0x50 for Centurion + bool addressed; // Centurion 0x50 has device address byte + uint8_t device_address; // Centurion 0x50: device address (e.g., 0x23) + uint8_t bridge_feat_idx; // CentPPBridge feature index on parent (0 if N/A) + uint8_t sub_device_id; // CentPPBridge sub-device ID (typically 0) + uint16_t bridge_mtu; // CentPPBridge MTU from getConnectionInfo: + // 0 = no sub-device, sendFragment will fail + // >0 = sub-device present, payload size in bytes +}; + +/*-----------------------------------------------------*\ +| Power management state machine | +| Matches Solaar's RGBPowerManager states | +\*-----------------------------------------------------*/ +enum HIDPP20PowerState +{ + HIDPP20_POWER_ACTIVE = 0, + HIDPP20_POWER_DIMMING = 1, + HIDPP20_POWER_IDLE = 2, + HIDPP20_POWER_SLEEPING = 3, +}; + +/*-----------------------------------------------------*\ +| Parsed HID++ message for the response queue | +\*-----------------------------------------------------*/ +struct HIDPP20RawMessage +{ + uint8_t feat; + uint8_t func; + uint8_t data[60]; + int result; +}; + +/*------------------------------------------------------*\ +| Per-key write tracking. SendPerKeyData is fire-and- | +| forget at the wire layer; we track which zones each | +| outstanding write covers so PerKeyFrameEnd can match | +| ACKs back by FIFO order and report which zones the | +| firmware actually committed. | +\*------------------------------------------------------*/ +struct OutstandingPerKeyWrite +{ + uint8_t function; // FN_8081_* (high nibble carries the type) + std::vector zone_ids; // zones covered by this packet +}; + +/*-----------------------------------------------------*\ +| Result of a per-key frame commit. The caller uses | +| these to update its delta-tracking state: | +| - frame_end_acked: did the FrameEnd packet ACK? | +| - acked_zones: zones whose write packet ACKed | +| - attempted_zones: every zone written this frame | +\*-----------------------------------------------------*/ +struct PerKeyFrameResult +{ + bool frame_end_acked; + std::vector acked_zones; + std::vector attempted_zones; +}; + +/*-------------------------------------------------------*\ +| Retry policy for SendAcked. | +| | +| Controls the send/read/retry loop for a single HID++ | +| request. Three canned policies cover all use cases: | +| | +| Reliable: probe/discovery/set/get (~6s worst case) | +| FrameEnd: per-key commit gate (~230ms worst) | +| Streaming: per-key write inside the animation loop | +| (~80ms worst) | +| | +| backoff_ms[i] is the delay applied BEFORE the i-th | +| attempt. backoff_ms[0] is normally 0 (no delay before | +| the first send). The schedule mirrors the firmware's | +| own event burst pattern (63→125→250→500→1000→2000ms, | +| "catch at least one of N"). | +\*-------------------------------------------------------*/ +struct HIDPP20RetryPolicy +{ + const uint16_t* backoff_ms; // schedule[i] = delay before attempt i + uint8_t attempts; // length of backoff_ms (>=1) + uint16_t read_window_ms; // per-attempt read budget + bool flush_before; // flush response queue at call start + bool retry_on_busy; // BUSY (0x08) -> retry the send + const char* name; // for logging ("reliable", etc.) +}; + +/*------------------------------------------------------*\ +| Canned backoff schedules. | +\*------------------------------------------------------*/ +static constexpr uint16_t HIDPP20_BACKOFF_RELIABLE[] = + { 0, 63, 125, 250, 500, 1000, 2000 }; +static constexpr uint16_t HIDPP20_BACKOFF_PROBE[] = + { 0, 100 }; + +/*------------------------------------------------------*\ +| SW-control reclaim backoff. Used by ReconnectDevice | +| to retry the claim+push sequence after a wireless | +| reconnect, racing the firmware boot animation. The | +| vendor app typically lands control in ~50ms; this | +| schedule fits | +| 6 attempts inside ~620ms so the animation never gets | +| a chance to become visible. | +\*------------------------------------------------------*/ +static constexpr uint16_t HIDPP20_RECLAIM_BACKOFF_MS[] = + { 0, 20, 40, 80, 160, 320 }; + +/*------------------------------------------------------*\ +| FrameEnd BUSY retry backoff. Used by PerKeyFrameEnd | +| when the firmware returns HID++ error 0x08 (BUSY) | +| because it's still draining the per-key write queue. | +| First retry is fast (~2 USB round trips) for the | +| common case where BUSY was transient; subsequent | +| retries give actual drain headroom. Total worst case | +| ~180ms, fits inside the PerKeyFrameEnd 300ms deadline | +| with margin for the eventual ACK to land. | +\*------------------------------------------------------*/ +static constexpr uint16_t HIDPP20_FRAME_END_BUSY_BACKOFF_MS[] = + { 30, 60, 90 }; + +/*-----------------------------------------------------*\ +| Deep-sleep detection threshold. After StartSleep() | +| commands the firmware fade, the device eventually | +| enters deep sleep and returns BUSY to every FrameEnd. | +| Once this many consecutive FrameEnd attempts exhaust | +| all BUSY retries while power_state == SLEEPING, we | +| suppress further frame sends until Wake() fires. | +\*-----------------------------------------------------*/ +static constexpr int HIDPP20_DEEP_SLEEP_FAILURE_THRESHOLD = 5; + +/*------------------------------------------------------*\ +| Per-key frame retry backoff. Used when a full | +| DeviceUpdateLEDs pass completes with some zones | +| unacked (partial commit). The retry re-runs a whole | +| frame from the power thread, so the backoff is | +| between full frames, not individual packets. | +| | +| First value aligned to the power thread's 50ms poll | +| cadence — anything shorter rounds up anyway, and | +| matching the tick makes latency predictable. | +| | +| Worst case: 5 retries, cumulative ~1550ms. This | +| covers the reconnect-transient window where the G502 | +| firmware silently drops per-key writes for several | +| hundred ms after the wireless link re-establishes. | +\*------------------------------------------------------*/ +static constexpr uint16_t HIDPP20_REPAINT_RETRY_BACKOFF_MS[] = + { 50, 100, 200, 400, 800 }; + +static constexpr HIDPP20RetryPolicy HIDPP20_POLICY_RELIABLE = { + HIDPP20_BACKOFF_RELIABLE, + sizeof(HIDPP20_BACKOFF_RELIABLE) / sizeof(uint16_t), + 300, // read window + true, // flush_before + true, // retry_on_busy + "reliable" +}; + +/*------------------------------------------------------*\ +| Probe policy: tight budget for is-this-HID++ checks | +| during initial discovery. ~500ms worst case for dead | +| devices, vs ~6s for reliable. One retry handles a | +| transient hiccup on the first IRoot call (e.g. on a | +| busy mouse), but we bail fast on truly non-responsive | +| or non-HID++ hidraws so probe latency stays bounded. | +\*------------------------------------------------------*/ +static constexpr HIDPP20RetryPolicy HIDPP20_POLICY_PROBE = { + HIDPP20_BACKOFF_PROBE, + sizeof(HIDPP20_BACKOFF_PROBE) / sizeof(uint16_t), + 200, // read window + true, // flush_before + true, // retry_on_busy + "probe" +}; + +class LogitechHIDPP20Controller +{ +public: + LogitechHIDPP20Controller(hid_device* dev, const char* path, + uint8_t device_index, bool wireless, + std::shared_ptr mutex_ptr, + uint16_t usage_page = 0xFF00); + ~LogitechHIDPP20Controller(); + + /*-------------------------------------------------*\ + | Lifecycle | + \*-------------------------------------------------*/ + bool Probe(); + void Initialize(); + void Shutdown(); + + /*-------------------------------------------------*\ + | Accessors | + \*-------------------------------------------------*/ + const HIDPP20DeviceCapabilities& GetCapabilities() const; + std::string GetDeviceLocation(); + std::string GetSerialString(); + uint32_t GetInitGeneration() const; + + /*--------------------------------------------------*\ + | Per-key lighting (0x8081) | + | | + | Per-key writes are fire-and-forget at the wire | + | layer. SendPerKeyData enqueues an outstanding | + | write entry; PerKeyFrameEnd drains the response | + | queue, matches ACKs by FIFO, and returns which | + | zones the firmware actually committed plus | + | whether the FrameEnd itself ACKed. | + \*--------------------------------------------------*/ + void SetPerKeyColors(const std::vector>& zone_colors); + void SetAllPerKeyColor(RGBColor color); + void SendPerKeyData(uint8_t perkey_idx, uint8_t function, + const uint8_t* data, size_t len, + const std::vector& zone_ids); + PerKeyFrameResult PerKeyFrameEnd(); + + /*-------------------------------------------------*\ + | Zone effects (0x8071 / 0x8070) | + \*-------------------------------------------------*/ + void SetZoneEffect(uint8_t cluster_idx, uint8_t effect_idx, + uint16_t effect_id, + unsigned char r, unsigned char g, unsigned char b, + uint16_t period, unsigned char brightness, + unsigned char direction, bool persist); + + /*---------------------------------------------------*\ + | Headset RGB hostmode (0x0620). | + | Sticky-claim model: SetHostMode() claims once via | + | fn8, then each write is fn5 (single-value) or fn2 | + | (individual) + fn6 FrameEnd[0x01]. 0x02 persist was | + | tested and does not work on G522 firmware. | + \*---------------------------------------------------*/ + void SetHeadsetRGBHostmodeColors(const std::vector& zone_colors); + + /*-------------------------------------------------*\ + | SW control management | + \*-------------------------------------------------*/ + int SetSWControl(uint8_t mode, uint8_t flags); + void SetRGBPowerMode(uint8_t mode); + void SetHostMode(); + bool ClaimSWControlIfNeeded(); + void UpgradeSwControlAfterFirstPaint(); + + /*-------------------------------------------------*\ + | Keyboard-family handshake (0x4522 fn3 + fn1). | + | G815 / G915 / G Pro send this before any mode | + | write. Feature-gated no-op on devices (G502 / | + | G515) that don't enumerate 0x4522. | + \*-------------------------------------------------*/ + void DoDisableKeysByUsageHandshake(); + + /*--------------------------------------------------*\ + | Keyboard-family per-key takeover prep. | + | Per-cluster SetEffectByIndex(effectIdx=0=Off, | + | persist=1) + primer key via SetIndividualRgbZones | + | + FrameEnd. Matches G815 / G915 InitializeDirect. | + | Gated on 0x4522 + per-key V2 presence. | + \*--------------------------------------------------*/ + void DoKeyboardFamilyPerKeyPrep(); + + /*--------------------------------------------------*\ + | Wake-repaint flag. Set by Wake() before calling | + | request_repaint_fn so the repaint callback knows | + | to invalidate sent_colors (force a full per-key | + | push) without triggering the claim/prep sequence. | + \*--------------------------------------------------*/ + bool ConsumeWakeFullRepaint(); + bool NeedsPrepSequence() const { return sw_control_needs_upgrade_to_5; } + + /*--------------------------------------------------*\ + | Per-key retry scheduling. Called by the RGB | + | controller's DeviceUpdateLEDs on partial-commit | + | frames; the power thread polls and fires | + | request_repaint_fn when a retry deadline expires. | + \*--------------------------------------------------*/ + void ScheduleRetryPaint(); + void CancelRetryPaint(); + void TickRetryPaintIfPending(); + + /*--------------------------------------------------*\ + | Observed per-key prep sequence. Two | + | SetEffectByIndex calls cloned byte-for-byte from a | + | wire capture on a G502 X PLUS. | + | Used in place of the Static-pass-through prep when | + | the device's RGBEffects enumeration matches the | + | G502 shape — see DeviceUpdateLEDs for gating. | + \*--------------------------------------------------*/ + void DoObservedPerKeyPrep(); + + /*--------------------------------------------------*\ + | Power management (idle/dim/sleep/wake) | + \*--------------------------------------------------*/ + void StartPowerManager(); + void StopPowerManager(); + void StartEventWatcher(); + void StartProbeWatcher(); + bool HasBridge() const; + void SetRepaintCallback(std::function repaint); + void SetReapplyActiveModeCallback(std::function cb); + void SetRegisterCallback(std::function cb); + HIDPP20PowerState GetPowerState() const; + int GetDimBrightness() const; + bool IsOnline() const; + bool IsDeepSleep() const; + void SetWireless(bool w) { wireless = w; } + bool QueryWirelessStatus(); + bool QueryExternalPower(); + void FlushResponseQueue(); + +private: + hid_device* dev; + std::string location; + uint8_t device_index; + bool wireless; + std::shared_ptr mutex; + HIDPP20DeviceCapabilities caps; + HIDPP20Transport transport; + bool initialized; + bool sw_control_claimed; + bool sw_control_needs_upgrade_to_5; + uint32_t frame_counter; + + /*--------------------------------------------------*\ + | Retry-paint state (partial-commit recovery). | + | retry_paint_deadline_ zero = no retry pending. | + | retry_paint_attempt_ indexes into | + | HIDPP20_REPAINT_RETRY_BACKOFF_MS; once it reaches | + | the array length, we give up for this sequence. | + | Atomic so both the paint thread (RGB controller) | + | and the power thread can access without locks. | + \*--------------------------------------------------*/ + std::atomic retry_paint_deadline_; + std::atomic retry_paint_attempt_; + std::atomic wake_full_repaint_pending_; + uint32_t init_generation; + std::string log_tag; + + /*---------------------------------------------------*\ + | Transport-layer I/O | + | | + | SendMessage/ReadMessage handle wire framing based | + | on transport.type. Upper layers pass feature index, | + | function ID, and payload — the transport layer | + | wraps them in the correct report format. | + \*---------------------------------------------------*/ + int SendMessage(uint8_t feat_idx, uint8_t function, + const uint8_t* data, size_t len); + int ReadMessage(uint8_t* feat_idx_out, uint8_t* function_out, + uint8_t* data_out, size_t data_max, + int timeout_ms = LOGITECH_PROTOCOL_TIMEOUT); + + /*--------------------------------------------------*\ + | Standard HID++ transport (0xFF00/0xFF43) | + \*--------------------------------------------------*/ + int SendStandard(uint8_t feat_idx, uint8_t function, + const uint8_t* data, size_t len); + int ReadStandardDirect(uint8_t* feat_idx_out, uint8_t* function_out, + uint8_t* data_out, size_t data_max, + int timeout_ms); + + /*--------------------------------------------------*\ + | Centurion transport (0xFFA0) | + | Wraps messages in CPL framing, routes through | + | CentPPBridge for sub-device access. | + \*--------------------------------------------------*/ + int SendCenturion(uint8_t feat_idx, uint8_t function, + const uint8_t* data, size_t len); + int ReadCenturionDirect(uint8_t* feat_idx_out, uint8_t* function_out, + uint8_t* data_out, size_t data_max, + int timeout_ms); + + /*--------------------------------------------------*\ + | Reader thread dispatch layer | + | ReadHIDDirect: raw HID read (used by reader thread | + | and during Probe before reader starts). | + | ReadFromQueue: waits on response queue filled by | + | the reader thread. | + \*--------------------------------------------------*/ + int ReadHIDDirect(uint8_t* feat_idx_out, uint8_t* function_out, + uint8_t* data_out, size_t data_max, + int timeout_ms); + int ReadFromQueue(uint8_t* feat_idx_out, uint8_t* function_out, + uint8_t* data_out, size_t data_max, + int timeout_ms); + + /*---------------------------------------------------*\ + | High-level helpers | + | SendAndReceive is retained as a thin wrapper | + | around SendAcked with the reliable policy, for | + | call-site stability. | + \*---------------------------------------------------*/ + int SendAndReceive(uint8_t feat_idx, uint8_t function, + const uint8_t* send_data, size_t send_len, + uint8_t* recv_data, size_t recv_max); + + /*---------------------------------------------------*\ + | Unified send-and-ack primitive with retry policy. | + | All command paths converge here. Returns: | + | >0 : bytes copied into recv_data | + | 0 : timeout / BUSY exhaustion | + | -1 : non-BUSY HID++ error | + | -2 : wire error (SendMessage failed) | + | If hidpp20_error_out is non-null and return is -1, | + | the HID++ error code is stored there. | + \*---------------------------------------------------*/ + int SendAcked(uint8_t feat_idx, uint8_t function, + const uint8_t* send_data, size_t send_len, + uint8_t* recv_data, size_t recv_max, + const HIDPP20RetryPolicy& policy = HIDPP20_POLICY_RELIABLE, + uint8_t* hidpp20_error_out = nullptr); + + /*--------------------------------------------------*\ + | Compatibility shim: same as SendAcked but writes | + | the response into a blankFAPmessage. Used by | + | callers that inherited the SendLong+ReadResponse | + | interface and inspect response.data[] downstream. | + \*--------------------------------------------------*/ + int SendAckedIntoFAP(uint8_t feat_idx, uint8_t function, + const uint8_t* send_data, size_t send_len, + blankFAPmessage& response, + const HIDPP20RetryPolicy& policy = HIDPP20_POLICY_RELIABLE); + + /*-------------------------------------------------*\ + | Feature discovery | + \*-------------------------------------------------*/ + uint8_t GetFeatureIndex(uint16_t feature_page, + const HIDPP20RetryPolicy& policy = HIDPP20_POLICY_RELIABLE); + uint8_t GetFeatureVersion(uint16_t feature_page) const; + void DiscoverTransport(); + void DiscoverDeviceName(); + void DiscoverDeviceType(); + void EnumerateFeatures(uint8_t feature_set_idx); + void DiscoverFirmwareInfo(); + void DiscoverRGBEffects(); + void DiscoverEffectCards(); + void DiscoverHeadsetRGBHostmode(); + void DiscoverPerKeyZones(); + void DiscoverKeyboardLayout(); + + /*-------------------------------------------------*\ + | Power management internals | + \*-------------------------------------------------*/ + void ReaderThreadFunc(); + void PowerThreadFunc(); + void DispatchEvent(uint8_t feat, uint8_t func, const uint8_t* data); + void OnUserActivity(uint8_t activity_type); + void StartDimRamp(); + void DimRampStep(); + void StartSleep(); + void Wake(); + void ReadFirmwareTimers(); + void ReadNvSleepRampConfig(); + void WritePowerConfig(uint16_t idle_s, uint16_t sleep_s); + void ReadActiveProfileSector(); + void ReprobeSubDevice(); + void ReconnectDevice(); + void FullReprobe(); + void RediscoverFeatures(); + /*----------------------------------------------------*\ + | Platform-specific. ScanForDevice walks the OS-level | + | HID enumeration to find the same physical device on | + | a new path (USB<->wireless transitions). Linux uses | + | sysfs; Windows and macOS use hidapi + serial_number | + | matching. Bodies live in | + | LogitechHIDPP20Controller_Linux.cpp and | + | LogitechHIDPP20Controller_Windows_MacOS.cpp. | + \*----------------------------------------------------*/ + bool ScanForDevice(bool force = false); + + /*----------------------------------------------------*\ + | Platform-specific. Returns the friendly name for a | + | Centurion sub-device at the given hidapi path, or "" | + | if no name is available. Linux reads HID_NAME from | + | sysfs; Windows uses hid_device_info::product_string. | + \*----------------------------------------------------*/ + std::string GetCenturionSubDeviceName(const std::string& path); + + void SwapHIDHandle(hid_device* new_dev, const std::string& new_path); + + /*-------------------------------------------------*\ + | Reader thread + response queue | + \*-------------------------------------------------*/ + std::thread* reader_thread; + std::atomic reader_running; + std::mutex response_mutex; + std::condition_variable response_cv; + std::deque response_queue; + + /*-------------------------------------------------*\ + | Power thread (state machine + command sender) | + \*-------------------------------------------------*/ + std::thread* power_thread; + std::atomic power_thread_running; + std::atomic pending_activity; // -1=none, 0=idle, 1+=active + std::atomic pending_connection; // 0=none, +1=connected, -1=disconnected + std::atomic pending_path_check; // HID++1.0 DJ notification → force-scan retries remaining (0=idle) + std::atomic device_online; // false when device is unreachable + std::atomic consecutive_timeouts; // reset on successful response + std::atomic watcher_mode; // true when retrying failed probe + + /*-------------------------------------------------*\ + | Power management state | + \*-------------------------------------------------*/ + HIDPP20PowerState power_state; + std::mutex power_mutex; + std::atomic deep_sleep; // true once device stops responding after StartSleep() + std::atomic consecutive_frame_end_failures; // FrameEnd BUSY exhaustions while SLEEPING + + /*-------------------------------------------------*\ + | Dim ramp state | + | dim_brightness_pct is applied by DeviceUpdateLEDs | + | to scale colors before pushing to device. | + | This is our own host-side animation, independent | + | of any firmware dim/sleep timers. | + \*-------------------------------------------------*/ + static const int DIM_STEPS = 25; + static const int DIM_INTERVAL_MS = 200; + static const int DIM_TARGET_PCT = 50; + std::atomic dim_brightness_pct; // 100=full, 50=dimmed + int dim_step; + std::chrono::steady_clock::time_point next_dim_time; + + /*-------------------------------------------------*\ + | Sleep timer | + \*-------------------------------------------------*/ + std::chrono::steady_clock::time_point sleep_deadline; + + /*--------------------------------------------------*\ + | Last idle-settings re-read timestamp. Drives the | + | 500ms poll in PowerThreadFunc that re-reads the | + | LogitechHIDPP20IdleSettings JSON key so updates | + | from the plugin (or manual edits) apply within | + | about half a second without any callback plumbing. | + \*--------------------------------------------------*/ + std::chrono::steady_clock::time_point last_idle_poll; + + /*-------------------------------------------------*\ + | Effective idle/sleep timers used by the state | + | machine. Populated from the firmware snapshot by | + | default, then possibly overridden by profile | + | values in ApplyPowerSavingProfile. | + \*-------------------------------------------------*/ + uint16_t idle_timeout_s; + uint16_t sleep_timeout_s; + + /*--------------------------------------------------*\ + | Firmware-configured timer snapshot. Read at init | + | (and on reconnect) by ReadFirmwareTimers and | + | never overwritten by profile application, so | + | that the unconfigured fallback path and transition | + | back from a user profile both have a clean set | + | of defaults to return to. | + \*--------------------------------------------------*/ + uint16_t fw_idle_timeout_s = 60; + uint16_t fw_sleep_timeout_s = 300; + uint16_t written_idle_s = 0; // last value written to device RAM (0 = not written yet) + uint16_t written_sleep_s = 0; + + /*--------------------------------------------------*\ + | Host-side idle/dim/sleep state. | + | Populated from LogitechHIDPP20IdleSettings on each | + | ApplyPowerSavingProfile() invocation. | + \*--------------------------------------------------*/ + bool ps_dim_enabled = false; + int ps_dim_target_pct = DIM_TARGET_PCT; + bool ps_sleep_enabled = false; + bool ps_on_external_power = false; + bool ps_last_logged_external = false; + int ps_last_logged_pct = -1; + int ps_last_logged_idle = -1; + int ps_last_logged_sleep = -1; + uint16_t last_power_raw = 0xFFFF; // dedup for QueryExternalPower TRACE + uint8_t idx_unified_battery = 0; + + void ApplyPowerSavingProfile(); + bool IsCurrentlyWireless() const; + + /*-------------------------------------------------*\ + | Per-key write tracking (per active frame) | + | Populated by SendPerKeyData, drained by | + | PerKeyFrameEnd. Single-threaded — only the RGB | + | controller thread touches per-key state. | + \*-------------------------------------------------*/ + std::vector outstanding_writes; + + /*-------------------------------------------------*\ + | Callbacks | + \*-------------------------------------------------*/ + std::function request_repaint_fn; + std::function reapply_active_mode_fn; + std::function register_controller_fn; +}; diff --git a/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller_Linux.cpp b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller_Linux.cpp new file mode 100644 index 000000000..d166e0612 --- /dev/null +++ b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller_Linux.cpp @@ -0,0 +1,433 @@ +/*---------------------------------------------------------*\ +| LogitechHIDPP20Controller_Linux.cpp | +| | +| Linux-specific path-migration and device-name lookup | +| for the unified Logitech HID++ 2.0 controller. | +| | +| Uses sysfs (/sys/class/hidraw) to find the same | +| physical device on a new hidraw path after a USB <-> | +| wireless transition, and to read Centurion sub-device | +| friendly names from HID_NAME uevent fields. | +| | +| This file is part of the OpenRGB project | +| SPDX-License-Identifier: GPL-2.0-or-later | +\*---------------------------------------------------------*/ + +#include +#include +#include +#include +#include +#include +#include +#include +#include "LogitechHIDPP20Controller.h" +#include "LogManager.h" + +#define LOG_TAG log_tag.c_str() + +std::string LogitechHIDPP20Controller::GetCenturionSubDeviceName(const std::string& path) +{ + /*--------------------------------------------------------*\ + | Centurion sub-device friendly name comes from sysfs | + | HID_NAME=, same field Solaar reads. The `path` arg is a | + | hidraw dev path like /dev/hidraw5; extract the hidrawN | + | basename and read /sys/class/hidraw//device/ | + | uevent. | + \*--------------------------------------------------------*/ + std::string sysfs_name; + size_t pos = path.rfind("hidraw"); + + if(pos == std::string::npos) + { + return sysfs_name; + } + + std::string uevent_path = "/sys/class/hidraw/" + path.substr(pos) + "/device/uevent"; + FILE* f = fopen(uevent_path.c_str(), "r"); + + if(!f) + { + return sysfs_name; + } + + char line[256]; + + while(fgets(line, sizeof(line), f)) + { + if(strncmp(line, "HID_NAME=", 9) == 0) + { + sysfs_name = line + 9; + + while(!sysfs_name.empty() && (sysfs_name.back() == '\n' || sysfs_name.back() == '\r')) + { + sysfs_name.pop_back(); + } + + break; + } + } + + fclose(f); + return sysfs_name; +} + +bool LogitechHIDPP20Controller::ScanForDevice(bool force) +{ + /*---------------------------------------------------------*\ + | Scan sysfs for a hidraw with matching unitId. Called by | + | the power thread either periodically (normal reactive | + | mode — only when device_online==false, USB/wireless | + | transition or physical unplug) or on demand from the | + | reader thread after a HID++1.0 Device Connection | + | notification arrives (force=true, bypasses the online | + | gate). Finds the device on its new connection path. | + | | + | Reactive-only: we never migrate while the current path | + | still works. On devices like the G502 X PLUS that expose | + | both a USB-direct hidraw AND a wireless-via-dongle hidraw | + | simultaneously (when paired to the receiver and plugged | + | in at the same time), eager migration would bounce us off | + | a working path onto one the firmware has actively muted, | + | breaking control. The device signals which path is | + | active by returning errors/going silent on the inactive | + | path — our reader thread picks that up as a hid_read | + | failure and flips device_online=false, at which point the | + | power thread calls this function to pick the new path. | + | | + | Linux implementation: walks /sys/class/hidraw and matches | + | on HID_UNIQ. The Windows counterpart (same class method, | + | different .cpp file) uses hid_enumerate + serial_number. | + | | + | Match criteria: | + | - device_online == false (caller should already ensure) | + | - HID_UNIQ matches our unitId | + | - Logitech VID (046D) | + | - HID++ interface (usage_page 0xFF00, usage 2) | + | - Different from our current path (device moved) | + \*---------------------------------------------------------*/ + if(caps.unit_id.empty() || caps.unit_id == "00000000") + { + return false; + } + + /*---------------------------------------------------------*\ + | Online guard: never migrate off a working path unless | + | the caller explicitly forces a re-check. The normal | + | periodic scan path stays gated on device_online==false | + | so it's a no-op when everything is healthy. | + | | + | The reader thread bypasses this guard (force=true) after | + | seeing a HID++1.0 Device Connection Status notification | + | from the Lightspeed receiver, which fires BEFORE the | + | firmware fully switches its data flow from wireless to | + | USB. At that moment device_online is still true (the | + | current path hasn't failed yet), but we want the scan to | + | run so we can migrate to the new path proactively. | + \*---------------------------------------------------------*/ + if(!force && device_online.load()) + { + return false; + } + + /*---------------------------------------------------------*\ + | Normalize our unitId to lowercase hex without dashes for | + | comparison. Sysfs HID_UNIQ varies between drivers: | + | - Lightspeed virtual: "0d-12-5d-47" (dashes) | + | - USB direct: "0D125D47" (no dashes) | + | We strip dashes and lowercase both sides for matching. | + \*---------------------------------------------------------*/ + std::string target_norm; + + for(char c : caps.unit_id) + { + if(c != '-') + { + target_norm += (char)tolower(c); + } + } + + /*---------------------------------------------------------*\ + | Read our current PID from sysfs. Only migrate to paths | + | with a DIFFERENT PID — same PID means same receiver, | + | just a different pairing slot (e.g., stale pairing). | + \*---------------------------------------------------------*/ + unsigned int current_pid = 0; + { + std::string cur_hidraw = location.substr(location.rfind('/') + 1); + std::string cur_uevent = "/sys/class/hidraw/" + cur_hidraw + "/device/uevent"; + std::ifstream cur_file(cur_uevent); + std::string line; + + while(std::getline(cur_file, line)) + { + if(line.compare(0, 7, "HID_ID=") == 0) + { + size_t lc = line.rfind(':'); + + if(lc != std::string::npos) + { + sscanf(line.c_str() + lc + 1, "%x", ¤t_pid); + } + + break; + } + } + } + + /*----------------------------------------------------------*\ + | Collect ALL sysfs hidraws with matching unit_id + Logitech | + | VID + different PID. With multiple Lightspeed dongles in | + | the system, several hidraws can share the same HID_UNIQ: | + | one is the live virt-slot on the connected dongle, others | + | are stale virt-slots on dongles where our device is not | + | actually present. We have to probe each one to find out | + | which slot is real — sysfs alone can't tell them apart. | + \*----------------------------------------------------------*/ + struct Candidate + { + std::string dev_path; + unsigned int pid; + }; + + std::vector candidates; + + DIR* dir = opendir("/sys/class/hidraw"); + + if(!dir) + { + return false; + } + + struct dirent* entry; + + while((entry = readdir(dir)) != nullptr) + { + if(strncmp(entry->d_name, "hidraw", 6) != 0) + { + continue; + } + + std::string uevent_path = "/sys/class/hidraw/" + + std::string(entry->d_name) + "/device/uevent"; + std::ifstream uevent(uevent_path); + + if(!uevent.is_open()) + { + continue; + } + + std::string line; + std::string hid_uniq; + std::string hid_id; + + while(std::getline(uevent, line)) + { + if(line.compare(0, 9, "HID_UNIQ=") == 0) + { + hid_uniq = line.substr(9); + } + else if(line.compare(0, 7, "HID_ID=") == 0) + { + hid_id = line.substr(7); + } + } + + std::string uniq_norm; + + for(char c : hid_uniq) + { + if(c != '-') + { + uniq_norm += (char)tolower(c); + } + } + + if(uniq_norm != target_norm) + { + continue; + } + + if(hid_id.find("0000046D") == std::string::npos && + hid_id.find("0000046d") == std::string::npos) + { + continue; + } + + std::string dev_path = "/dev/" + std::string(entry->d_name); + + if(dev_path == location) + { + continue; + } + + size_t last_colon = hid_id.rfind(':'); + + if(last_colon == std::string::npos) + { + continue; + } + + unsigned int pid = 0; + sscanf(hid_id.c_str() + last_colon + 1, "%x", &pid); + + if(pid == current_pid) + { + continue; + } + + candidates.push_back({dev_path, pid}); + } + + closedir(dir); + + if(candidates.empty()) + { + return false; + } + + /*----------------------------------------------------------*\ + | Probe each candidate before committing. Two-dongle systems | + | can expose multiple sysfs hidraws with the same HID_UNIQ: | + | one is the live slot, others are stale pairings that | + | respond with short-format UNKNOWN_DEVICE errors. Issue a | + | cheap IRoot GetFeature (feat 0x0001) to distinguish them. | + | A live slot returns a long-form response with feat_idx=0 | + | and feat_byte matching the sub-index we asked for. A stale | + | slot returns r[10 xx 8F 00 00 08 ...] (short error, code | + | 0x08 UNKNOWN_DEVICE). We accept the first candidate that | + | passes; the overall pending_path_check retry loop will | + | naturally re-probe all candidates on subsequent scans if | + | none pass on the current pass (e.g., mid-transition). | + \*----------------------------------------------------------*/ + std::string found_path; + hid_device* found_dev = nullptr; + + for(size_t c = 0; c < candidates.size(); c++) + { + const Candidate& cand = candidates[c]; + + LOG_DEBUG("%s Scan: migration candidate at %s (pid=0x%04X, current=%s pid=0x%04X)", + LOG_TAG, cand.dev_path.c_str(), cand.pid, + location.c_str(), current_pid); + + /*-----------------------------------------------------*\ + | Multi-dongle caveat: hid_enumerate returns entries | + | for ALL dongles with this PID (046D:4099 Lightspeed | + | can appear several times in a single machine). Match | + | strictly on the sysfs candidate's dev_path so each | + | candidate resolves to its OWN dongle's HID++ | + | interface — not the first match we stumble across. | + \*-----------------------------------------------------*/ + hid_device_info* devs = hid_enumerate(0x046D, (uint16_t)cand.pid); + std::string hidpp20_path; + + for(hid_device_info* d = devs; d != nullptr; d = d->next) + { + LOG_TRACE("%s Scan: enumerate PID=0x%04X path=%s page=0x%04X usage=%d", + LOG_TAG, cand.pid, d->path, d->usage_page, d->usage); + + if(std::string(d->path) == cand.dev_path && + d->usage_page == 0xFF00 && d->usage == 2 && + std::string(d->path) != location) + { + hidpp20_path = d->path; + break; + } + } + + hid_free_enumeration(devs); + + if(hidpp20_path.empty()) + { + LOG_DEBUG("%s Scan: %s no matching LogitechHID++ interface in enum", + LOG_TAG, cand.dev_path.c_str()); + continue; + } + + hid_device* test_dev = hid_open_path(hidpp20_path.c_str()); + + if(!test_dev) + { + LOG_DEBUG("%s Scan: %s failed to open", LOG_TAG, hidpp20_path.c_str()); + continue; + } + + /*------------------------------------------------------*\ + | Probe: HID++2.0 IRoot GetFeature for feat 0x0001 | + | (IFeatureSet). Wire: w[10 FF 0000 000100] | + | - report_id 0x10 (short) | + | - device_index 0xFF | + | - feat_idx 0x00 (IRoot) | + | - address 0x00 (func 0 GetFeature, sw_id 0) | + | - payload 00 01 00 (feat_id 0x0001) | + | | + | A live device returns r[11 xx 00 0X ...] — long form, | + | feat_idx 0x00, the address byte we sent back, and the | + | feature index in the payload. | + | A stale slot returns r[10 xx 8F 00 00 08 ...] — short | + | error form. Reject anything that starts with 0x10. | + \*------------------------------------------------------*/ + uint8_t probe[7] = {0x10, 0xFF, 0x00, 0x00, 0x00, 0x01, 0x00}; + uint8_t reply[20] = {}; + int write_rc = hid_write(test_dev, probe, sizeof(probe)); + bool probe_ok = false; + + if(write_rc >= 0) + { + /*-----------------------------------------------------*\ + | Drain up to ~100ms, looking for a long-form response | + | matching our probe. Skip stray reports from unrelated | + | firmware events that might be queued on the hidraw. | + \*-----------------------------------------------------*/ + std::chrono::steady_clock::time_point deadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds(100); + + while(std::chrono::steady_clock::now() < deadline) + { + int read_rc = hid_read_timeout(test_dev, reply, sizeof(reply), 50); + + if(read_rc <= 0) + { + continue; + } + + if(reply[0] == 0x11 && reply[2] == 0x00 && reply[3] == 0x00) + { + probe_ok = true; + break; + } + + if(reply[0] == 0x10 && reply[2] == 0x8F) + { + LOG_DEBUG("%s Scan: %s probe rejected — err=0x%02X (stale slot)", + LOG_TAG, hidpp20_path.c_str(), reply[5]); + break; + } + } + } + + if(!probe_ok) + { + hid_close(test_dev); + continue; + } + + LOG_DEBUG("%s Scan: %s probe accepted (feat_idx=0x%02X)", + LOG_TAG, hidpp20_path.c_str(), reply[4]); + + found_path = hidpp20_path; + found_dev = test_dev; + break; + } + + if(!found_dev) + { + return false; + } + + LOG_INFO("%s Device migrated: %s -> %s", LOG_TAG, location.c_str(), found_path.c_str()); + + SwapHIDHandle(found_dev, found_path); + return true; +} diff --git a/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller_Windows_MacOS.cpp b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller_Windows_MacOS.cpp new file mode 100644 index 000000000..6c01d99b6 --- /dev/null +++ b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller_Windows_MacOS.cpp @@ -0,0 +1,302 @@ +/*---------------------------------------------------------*\ +| LogitechHIDPP20Controller_Windows_MacOS.cpp | +| | +| Path-migration and device-name lookup for the unified | +| Logitech HID++ 2.0 controller on Windows and macOS. | +| | +| Uses hidapi's hid_enumerate + hid_device_info fields | +| (serial_number, product_string, product_id, usage_page) | +| on platforms with no /sys/class/hidraw equivalent. | +| | +| This file is part of the OpenRGB project | +| SPDX-License-Identifier: GPL-2.0-or-later | +\*---------------------------------------------------------*/ + +#include +#include +#include +#include +#include +#include "LogitechHIDPP20Controller.h" +#include "LogManager.h" +#include "StringUtils.h" + +#define LOG_TAG log_tag.c_str() + +std::string LogitechHIDPP20Controller::GetCenturionSubDeviceName(const std::string& path) +{ + /*---------------------------------------------------------*\ + | On Windows, hidapi's hid_device_info carries a | + | product_string field (wchar_t*). Enumerate all Logitech | + | devices and find the one whose path matches `path`. | + | | + | Caveat: Windows hidapi typically returns the parent | + | product string on every (interface, usage_page, usage) | + | entry that maps to the same USB device, so Centurion | + | sub-devices may share a name with the parent dongle. | + | That's a less specific name than Linux's HID_NAME, but | + | still better than "Logitech Centurion Device". | + \*---------------------------------------------------------*/ + std::string friendly; + + hid_device_info* devs = hid_enumerate(0x046D, 0x0000); + + for(hid_device_info* d = devs; d != nullptr; d = d->next) + { + if(d->path == nullptr) + { + continue; + } + + if(std::string(d->path) != path) + { + continue; + } + + friendly = StringUtils::wchar_to_string(d->product_string); + break; + } + + hid_free_enumeration(devs); + return friendly; +} + +bool LogitechHIDPP20Controller::ScanForDevice(bool force) +{ + /*---------------------------------------------------------*\ + | Scan hidapi for a Logitech device with matching unitId. | + | Called by the power thread either periodically (normal | + | reactive mode — only when device_online==false, USB/ | + | wireless transition or physical unplug) or on demand | + | from the reader thread after a HID++1.0 Device | + | Connection notification arrives (force=true, bypasses | + | the online gate). Finds the device on its new | + | connection path. | + | | + | Reactive-only: we never migrate while the current path | + | still works (same rationale as Linux — see the Linux | + | companion file for the full explanation). | + | | + | Windows implementation: walks hid_enumerate(046D, *) and | + | matches on hid_device_info::serial_number, which for | + | Logitech devices generally corresponds to the same | + | stable identity as the HID++-reported unit_id. If | + | serial_number matching yields nothing (hidapi may not | + | populate it for some Logitech devices on Windows), we | + | fall through to IRoot probing every candidate on the | + | matching usage_page/usage so we can still find the | + | device albeit more slowly. | + \*---------------------------------------------------------*/ + if(caps.unit_id.empty() || caps.unit_id == "00000000") + { + return false; + } + + if(!force && device_online.load()) + { + return false; + } + + std::string target_norm = StringUtils::normalize_hex_id(caps.unit_id); + + /*---------------------------------------------------------*\ + | Candidate = {path, pid, has_serial_match}. Serial matches | + | sort first so we try the cheapest/most-likely candidate. | + \*---------------------------------------------------------*/ + struct Candidate + { + std::string dev_path; + unsigned int pid; + bool serial_match; + }; + + std::vector candidates; + unsigned int current_pid = 0; + + hid_device_info* devs = hid_enumerate(0x046D, 0x0000); + + /*---------------------------------------------------------*\ + | First pass: find the entry matching our current path and | + | record its product_id so we can skip same-PID candidates. | + \*---------------------------------------------------------*/ + for(hid_device_info* d = devs; d != nullptr; d = d->next) + { + if(d->path != nullptr && std::string(d->path) == location) + { + current_pid = d->product_id; + break; + } + } + + /*---------------------------------------------------------*\ + | Second pass: collect candidates that (a) aren't our | + | current path, (b) speak the HID++ interface, and (c) have | + | a plausible identity match — either the serial_number | + | matches our unit_id, or at minimum their PID differs from | + | ours so they can't be another slot on the same dongle. | + \*---------------------------------------------------------*/ + for(hid_device_info* d = devs; d != nullptr; d = d->next) + { + if(d->path == nullptr) + { + continue; + } + + if(std::string(d->path) == location) + { + continue; + } + + /*-----------------------------------------------------*\ + | Only HID++ interface (usage_page 0xFF00, usage 2). | + | Note: on Windows hidapi may or may not populate | + | usage / usage_page consistently. If either is zero, | + | fall through — we'll probe unconditionally in that | + | case rather than dropping a potential candidate. | + \*-----------------------------------------------------*/ + bool usage_known = (d->usage_page != 0 || d->usage != 0); + + if(usage_known && (d->usage_page != 0xFF00 || d->usage != 2)) + { + continue; + } + + /*-----------------------------------------------------*\ + | Compare serial_number (wchar_t*) against our unit_id. | + | Normalize both and do an exact-match comparison. | + | Empty/missing serial is allowed — falls through as a | + | serial_match=false candidate so the probe path can | + | still find it via a different-PID filter. | + \*-----------------------------------------------------*/ + bool serial_match = false; + + if(d->serial_number != nullptr && d->serial_number[0] != L'\0') + { + std::string sn = StringUtils::wchar_to_string(d->serial_number); + std::string sn_norm = StringUtils::normalize_hex_id(sn); + + if(!sn_norm.empty() && sn_norm == target_norm) + { + serial_match = true; + } + } + + /*-----------------------------------------------------*\ + | If we have no serial match AND this path shares the | + | current PID, skip. Same PID with no identity evidence | + | is probably a sibling slot on the same dongle, not a | + | valid migration target. | + \*-----------------------------------------------------*/ + if(!serial_match && d->product_id == current_pid && current_pid != 0) + { + continue; + } + + Candidate c; + c.dev_path = d->path; + c.pid = d->product_id; + c.serial_match = serial_match; + candidates.push_back(c); + } + + hid_free_enumeration(devs); + + if(candidates.empty()) + { + return false; + } + + /*---------------------------------------------------------*\ + | Sort so serial-matched candidates get probed first. | + \*---------------------------------------------------------*/ + std::stable_sort(candidates.begin(), candidates.end(), + [](const Candidate& a, const Candidate& b) + { + return a.serial_match && !b.serial_match; + }); + + /*---------------------------------------------------------*\ + | Probe each candidate with IRoot GetFeature feat 0x0001. | + | A live slot returns a long-form (0x11) response; a stale | + | slot returns a short-form (0x10) error. See the Linux | + | companion file for the wire-level explanation. | + \*---------------------------------------------------------*/ + std::string found_path; + hid_device* found_dev = nullptr; + + for(size_t c = 0; c < candidates.size(); c++) + { + const Candidate& cand = candidates[c]; + + LOG_DEBUG("%s Scan: migration candidate at %s (pid=0x%04X, serial_match=%d)", + LOG_TAG, cand.dev_path.c_str(), cand.pid, + cand.serial_match ? 1 : 0); + + hid_device* test_dev = hid_open_path(cand.dev_path.c_str()); + + if(!test_dev) + { + LOG_DEBUG("%s Scan: %s failed to open", + LOG_TAG, cand.dev_path.c_str()); + continue; + } + + uint8_t probe[7] = {0x10, 0xFF, 0x00, 0x00, 0x00, 0x01, 0x00}; + uint8_t reply[20] = {}; + int write_rc = hid_write(test_dev, probe, sizeof(probe)); + bool probe_ok = false; + + if(write_rc >= 0) + { + std::chrono::steady_clock::time_point deadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds(100); + + while(std::chrono::steady_clock::now() < deadline) + { + int read_rc = hid_read_timeout(test_dev, reply, sizeof(reply), 50); + + if(read_rc <= 0) + { + continue; + } + + if(reply[0] == 0x11 && reply[2] == 0x00 && reply[3] == 0x00) + { + probe_ok = true; + break; + } + + if(reply[0] == 0x10 && reply[2] == 0x8F) + { + LOG_DEBUG("%s Scan: %s probe rejected — err=0x%02X (stale slot)", + LOG_TAG, cand.dev_path.c_str(), reply[5]); + break; + } + } + } + + if(!probe_ok) + { + hid_close(test_dev); + continue; + } + + LOG_DEBUG("%s Scan: %s probe accepted (feat_idx=0x%02X)", + LOG_TAG, cand.dev_path.c_str(), reply[4]); + + found_path = cand.dev_path; + found_dev = test_dev; + break; + } + + if(!found_dev) + { + return false; + } + + LOG_INFO("%s Device migrated: %s -> %s", + LOG_TAG, location.c_str(), found_path.c_str()); + + SwapHIDHandle(found_dev, found_path); + return true; +} diff --git a/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20IdleSettings.cpp b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20IdleSettings.cpp new file mode 100644 index 000000000..3d1b0e02b --- /dev/null +++ b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20IdleSettings.cpp @@ -0,0 +1,146 @@ +/*---------------------------------------------------------*\ +| LogitechHIDPP20IdleSettings.cpp | +| | +| Host-side idle/dim/sleep settings storage helper for | +| Logitech HID++ 2.0 devices. Loads the configuration | +| block from SettingsManager JSON, caches it, and writes | +| changes back through SettingsManager::SetSettings(). | +| | +| This file is part of the OpenRGB project | +| SPDX-License-Identifier: GPL-2.0-or-later | +\*---------------------------------------------------------*/ + +#include "LogitechHIDPP20IdleSettings.h" +#include "ResourceManager.h" +#include "SettingsManager.h" + +static const char* SETTINGS_KEY = "LogitechHIDPP20IdleSettings"; + +LogitechHIDPP20IdleSettings* LogitechHIDPP20IdleSettings::instance() +{ + static LogitechHIDPP20IdleSettings inst; + return &inst; +} + +/*---------------------------------------------------------*\ +| Minimum idle / sleep values the controller will honor. | +| Matches what Logitech firmware typically clamps to on | +| HID++ 2.0 devices, and guarantees the skip-dim path's | +| sleep_delay math always produces a positive window. | +\*---------------------------------------------------------*/ +static const int MIN_IDLE_S = 15; +static const int MIN_SLEEP_S = 45; +static const int MIN_DIM_WINDOW = 30; // sleep must exceed idle by at least this + +static void ClampProfile(LogitechHIDPP20IdleProfile& p) +{ + if(p.dim_brightness < 0) p.dim_brightness = 0; + if(p.dim_brightness > 100) p.dim_brightness = 100; + + if(p.idle_timeout_s < MIN_IDLE_S) + { + p.idle_timeout_s = MIN_IDLE_S; + } + + if(p.sleep_timeout_s < MIN_SLEEP_S) + { + p.sleep_timeout_s = MIN_SLEEP_S; + } + + if(p.sleep_timeout_s < p.idle_timeout_s + MIN_DIM_WINDOW) + { + p.sleep_timeout_s = p.idle_timeout_s + MIN_DIM_WINDOW; + } +} + +static LogitechHIDPP20IdleProfile ProfileFromJson(const json& j) +{ + LogitechHIDPP20IdleProfile p; + + if(j.contains("dim_when_idle")) p.dim_when_idle = j["dim_when_idle"]; + if(j.contains("dim_brightness")) p.dim_brightness = j["dim_brightness"]; + if(j.contains("idle_timeout_s")) p.idle_timeout_s = j["idle_timeout_s"]; + if(j.contains("allow_sleep")) p.allow_sleep = j["allow_sleep"]; + if(j.contains("sleep_timeout_s")) p.sleep_timeout_s = j["sleep_timeout_s"]; + + ClampProfile(p); + + return p; +} + +static json ProfileToJson(const LogitechHIDPP20IdleProfile& p) +{ + json j; + + j["dim_when_idle"] = p.dim_when_idle; + j["dim_brightness"] = p.dim_brightness; + j["idle_timeout_s"] = p.idle_timeout_s; + j["allow_sleep"] = p.allow_sleep; + j["sleep_timeout_s"] = p.sleep_timeout_s; + + return j; +} + +void LogitechHIDPP20IdleSettings::load() +{ + json settings = ResourceManager::get()->GetSettingsManager()->GetSettings(SETTINGS_KEY); + + /*---------------------------------------------------------*\ + | Empty / missing key means the plugin is not in use. | + | Reset both profiles to defaults with configured=false so | + | the controller defers to firmware. | + \*---------------------------------------------------------*/ + if(!settings.is_object() || settings.empty()) + { + configured = false; + on_battery = LogitechHIDPP20IdleProfile{}; + plugged_in = LogitechHIDPP20IdleProfile{}; + return; + } + + configured = true; + + if(settings.contains("on_battery")) + { + on_battery = ProfileFromJson(settings["on_battery"]); + } + else + { + on_battery = LogitechHIDPP20IdleProfile{}; + } + + if(settings.contains("plugged_in")) + { + plugged_in = ProfileFromJson(settings["plugged_in"]); + } + else + { + plugged_in = LogitechHIDPP20IdleProfile{}; + } +} + +void LogitechHIDPP20IdleSettings::save() +{ + json settings; + + settings["on_battery"] = ProfileToJson(on_battery); + settings["plugged_in"] = ProfileToJson(plugged_in); + + SettingsManager* mgr = ResourceManager::get()->GetSettingsManager(); + mgr->SetSettings(SETTINGS_KEY, settings); + mgr->SaveSettings(); + + configured = true; +} + +void LogitechHIDPP20IdleSettings::setOnBattery(const LogitechHIDPP20IdleProfile& p) +{ + on_battery = p; + configured = true; +} + +void LogitechHIDPP20IdleSettings::setPluggedIn(const LogitechHIDPP20IdleProfile& p) +{ + plugged_in = p; + configured = true; +} diff --git a/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20IdleSettings.h b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20IdleSettings.h new file mode 100644 index 000000000..f3cacbcad --- /dev/null +++ b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20IdleSettings.h @@ -0,0 +1,47 @@ +/*---------------------------------------------------------*\ +| LogitechHIDPP20IdleSettings.h | +| | +| Host-side idle/dim/sleep configuration for Logitech | +| HID++ 2.0 devices. Two profiles (on_battery, plugged_in)| +| selected at runtime based on the device's external- | +| power flag. `configured == false` means the JSON key | +| is absent entirely — the controller defers to firmware. | +| Qt-free so the controller can consume it directly. | +| | +| This file is part of the OpenRGB project | +| SPDX-License-Identifier: GPL-2.0-or-later | +\*---------------------------------------------------------*/ + +#pragma once + +struct LogitechHIDPP20IdleProfile +{ + bool dim_when_idle = false; + int dim_brightness = 50; + int idle_timeout_s = 60; + bool allow_sleep = false; + int sleep_timeout_s = 300; +}; + +class LogitechHIDPP20IdleSettings +{ +public: + static LogitechHIDPP20IdleSettings* instance(); + + void load(); + void save(); + + bool isConfigured() const { return configured; } + const LogitechHIDPP20IdleProfile& onBattery() const { return on_battery; } + const LogitechHIDPP20IdleProfile& pluggedIn() const { return plugged_in; } + + void setOnBattery(const LogitechHIDPP20IdleProfile& p); + void setPluggedIn(const LogitechHIDPP20IdleProfile& p); + +private: + LogitechHIDPP20IdleSettings() = default; + + bool configured = false; + LogitechHIDPP20IdleProfile on_battery; + LogitechHIDPP20IdleProfile plugged_in; +}; diff --git a/Controllers/LogitechController/LogitechHIDPP20Controller/RGBController_LogitechHIDPP20.cpp b/Controllers/LogitechController/LogitechHIDPP20Controller/RGBController_LogitechHIDPP20.cpp new file mode 100644 index 000000000..1d47a0103 --- /dev/null +++ b/Controllers/LogitechController/LogitechHIDPP20Controller/RGBController_LogitechHIDPP20.cpp @@ -0,0 +1,1796 @@ +/*---------------------------------------------------------*\ +| RGBController_LogitechHIDPP20.cpp | +| | +| RGBController for unified Logitech HID++ 2.0 devices | +| | +| This file is part of the OpenRGB project | +| SPDX-License-Identifier: GPL-2.0-or-later | +\*---------------------------------------------------------*/ + +#include +#include +#include +#include +#include +#include "RGBController_LogitechHIDPP20.h" +#include "RGBControllerKeyNames.h" +#include "KeyboardLayoutManager.h" +#include "LogManager.h" + +/*----------------------------------------------------------*\ +| Sentinel for "this LED's last write didn't ACK". | +| Stored in sent_colors[i] to force the next frame to | +| re-push the LED regardless of color delta. The high byte | +| 0xFF is unreachable from any ToRGBColor(r,g,b) value | +| (those have high byte 0), so the sentinel never collides | +| with a real color including black (0x00000000) or | +| white (0x00FFFFFF). | +\*----------------------------------------------------------*/ +static constexpr RGBColor HIDPP20_UNCOMMITTED = 0xFF000000; + +/*---------------------------------------------------------*\ +| Effect period range, in milliseconds. Matches the | +| 1000..20000ms range Logitech firmware tests against and | +| the vendor app clamps to in observed wire captures. | +| Out-of-band values can | +| produce flashy / invisible animations on real hardware, | +| so the slider stays clamped here on our side. | +\*---------------------------------------------------------*/ +static const uint16_t HIDPP20_PERIOD_MIN_MS = 1000; +static const uint16_t HIDPP20_PERIOD_MAX_MS = 20000; + +/*---------------------------------------------------------*\ +| Ripple has its own narrower, much faster period range. | +| Values taken from G915's LOGITECH_G915_SPEED_RIPPLE_* | +| constants: 2ms..200ms. A ripple feels right when quick — | +| using the breathing range (1..20s) makes it invisible. | +\*---------------------------------------------------------*/ +static const uint16_t HIDPP20_RIPPLE_PERIOD_MIN_MS = 2; +static const uint16_t HIDPP20_RIPPLE_PERIOD_MAX_MS = 200; + +/*---------------------------------------------------------*\ +| Speed slider range presented to the user. 1..100 matches | +| our brightness convention. Higher = faster animation, | +| inverted on the wire because lower period = faster cycle. | +\*---------------------------------------------------------*/ +static const int HIDPP20_SPEED_SLIDER_MIN = 1; +static const int HIDPP20_SPEED_SLIDER_MAX = 100; + +static uint16_t SliderToPeriodMs(int slider, uint16_t period_min_ms, uint16_t period_max_ms) +{ + if(slider <= HIDPP20_SPEED_SLIDER_MIN) return period_max_ms; + if(slider >= HIDPP20_SPEED_SLIDER_MAX) return period_min_ms; + + const int period_range = period_max_ms - period_min_ms; + const int slider_range = HIDPP20_SPEED_SLIDER_MAX - HIDPP20_SPEED_SLIDER_MIN; + + return (uint16_t)(period_max_ms + - ((slider - HIDPP20_SPEED_SLIDER_MIN) * period_range) / slider_range); +} + +static uint16_t SpeedSliderToPeriodMs(int slider) +{ + return SliderToPeriodMs(slider, HIDPP20_PERIOD_MIN_MS, HIDPP20_PERIOD_MAX_MS); +} + +static uint16_t RippleSpeedSliderToPeriodMs(int slider) +{ + return SliderToPeriodMs(slider, HIDPP20_RIPPLE_PERIOD_MIN_MS, HIDPP20_RIPPLE_PERIOD_MAX_MS); +} + +/*---------------------------------------------------------*\ +| Color Wave 0x0016 carries a direction byte. Map OpenRGB's | +| 6 direction slots onto the Logitech wire values (Solaar | +| LedDirectionChoices). Logitech defines 8 directions; the | +| G515 uses 6 of them, which line up 1:1 with OpenRGB's set | +| (In / Out are not exposed on this keyboard). | +\*---------------------------------------------------------*/ +static uint8_t WaveDirectionToWire(unsigned int dir) +{ + switch(dir) + { + case MODE_DIRECTION_LEFT: return 6; /* Left */ + case MODE_DIRECTION_RIGHT: return 1; /* Right */ + case MODE_DIRECTION_UP: return 7; /* Up */ + case MODE_DIRECTION_DOWN: return 2; /* Down */ + case MODE_DIRECTION_HORIZONTAL: return 3; /* Center Out */ + case MODE_DIRECTION_VERTICAL: return 8; /* Center In */ + default: return 1; /* Right */ + } +} + +/**------------------------------------------------------------------*\ + @name Logitech HID++ 2.0 + @category Keyboard,Mouse,Headset + @type USB + @save :x: + @direct :white_check_mark: + @effects :white_check_mark: + @detectors DetectLogitechHIDPP20 + @comment + Unified HID++ 2.0 controller that dynamically discovers device + capabilities via feature probing. Supports per-key lighting + (0x8081/0x8080) and zone-based effects (0x8071/0x8070). +\*-------------------------------------------------------------------*/ + +static const char* zone_location_name(uint16_t location) +{ + switch(location) + { + case 0x0001: return "All"; + case 0x0002: return "Primary"; + case 0x0003: return "Combined"; + case 0x0004: return "Logo"; + case 0x0005: return "Left"; + case 0x0006: return "Right"; + case 0x0007: return "Group 1"; + case 0x0008: return "Group 2"; + case 0x0009: return "Group 3"; + case 0x000A: return "Group 4"; + case 0x000B: return "Group 5"; + case 0x2000: return "Top"; + case 0x4000: return "Bottom"; + default: + { + static char buf[16]; + snprintf(buf, sizeof(buf), "Zone 0x%04X", location); + return buf; + } + } +} + +/*---------------------------------------------------------*\ +| HID++ per-key zone ID to OpenRGB key name mapping | +| Zone IDs follow Solaar's KEYCODES (special_keys.py) | +| Used to look up zone IDs by key name after KLM builds | +| the keymap in its own sorted order. | +\*---------------------------------------------------------*/ +static const std::map hidpp20_key_name_to_zone = +{ + { KEY_EN_A, 1 }, + { KEY_EN_B, 2 }, + { KEY_EN_C, 3 }, + { KEY_EN_D, 4 }, + { KEY_EN_E, 5 }, + { KEY_EN_F, 6 }, + { KEY_EN_G, 7 }, + { KEY_EN_H, 8 }, + { KEY_EN_I, 9 }, + { KEY_EN_J, 10 }, + { KEY_EN_K, 11 }, + { KEY_EN_L, 12 }, + { KEY_EN_M, 13 }, + { KEY_EN_N, 14 }, + { KEY_EN_O, 15 }, + { KEY_EN_P, 16 }, + { KEY_EN_Q, 17 }, + { KEY_EN_R, 18 }, + { KEY_EN_S, 19 }, + { KEY_EN_T, 20 }, + { KEY_EN_U, 21 }, + { KEY_EN_V, 22 }, + { KEY_EN_W, 23 }, + { KEY_EN_X, 24 }, + { KEY_EN_Y, 25 }, + { KEY_EN_Z, 26 }, + { KEY_EN_1, 27 }, + { KEY_EN_2, 28 }, + { KEY_EN_3, 29 }, + { KEY_EN_4, 30 }, + { KEY_EN_5, 31 }, + { KEY_EN_6, 32 }, + { KEY_EN_7, 33 }, + { KEY_EN_8, 34 }, + { KEY_EN_9, 35 }, + { KEY_EN_0, 36 }, + { KEY_EN_ANSI_ENTER, 37 }, + { KEY_EN_ESCAPE, 38 }, + { KEY_EN_BACKSPACE, 39 }, + { KEY_EN_TAB, 40 }, + { KEY_EN_SPACE, 41 }, + { KEY_EN_MINUS, 42 }, + { KEY_EN_EQUALS, 43 }, + { KEY_EN_LEFT_BRACKET, 44 }, + { KEY_EN_RIGHT_BRACKET, 45 }, + { KEY_EN_ANSI_BACK_SLASH, 46 }, + { KEY_EN_SEMICOLON, 48 }, + { KEY_EN_QUOTE, 49 }, + { KEY_EN_BACK_TICK, 50 }, + { KEY_EN_COMMA, 51 }, + { KEY_EN_PERIOD, 52 }, + { KEY_EN_FORWARD_SLASH, 53 }, + { KEY_EN_CAPS_LOCK, 54 }, + { KEY_EN_F1, 55 }, + { KEY_EN_F2, 56 }, + { KEY_EN_F3, 57 }, + { KEY_EN_F4, 58 }, + { KEY_EN_F5, 59 }, + { KEY_EN_F6, 60 }, + { KEY_EN_F7, 61 }, + { KEY_EN_F8, 62 }, + { KEY_EN_F9, 63 }, + { KEY_EN_F10, 64 }, + { KEY_EN_F11, 65 }, + { KEY_EN_F12, 66 }, + { KEY_EN_PRINT_SCREEN, 67 }, + { KEY_EN_SCROLL_LOCK, 68 }, + { KEY_EN_PAUSE_BREAK, 69 }, + { KEY_EN_INSERT, 70 }, + { KEY_EN_HOME, 71 }, + { KEY_EN_PAGE_UP, 72 }, + { KEY_EN_DELETE, 73 }, + { KEY_EN_END, 74 }, + { KEY_EN_PAGE_DOWN, 75 }, + { KEY_EN_RIGHT_ARROW, 76 }, + { KEY_EN_LEFT_ARROW, 77 }, + { KEY_EN_DOWN_ARROW, 78 }, + { KEY_EN_UP_ARROW, 79 }, + { KEY_EN_RIGHT_FUNCTION, 111 }, + { KEY_EN_MENU, 98 }, + + /*------------------------------------------------------*\ + | Numpad zones (Solaar KEYCODES 80-96). | + | Required for any full-size HID++ keyboard. | + \*------------------------------------------------------*/ + { KEY_EN_NUMPAD_LOCK, 80 }, + { KEY_EN_NUMPAD_DIVIDE, 81 }, + { KEY_EN_NUMPAD_TIMES, 82 }, + { KEY_EN_NUMPAD_MINUS, 83 }, + { KEY_EN_NUMPAD_PLUS, 84 }, + { KEY_EN_NUMPAD_ENTER, 85 }, + { KEY_EN_NUMPAD_1, 86 }, + { KEY_EN_NUMPAD_2, 87 }, + { KEY_EN_NUMPAD_3, 88 }, + { KEY_EN_NUMPAD_4, 89 }, + { KEY_EN_NUMPAD_5, 90 }, + { KEY_EN_NUMPAD_6, 91 }, + { KEY_EN_NUMPAD_7, 92 }, + { KEY_EN_NUMPAD_8, 93 }, + { KEY_EN_NUMPAD_9, 94 }, + { KEY_EN_NUMPAD_0, 95 }, + { KEY_EN_NUMPAD_PERIOD, 96 }, + + { KEY_EN_LEFT_CONTROL, 104 }, + { KEY_EN_LEFT_SHIFT, 105 }, + { KEY_EN_LEFT_ALT, 106 }, + { KEY_EN_LEFT_WINDOWS, 107 }, + { KEY_EN_RIGHT_CONTROL, 108 }, + { KEY_EN_RIGHT_SHIFT, 109 }, + { KEY_EN_RIGHT_ALT, 110 }, + { KEY_EN_RIGHT_WINDOWS, 111 }, + + /*------------------------------------------------------*\ + | G915 (and similar) out-of-KLM LEDs. | + | Zone IDs from Solaar KEYCODES. Names match the legacy | + | G915 controller so existing users don't see their LED | + | labels change when they move onto the unified driver. | + \*------------------------------------------------------*/ + { "Key: Brightness", 153 }, + { KEY_EN_MEDIA_PLAY_PAUSE, 155 }, + { KEY_EN_MEDIA_MUTE, 156 }, + { KEY_EN_MEDIA_NEXT, 157 }, + { KEY_EN_MEDIA_PREVIOUS, 158 }, + { "Key: G1", 180 }, + { "Key: G2", 181 }, + { "Key: G3", 182 }, + { "Key: G4", 183 }, + { "Key: G5", 184 }, + { "Logo", 210 }, +}; + +/*---------------------------------------------------------*\ +| Mouse LED layout table | +| Each entry defines a matrix layout for a known mouse. | +| Looked up by substring match on device name. | +| To add a new mouse: add an entry with name pattern, | +| grid dimensions, LED count, map, and LED names. | +\*---------------------------------------------------------*/ +#define ML_NA 0xFFFFFFFF + +struct MouseLayout +{ + const char* name_match; + unsigned int rows; + unsigned int cols; + unsigned int led_count; + const unsigned int* map; + const char* const* led_names; +}; + +static const unsigned int g502x_map[3 * 7] = +{ + /* C . . . . . B */ + 2, ML_NA, ML_NA, ML_NA, ML_NA, ML_NA, 1, + /* . D H G F E . */ + ML_NA, 3, 7, 6, 5, 4, ML_NA, + /* . . . . . . A */ + ML_NA, ML_NA, ML_NA, ML_NA, ML_NA, ML_NA, 0, +}; + +static const char* g502x_led_names[] = +{ + "LED A", "LED B", "LED C", "LED D", + "LED E", "LED F", "LED G", "LED H", +}; + +static const MouseLayout known_mouse_layouts[] = +{ + { "G502 X", 3, 7, 8, g502x_map, g502x_led_names }, + /*-------------------------------------------------------*\ + | Add new mice here: | + | { "G PRO X", rows, cols, count, map_ptr, names_ptr }, | + \*-------------------------------------------------------*/ + { nullptr, 0, 0, 0, nullptr, nullptr } +}; + +static const MouseLayout* FindMouseLayout(const std::string& device_name) +{ + for(const MouseLayout* ml = known_mouse_layouts; ml->name_match != nullptr; ml++) + { + if(device_name.find(ml->name_match) != std::string::npos) + { + return ml; + } + } + + return nullptr; +} + +RGBController_LogitechHIDPP20::RGBController_LogitechHIDPP20(LogitechHIDPP20Controller* controller_ptr) +{ + controller = controller_ptr; + + const HIDPP20DeviceCapabilities& caps = controller->GetCapabilities(); + + name = caps.device_name; + vendor = "Logitech"; + description = "Logitech HID++ 2.0 Device"; + version = caps.firmware_version; + location = controller->GetDeviceLocation(); + serial = controller->GetSerialString(); + + switch(caps.device_type) + { + case LOGITECH_DEVICE_TYPE_KEYBOARD: + type = DEVICE_TYPE_KEYBOARD; + break; + case LOGITECH_DEVICE_TYPE_MOUSE: + case LOGITECH_DEVICE_TYPE_TRACKBALL: + type = DEVICE_TYPE_MOUSE; + break; + case LOGITECH_DEVICE_TYPE_HEADSET: + type = DEVICE_TYPE_HEADSET; + break; + case LOGITECH_DEVICE_TYPE_MOUSEPAD: + type = DEVICE_TYPE_MOUSEMAT; + break; + default: + type = DEVICE_TYPE_UNKNOWN; + break; + } + + /*----------------------------------------------------------*\ + | Build mode list from discovered capabilities | + \*----------------------------------------------------------*/ + + /*----------------------------------------------------------*\ + | Direct mode: per-key control via 0x8081 | + \*----------------------------------------------------------*/ + if(caps.has_perkey) + { + mode Direct; + Direct.name = "Direct"; + Direct.value = 0; + Direct.flags = MODE_FLAG_HAS_PER_LED_COLOR; + Direct.color_mode = MODE_COLORS_PER_LED; + modes.push_back(Direct); + } + + /*----------------------------------------------------------*\ + | Off mode: always available | + \*----------------------------------------------------------*/ + { + mode Off; + Off.name = "Off"; + Off.value = 0xFF; + Off.flags = 0; + Off.color_mode = MODE_COLORS_NONE; + modes.push_back(Off); + } + + /*----------------------------------------------------------*\ + | 0x0620 Headset RGB Hostmode has no effect cards. Provide | + | a single Direct mode that maps every LED to the frame | + | buffer; SetHeadsetRGBHostmodeColors writes them straight | + | to the earcup zones. | + \*----------------------------------------------------------*/ + if(caps.is_headset_rgb_hostmode) + { + mode Direct; + Direct.name = "Direct"; + Direct.value = 0; + Direct.flags = MODE_FLAG_HAS_PER_LED_COLOR; + Direct.color_mode = MODE_COLORS_PER_LED; + modes.push_back(Direct); + } + + /*----------------------------------------------------------*\ + | Effect modes from zone cluster discovery | + | Scan effects from the first cluster (effects are usually | + | the same across clusters) | + \*----------------------------------------------------------*/ + if(caps.has_zone_effects && !caps.zone_clusters.empty() && !caps.is_headset_rgb_hostmode) + { + const HIDPP20ZoneCluster& cluster = caps.zone_clusters[0]; + + for(size_t i = 0; i < cluster.effects.size(); i++) + { + const HIDPP20Effect& fx = cluster.effects[i]; + + switch(fx.effect_id) + { + case 0x0001: // Static / Fixed Color + { + mode Static; + Static.name = "Static"; + Static.value = fx.index; + + /*-----------------------------------------------------*\ + | Multi-cluster devices (mice with logo/scroll/DPI) | + | get per-LED colors so each zone can be painted | + | independently in Static. Single-cluster devices | + | (keyboards, single-zone mice) keep the single-color | + | MODE_COLORS_MODE_SPECIFIC UX. | + \*-----------------------------------------------------*/ + if(caps.zone_clusters.size() > 1) + { + Static.flags = MODE_FLAG_HAS_PER_LED_COLOR; + Static.color_mode = MODE_COLORS_PER_LED; + } + else + { + Static.flags = MODE_FLAG_HAS_MODE_SPECIFIC_COLOR; + Static.colors_min = 1; + Static.colors_max = 1; + Static.color_mode = MODE_COLORS_MODE_SPECIFIC; + Static.colors.resize(1); + } + modes.push_back(Static); + break; + } + + case 0x0003: // Color Cycle / Spectrum + { + mode Cycle; + Cycle.name = "Spectrum Cycle"; + Cycle.value = fx.index; + Cycle.flags = MODE_FLAG_HAS_SPEED + | MODE_FLAG_HAS_BRIGHTNESS; + Cycle.speed_min = HIDPP20_SPEED_SLIDER_MIN; + Cycle.speed_max = HIDPP20_SPEED_SLIDER_MAX; + Cycle.speed = 80; /* ~4.9s, lively medium */ + Cycle.brightness_min = 1; + Cycle.brightness_max = 100; + Cycle.brightness = 100; + Cycle.color_mode = MODE_COLORS_NONE; + modes.push_back(Cycle); + break; + } + + case 0x000A: // Breathing + { + mode Breathing; + Breathing.name = "Breathing"; + Breathing.value = fx.index; + Breathing.speed_min = HIDPP20_SPEED_SLIDER_MIN; + Breathing.speed_max = HIDPP20_SPEED_SLIDER_MAX; + Breathing.speed = 70; /* ~6.8s, calm medium */ + Breathing.brightness_min = 1; + Breathing.brightness_max = 100; + Breathing.brightness = 100; + + /*-----------------------------------------------------*\ + | See Static above — multi-cluster gets per-LED colors. | + \*-----------------------------------------------------*/ + if(caps.zone_clusters.size() > 1) + { + Breathing.flags = MODE_FLAG_HAS_PER_LED_COLOR + | MODE_FLAG_HAS_SPEED + | MODE_FLAG_HAS_BRIGHTNESS; + Breathing.color_mode = MODE_COLORS_PER_LED; + } + else + { + Breathing.flags = MODE_FLAG_HAS_MODE_SPECIFIC_COLOR + | MODE_FLAG_HAS_SPEED + | MODE_FLAG_HAS_BRIGHTNESS; + Breathing.colors_min = 1; + Breathing.colors_max = 1; + Breathing.color_mode = MODE_COLORS_MODE_SPECIFIC; + Breathing.colors.resize(1); + } + modes.push_back(Breathing); + break; + } + + case 0x0004: // Color Wave + { + mode Wave; + Wave.name = "Color Wave"; + Wave.value = fx.index; + Wave.flags = MODE_FLAG_HAS_SPEED + | MODE_FLAG_HAS_BRIGHTNESS; + Wave.speed_min = HIDPP20_SPEED_SLIDER_MIN; + Wave.speed_max = HIDPP20_SPEED_SLIDER_MAX; + Wave.speed = 80; /* ~4.9s, lively medium */ + Wave.brightness_min = 1; + Wave.brightness_max = 100; + Wave.brightness = 100; + Wave.color_mode = MODE_COLORS_NONE; + modes.push_back(Wave); + break; + } + + case 0x000B: // Ripple + { + mode Ripple; + Ripple.name = "Ripple"; + Ripple.value = fx.index; + Ripple.flags = MODE_FLAG_HAS_MODE_SPECIFIC_COLOR + | MODE_FLAG_HAS_SPEED + | MODE_FLAG_HAS_BRIGHTNESS; + Ripple.speed_min = HIDPP20_SPEED_SLIDER_MIN; + Ripple.speed_max = HIDPP20_SPEED_SLIDER_MAX; + Ripple.speed = 70; /* ~6.8s, calm medium */ + Ripple.brightness_min = 1; + Ripple.brightness_max = 100; + Ripple.brightness = 100; + Ripple.colors_min = 1; + Ripple.colors_max = 1; + Ripple.color_mode = MODE_COLORS_MODE_SPECIFIC; + Ripple.colors.resize(1); + modes.push_back(Ripple); + break; + } + + case 0x0015: // Cycle (saturation variant) + { + /*-----------------------------------------------*\ + | Saturation-bearing variant of 0x0003. Same UI | + | (speed = period, brightness = intensity); the | + | saturation byte is hardcoded full on the wire. | + \*-----------------------------------------------*/ + mode Cycle; + Cycle.name = "Spectrum Cycle"; + Cycle.value = fx.index; + Cycle.flags = MODE_FLAG_HAS_SPEED + | MODE_FLAG_HAS_BRIGHTNESS; + Cycle.speed_min = HIDPP20_SPEED_SLIDER_MIN; + Cycle.speed_max = HIDPP20_SPEED_SLIDER_MAX; + Cycle.speed = 80; /* ~4.9s, lively medium */ + Cycle.brightness_min = 1; + Cycle.brightness_max = 100; + Cycle.brightness = 100; + Cycle.color_mode = MODE_COLORS_NONE; + modes.push_back(Cycle); + break; + } + + case 0x0016: // Wave (saturation variant) + { + /*-----------------------------------------------*\ + | Saturation-bearing variant of 0x0004. Period | + | is a BE16 ms value on the standard 1..20s range | + | (Solaar's LEDEffects table has no period range | + | override for Wave); saturation is hardcoded on | + | the wire. | + \*-----------------------------------------------*/ + mode Wave; + Wave.name = "Color Wave"; + Wave.value = fx.index; + Wave.flags = MODE_FLAG_HAS_SPEED + | MODE_FLAG_HAS_BRIGHTNESS + | MODE_FLAG_HAS_DIRECTION_LR + | MODE_FLAG_HAS_DIRECTION_UD + | MODE_FLAG_HAS_DIRECTION_HV; + Wave.speed_min = HIDPP20_SPEED_SLIDER_MIN; + Wave.speed_max = HIDPP20_SPEED_SLIDER_MAX; + Wave.speed = 80; /* ~4.9s, lively medium */ + Wave.brightness_min = 1; + Wave.brightness_max = 100; + Wave.brightness = 100; + Wave.direction = MODE_DIRECTION_RIGHT; + Wave.color_mode = MODE_COLORS_NONE; + modes.push_back(Wave); + break; + } + + case 0x0017: // Ripple (saturation variant) + { + /*-----------------------------------------------*\ + | Saturation-bearing variant of 0x000B. Carries | + | color + period only — no intensity param, so | + | no brightness slider. Saturation is hardcoded | + | full on the wire. | + \*-----------------------------------------------*/ + mode Ripple; + Ripple.name = "Ripple"; + Ripple.value = fx.index; + Ripple.flags = MODE_FLAG_HAS_MODE_SPECIFIC_COLOR + | MODE_FLAG_HAS_SPEED; + Ripple.speed_min = HIDPP20_SPEED_SLIDER_MIN; + Ripple.speed_max = HIDPP20_SPEED_SLIDER_MAX; + Ripple.speed = 70; /* mid, fast ripple range */ + Ripple.colors_min = 1; + Ripple.colors_max = 1; + Ripple.color_mode = MODE_COLORS_MODE_SPECIFIC; + Ripple.colors.resize(1); + modes.push_back(Ripple); + break; + } + + default: + break; + } + } + } + + /*---------------------------------------------------------*\ + | On 0x8070 devices every effect write is ephemeral by | + | default (see DeviceUpdateMode persist branch below). Add | + | a Save button on firmware-effect modes so users can | + | explicitly commit the active mode to NVM. Direct is | + | excluded because per-key framebuffer writes don't map to | + | a savable firmware effect on 0x8070. 0x8071/0x0600 | + | already persist on every write, so no Save button is | + | exposed there pending further research. | + \*---------------------------------------------------------*/ + if(caps.rgb_feature_page == HIDPP20_FEAT_COLOR_LED_EFFECTS) + { + for(size_t i = 0; i < modes.size(); i++) + { + if(modes[i].name != "Direct") + { + modes[i].flags |= MODE_FLAG_MANUAL_SAVE; + } + } + } + + SetupZones(); + + /*----------------------------------------------------------*\ + | Register repaint callback and start power manager. | + | The callback triggers DeviceUpdateLEDs from the power | + | thread for dim/wake when no animation is driving updates. | + \*----------------------------------------------------------*/ + controller->SetRepaintCallback( + std::bind(&RGBController_LogitechHIDPP20::OnRepaintRequest, this)); + + controller->SetReapplyActiveModeCallback( + std::bind(&RGBController_LogitechHIDPP20::ReapplyActiveMode, this)); +} + +/*---------------------------------------------------------------*\ +| Repaint callback handler (request_repaint_fn). Invoked from the | +| power thread for dim/wake when no animation is driving updates. | +\*---------------------------------------------------------------*/ +void RGBController_LogitechHIDPP20::OnRepaintRequest() +{ + /*-------------------------------------------------*\ + | If Wake() signaled a full repaint, invalidate | + | sent_colors so DeviceUpdateLEDs pushes every zone | + | regardless of delta. Uses HIDPP20_UNCOMMITTED | + | rather than clear() so sent_colors is non-empty — | + | that avoids the first_frame / prep trigger while | + | still forcing a full push. | + \*-------------------------------------------------*/ + if(controller->ConsumeWakeFullRepaint()) + { + for(size_t i = 0; i < sent_colors.size(); i++) + { + sent_colors[i] = HIDPP20_UNCOMMITTED; + } + } + DeviceUpdateLEDs(); +} + +RGBController_LogitechHIDPP20::~RGBController_LogitechHIDPP20() +{ + controller->StopPowerManager(); + delete controller; +} + +void RGBController_LogitechHIDPP20::SetupZones() +{ + const HIDPP20DeviceCapabilities& caps = controller->GetCapabilities(); + + led_to_zone_id.clear(); + sent_colors.clear(); + + if(caps.has_perkey) + { + if(caps.device_type == LOGITECH_DEVICE_TYPE_KEYBOARD) + { + /*--------------------------------------------------*\ + | Keyboard: use KeyboardLayoutManager for matrix | + | layout. Derive size from numpad presence, layout | + | from 0x4540 KeyboardLayout feature. | + \*--------------------------------------------------*/ + KEYBOARD_SIZE kb_size = caps.has_numpad + ? KEYBOARD_SIZE_FULL + : KEYBOARD_SIZE_TKL; + + KEYBOARD_LAYOUT kb_layout; + + switch(caps.keyboard_layout_code) + { + case 3: // German + case 7: // Swiss + kb_layout = KEYBOARD_LAYOUT_ISO_QWERTZ; + break; + + case 4: // French + kb_layout = KEYBOARD_LAYOUT_ISO_AZERTY; + break; + + case 2: // UK + case 5: // Spanish + case 0x0B: // Italian + case 0x0D: // Portuguese + case 0x0E: // Belgian + case 0x0F: // Scandinavian + case 8: // Nordic + case 0x16: // Nordic + case 0x1D: // Nordic + case 0x21: // Nordic + case 0x24: // Belgian + kb_layout = KEYBOARD_LAYOUT_ISO_QWERTY; + break; + + case 9: // Japanese + case 0x3E: // Japanese + kb_layout = KEYBOARD_LAYOUT_JIS; + break; + + case 1: // US + default: + kb_layout = KEYBOARD_LAYOUT_ANSI_QWERTY; + break; + } + + KeyboardLayoutManager klm(kb_layout, kb_size); + + zone perkey_zone; + perkey_zone.name = ZONE_EN_KEYBOARD; + perkey_zone.type = ZONE_TYPE_MATRIX; + perkey_zone.leds_min = klm.GetKeyCount(); + perkey_zone.leds_max = klm.GetKeyCount(); + perkey_zone.leds_count = klm.GetKeyCount(); + + matrix_map_type* new_map = new matrix_map_type; + new_map->height = klm.GetRowCount(); + new_map->width = klm.GetColumnCount(); + new_map->map = new unsigned int[new_map->height * new_map->width]; + klm.GetKeyMap(new_map->map, KEYBOARD_MAP_FILL_TYPE_COUNT, + new_map->height, new_map->width); + perkey_zone.matrix_map = new_map; + zones.push_back(perkey_zone); + + for(unsigned int i = 0; i < klm.GetKeyCount(); i++) + { + led new_led; + std::string key_name = klm.GetKeyNameAt(i); + new_led.name = key_name; + + /*---------------------------------------------*\ + | Look up zone ID by key name | + \*---------------------------------------------*/ + std::map::const_iterator it = hidpp20_key_name_to_zone.find(key_name); + unsigned int zone_id = (it != hidpp20_key_name_to_zone.end()) ? it->second : 0; + new_led.value = zone_id; + leds.push_back(new_led); + + led_to_zone_id.push_back((uint16_t)zone_id); + } + + /*-------------------------------------------------*\ + | Extras: zones the device reported via paginated | + | 0x8081 GetInfo that aren't covered by KLM. On a | + | G915 this is media keys, G1-G5, brightness, and | + | logo. Append them as a separate linear zone so | + | users can still address them. | + \*-------------------------------------------------*/ + std::set klm_claimed_zones; + for(uint16_t zid : led_to_zone_id) + { + if(zid != 0) + { + klm_claimed_zones.insert(zid); + } + } + + /*---------------------------------------------------*\ + | Extras candidates: zones reported by the device | + | that aren't claimed by KLM AND have a known name | + | in hidpp20_key_name_to_zone. We deliberately DROP | + | unnamed zones — firmware-side GetInfo bitmaps | + | enumerate phantom/reserved slots (G515 reports | + | 47, 97, 99-103, 254 among others) that aren't | + | wired to physical LEDs. Exposing them as "LED N" | + | created ghost entries in the GUI. Treat | + | hidpp20_key_name_to_zone as the curated allowlist. | + \*---------------------------------------------------*/ + std::vector> extras; + for(uint16_t zid : caps.perkey_zone_ids) + { + if(klm_claimed_zones.count(zid) != 0) + { + continue; + } + + std::string label; + for(const std::pair& kv : hidpp20_key_name_to_zone) + { + if(kv.second == zid) + { + label = kv.first; + break; + } + } + + if(label.empty()) + { + LOG_DEBUG("[LogitechHID++2.0 %s] Dropping unnamed per-key zone %u " + "(not in hidpp20_key_name_to_zone)", + name.c_str(), (unsigned)zid); + continue; + } + + extras.emplace_back(zid, label); + } + + if(!extras.empty()) + { + zone extras_zone; + extras_zone.name = "Extras"; + extras_zone.type = ZONE_TYPE_LINEAR; + extras_zone.leds_min = (unsigned int)extras.size(); + extras_zone.leds_max = (unsigned int)extras.size(); + extras_zone.leds_count = (unsigned int)extras.size(); + extras_zone.matrix_map = nullptr; + zones.push_back(extras_zone); + + for(size_t i = 0; i < extras.size(); i++) + { + led new_led; + new_led.name = extras[i].second; + new_led.value = extras[i].first; + leds.push_back(new_led); + led_to_zone_id.push_back(extras[i].first); + } + } + } + else if(const MouseLayout* ml = FindMouseLayout(caps.device_name)) + { + /*--------------------------------------------------*\ + | Known mouse: use table-defined matrix layout | + \*--------------------------------------------------*/ + zone perkey_zone; + perkey_zone.name = "Mouse LEDs"; + perkey_zone.type = ZONE_TYPE_MATRIX; + perkey_zone.leds_min = ml->led_count; + perkey_zone.leds_max = ml->led_count; + perkey_zone.leds_count = ml->led_count; + + matrix_map_type* new_map = new matrix_map_type; + new_map->height = ml->rows; + new_map->width = ml->cols; + new_map->map = new unsigned int[ml->rows * ml->cols]; + memcpy(new_map->map, ml->map, ml->rows * ml->cols * sizeof(unsigned int)); + perkey_zone.matrix_map = new_map; + zones.push_back(perkey_zone); + + for(unsigned int i = 0; i < ml->led_count && i < caps.perkey_zone_ids.size(); i++) + { + led new_led; + new_led.name = ml->led_names[i]; + new_led.value = caps.perkey_zone_ids[i]; + leds.push_back(new_led); + + led_to_zone_id.push_back(caps.perkey_zone_ids[i]); + } + } + else + { + /*-------------------------------------------------*\ + | Other devices: linear zone with auto-named LEDs | + \*-------------------------------------------------*/ + zone perkey_zone; + perkey_zone.name = "LEDs"; + perkey_zone.type = ZONE_TYPE_LINEAR; + perkey_zone.leds_min = (unsigned int)caps.perkey_zone_ids.size(); + perkey_zone.leds_max = (unsigned int)caps.perkey_zone_ids.size(); + perkey_zone.leds_count = (unsigned int)caps.perkey_zone_ids.size(); + perkey_zone.matrix_map = nullptr; + zones.push_back(perkey_zone); + + for(size_t i = 0; i < caps.perkey_zone_ids.size(); i++) + { + led new_led; + new_led.name = "LED " + std::to_string(caps.perkey_zone_ids[i]); + new_led.value = caps.perkey_zone_ids[i]; + leds.push_back(new_led); + + led_to_zone_id.push_back(caps.perkey_zone_ids[i]); + } + } + } + else if(caps.is_headset_rgb_hostmode) + { + /*------------------------------------------------------*\ + | Headset RGB hostmode (0x0620): single linear zone with | + | one LED per discovered earcup zone ID. | + \*------------------------------------------------------*/ + size_t led_count = caps.headset_rgb_hostmode_zone_ids.size(); + + zone headset_zone; + headset_zone.name = "Headset"; + headset_zone.type = ZONE_TYPE_LINEAR; + headset_zone.leds_min = (unsigned int)led_count; + headset_zone.leds_max = (unsigned int)led_count; + headset_zone.leds_count = (unsigned int)led_count; + headset_zone.matrix_map = nullptr; + zones.push_back(headset_zone); + + for(size_t i = 0; i < led_count; i++) + { + led new_led; + new_led.name = (i == 0) ? "Left Earcup" + : (i == 1) ? "Right Earcup" + : "Zone " + std::to_string(i); + new_led.value = caps.headset_rgb_hostmode_zone_ids[i]; + leds.push_back(new_led); + + led_to_zone_id.push_back(caps.headset_rgb_hostmode_zone_ids[i]); + } + } + else if(caps.has_zone_effects) + { + /*------------------------------------------------------*\ + | No per-key: create one zone per cluster | + \*------------------------------------------------------*/ + for(size_t i = 0; i < caps.zone_clusters.size(); i++) + { + const HIDPP20ZoneCluster& cluster = caps.zone_clusters[i]; + + zone new_zone; + new_zone.name = zone_location_name(cluster.location); + new_zone.type = ZONE_TYPE_SINGLE; + new_zone.leds_min = 1; + new_zone.leds_max = 1; + new_zone.leds_count = 1; + new_zone.matrix_map = nullptr; + zones.push_back(new_zone); + + led new_led; + new_led.name = new_zone.name; + new_led.value = cluster.index; + leds.push_back(new_led); + + led_to_zone_id.push_back(cluster.index); + } + } + + /*---------------------------------------------------------*\ + | Build the zone_id -> LED index reverse map. Indexed | + | 0..255 (zone IDs are bytes); -1 marks "no LED for this | + | zone". Used by the FrameEnd commit step to translate the | + | acked_zones list back into LED indices for sent_colors. | + \*---------------------------------------------------------*/ + zone_id_to_led_idx.assign(256, -1); + + for(size_t i = 0; i < led_to_zone_id.size(); i++) + { + uint16_t zid = led_to_zone_id[i]; + if(zid > 0 && zid < 256) + { + zone_id_to_led_idx[zid] = (int)i; + } + } + + SetupColors(); +} + +void RGBController_LogitechHIDPP20::ResizeZone(int /*zone*/, int /*new_size*/) +{ +} + +void RGBController_LogitechHIDPP20::DeviceUpdateLEDs() +{ + if(!controller->IsOnline()) + { + return; + } + + /*----------------------------------------------------------*\ + | Ensure SW control is claimed on first actual color push. | + | Safe here because we have real colors in the buffer. | + \*----------------------------------------------------------*/ + controller->ClaimSWControlIfNeeded(); + + const HIDPP20DeviceCapabilities& caps = controller->GetCapabilities(); + + /*----------------------------------------------------------*\ + | Frame handling during SLEEPING: | + | | + | Default — suppress frames. A suppressed frame cannot | + | wake a device that treats writes as activity, so this is | + | the safe choice when we don't know how a particular | + | firmware handles host traffic during its fade. | + | | + | Quirk-gated — devices flagged FADE_ACCEPTS_WRITES opt out | + | of suppression because their firmware accepts writes | + | without cancelling sleep. Frames flow through SLEEPING | + | until deep sleep starts BUSY-NACKing every FrameEnd; | + | consecutive-failure tracking then sets deep_sleep and the | + | top IsDeepSleep() check takes over. | + | | + | Both paths suppress until Wake() clears the state. | + \*----------------------------------------------------------*/ + if(controller->IsDeepSleep()) + { + return; + } + + if(controller->GetPowerState() == HIDPP20_POWER_SLEEPING + && !(caps.quirks & HIDPP20_QUIRK_FADE_ACCEPTS_WRITES)) + { + return; + } + + /*----------------------------------------------------------*\ + | Feature 0x0620 Headset RGB Hostmode (Centurion G522 / | + | PRO X 2). Static-color only, two earcup zones. Bypasses | + | per-key, SetZoneEffect, and effect-card paths entirely — | + | 0x0620 has none of that. Claim was made once in | + | SetHostMode(); we just write colors + FrameEnd[0x01]. | + \*----------------------------------------------------------*/ + if(caps.is_headset_rgb_hostmode) + { + controller->SetHeadsetRGBHostmodeColors(colors); + return; + } + + if(caps.has_perkey && (unsigned int)active_mode < modes.size() && + modes[active_mode].color_mode == MODE_COLORS_PER_LED) + { + uint8_t perkey_idx = (caps.idx_perkey_v2 != 0) ? caps.idx_perkey_v2 : caps.idx_perkey_v1; + + /*------------------------------------------------------*\ + | Detect re-initialization (reconnect, wake from sleep). | + | Device state is unknown — force full resend. | + \*------------------------------------------------------*/ + uint32_t gen = controller->GetInitGeneration(); + + if(gen != last_init_gen) + { + sent_colors.clear(); + last_init_gen = gen; + } + + /*-------------------------------------------------------*\ + | Per-key prep call. Two paths, selected by a runtime | + | capability probe at feature-discovery time: | + | | + | (A) Observed prep via DoObservedPerKeyPrep — two | + | SetEffectByIndex calls on 0x8071 cloned from | + | the observed vendor-app wire behavior. The | + | template bytes at | + | prep1 params[6..7] and the effectIdx at prep2 are | + | parameterized from device-discovery results | + | (caps.effect_card_template[], and | + | caps.zone_clusters[0].effects.size() respectively) | + | so the same code adapts to any device that shares | + | the G502-family prep pattern. | + | | + | Gated on caps.has_effect_cards, which is set by | + | DiscoverEffectCards iff the device responds | + | successfully to GetEffectSpecificInfo. Devices | + | without firmware effect cards leave this false | + | and fall through to path (B). | + | | + | (B) Static-pass-through prep (original fork behavior, | + | doc-verified on G515) — applied when the device | + | has no effect cards or uses 0x8070 / 0x0600 | + | instead of 0x8071. SetEffect cluster=0xFF, | + | effect=Static, RGB=(0,0,0), no fixed-color marker, | + | persist=1. | + | | + | An earlier revision used `effects.size() < 5` as a | + | heuristic proxy for "G502-shaped" devices. The proxy | + | accidentally correlated with "has effect cards" on | + | the two devices we knew about but had no principled | + | meaning — it's been replaced with the direct capability | + | probe. | + \*-------------------------------------------------------*/ + bool needs_prep = controller->NeedsPrepSequence(); + + if(needs_prep && caps.has_zone_effects) + { + bool shape_matches_keyboard_family = + caps.idx_disable_keys_by_usage != 0 + && caps.idx_perkey_v2 != 0 + && caps.rgb_feature_page == HIDPP20_FEAT_RGB_EFFECTS; + + bool shape_matches_observed_prep = + caps.has_effect_cards + && caps.rgb_feature_page == HIDPP20_FEAT_RGB_EFFECTS; + + if(shape_matches_keyboard_family) + { + /*---------------------------------------------*\ + | G815 / G915 / G Pro: per-cluster Off + primer | + | key + FrameEnd. Matches their legacy | + | InitializeDirect wire sequence. | + \*---------------------------------------------*/ + controller->DoKeyboardFamilyPerKeyPrep(); + } + else if(shape_matches_observed_prep) + { + controller->DoObservedPerKeyPrep(); + } + else + { + uint8_t static_effect_idx = 0; + + for(size_t j = 0; j < caps.zone_clusters[0].effects.size(); j++) + { + if(caps.zone_clusters[0].effects[j].effect_id == 0x0001) + { + static_effect_idx = caps.zone_clusters[0].effects[j].index; + break; + } + } + + controller->SetZoneEffect( + 0xFF, /* all clusters */ + static_effect_idx, + 0x0001, /* static effect */ + 0, 0, 0, /* black — no fixed-color marker */ + 0, + 100, /* brightness — unused for static */ + 0, /* direction — unused for static */ + true /* persist=true */); + } + } + + /*------------------------------------------------------*\ + | Snapshot colors to avoid races with effects updating | + | the colors array while we're sending. | + \*------------------------------------------------------*/ + std::vector snapshot(colors.begin(), colors.end()); + + /*-------------------------------------------------------*\ + | Apply dim brightness scaling if not at full brightness. | + | This modifies the OUTPUT only — the internal colors[] | + | buffer stays at full brightness for the animation. | + \*-------------------------------------------------------*/ + int brightness = controller->GetDimBrightness(); + + if(brightness < 100) + { + for(size_t i = 0; i < snapshot.size(); i++) + { + uint8_t r = RGBGetRValue(snapshot[i]) * brightness / 100; + uint8_t g = RGBGetGValue(snapshot[i]) * brightness / 100; + uint8_t b = RGBGetBValue(snapshot[i]) * brightness / 100; + snapshot[i] = ToRGBColor(r, g, b); + } + } + + /*------------------------------------------------------*\ + | Compute delta against last committed state. | + | First call (sent_colors empty) sends everything. | + \*------------------------------------------------------*/ + bool full_update = (sent_colors.size() != snapshot.size()); + + std::map> color_to_zones; + + for(size_t i = 0; i < snapshot.size() && i < led_to_zone_id.size(); i++) + { + if(led_to_zone_id[i] == 0 || led_to_zone_id[i] > 255) + { + continue; + } + + if(full_update || snapshot[i] != sent_colors[i]) + { + color_to_zones[snapshot[i]].push_back((uint8_t)led_to_zone_id[i]); + } + } + + if(color_to_zones.empty()) + { + return; + } + + /*-----------------------------------------------------*\ + | Drain stale ACKs from previous frames before sending. | + | Without this, SendPerKeyData reads a stale ACK, | + | mistakes it for the current write's ACK, returns | + | early, and FrameEnd then races with the actual ACK. | + \*-----------------------------------------------------*/ + controller->FlushResponseQueue(); + + /*------------------------------------------------------*\ + | Batch changed keys for efficient wire encoding. | + | | + | For same-color groups (>= 2 keys): | + | Sort zone IDs and find contiguous runs. | + | fn5 (SET_RANGE) for runs of 3+: | + | [start, end, R, G, B] × 3 per packet | + | fn6 (SET_SINGLE_VALUE) for scattered remainder: | + | [R, G, B, zid, zid, ...] up to 13 per packet | + | | + | For single-occurrence colors: | + | fn1 (SET_INDIVIDUAL): [zid,R,G,B] × 4 per packet | + \*------------------------------------------------------*/ + std::vector> individual_pairs; + + for(std::pair>& entry : color_to_zones) + { + RGBColor color = entry.first; + std::vector& zone_ids = entry.second; + + if(zone_ids.size() >= 2) + { + uint8_t r = RGBGetRValue(color); + uint8_t g = RGBGetGValue(color); + uint8_t b = RGBGetBValue(color); + + /*--------------------------------------------------*\ + | Sort zone IDs and extract contiguous runs for fn5 | + \*--------------------------------------------------*/ + std::sort(zone_ids.begin(), zone_ids.end()); + + std::vector> ranges; + std::vector scattered; + size_t run_start = 0; + + for(size_t i = 1; i <= zone_ids.size(); i++) + { + if(i < zone_ids.size() && zone_ids[i] == zone_ids[i - 1] + 1) + { + continue; + } + + size_t run_len = i - run_start; + + if(run_len >= 3) + { + ranges.push_back({zone_ids[run_start], zone_ids[i - 1]}); + } + else + { + for(size_t j = run_start; j < i; j++) + { + scattered.push_back(zone_ids[j]); + } + } + + run_start = i; + } + + /*--------------------------------------------------*\ + | fn5 (SET_RANGE): 3 range entries per packet. | + | Track every zone in each packet's ranges so the | + | FrameEnd ACK matcher can mark them committed. | + \*--------------------------------------------------*/ + for(size_t i = 0; i < ranges.size(); i += 3) + { + uint8_t data[16] = {}; + std::vector packet_zones; + size_t batch = ranges.size() - i; + if(batch > 3) batch = 3; + + for(size_t j = 0; j < batch; j++) + { + data[j * 5 + 0] = ranges[i + j].first; + data[j * 5 + 1] = ranges[i + j].second; + data[j * 5 + 2] = r; + data[j * 5 + 3] = g; + data[j * 5 + 4] = b; + + for(uint8_t z = ranges[i + j].first; + z <= ranges[i + j].second; z++) + { + packet_zones.push_back(z); + } + } + + controller->SendPerKeyData(perkey_idx, FN_8081_SET_RANGE, + data, batch * 5, packet_zones); + } + + /*--------------------------------------------------*\ + | fn6 (SET_SINGLE_VALUE) for remaining scattered. | + | Track the listed zone IDs in each packet. | + \*--------------------------------------------------*/ + for(size_t i = 0; i < scattered.size(); i += 13) + { + uint8_t data[16] = {}; + std::vector packet_zones; + data[0] = r; + data[1] = g; + data[2] = b; + + size_t batch = scattered.size() - i; + if(batch > 13) batch = 13; + + for(size_t j = 0; j < batch; j++) + { + data[3 + j] = scattered[i + j]; + packet_zones.push_back(scattered[i + j]); + } + + controller->SendPerKeyData(perkey_idx, FN_8081_SET_SINGLE_VALUE, + data, 3 + batch, packet_zones); + } + } + else + { + for(uint8_t zid : zone_ids) + { + individual_pairs.push_back({zid, color}); + } + } + } + + if(!individual_pairs.empty()) + { + controller->SetPerKeyColors(individual_pairs); + } + + PerKeyFrameResult commit = controller->PerKeyFrameEnd(); + + /*-----------------------------------------------------*\ + | A frame is "fully committed" only if FrameEnd ACKed | + | AND every attempted zone also ACKed. FrameEnd alone | + | is not enough — the firmware happily ACKs FrameEnd | + | even when prior per-key writes were silently dropped | + | (observed on G502 X PLUS during wireless reconnect | + | transients, where the Set* writes return no response | + | but FrameEnd still lands cleanly). | + \*-----------------------------------------------------*/ + bool full_commit = commit.frame_end_acked + && (commit.acked_zones.size() + == commit.attempted_zones.size()); + + /*-----------------------------------------------------*\ + | Upgrade SW control flags from 6 → 5 once the per-key | + | layer is populated. ClaimSWControlIfNeeded leaves the | + | device at flags=6 (effect engine still autonomous) to | + | avoid the onboard→host transition flash; now that the | + | per-key layer is masking zone output, it's safe (and | + | required for idle/wake event generation) to claim the | + | effect bit. No-op if the upgrade has already happened | + | or if claim itself hasn't occurred. | + | | + | Gated on full_commit: upgrading into flags=5 with an | + | empty per-key buffer would leave the firmware with | + | nothing to render and expose its default LED buffer | + | (warm-white on the G502 X PLUS). | + \*-----------------------------------------------------*/ + if(full_commit) + { + controller->UpgradeSwControlAfterFirstPaint(); + } + + /*------------------------------------------------------*\ + | Retry scheduling — ONLY on the critical first paint | + | after a fresh claim (needs_prep == true). Streaming | + | animation frames regularly partial-commit due to | + | fire-and-forget timing, and the delta carry-over | + | (HIDPP20_UNCOMMITTED) already handles missed zones on | + | the next animation tick. Scheduling retries on every | + | partial streaming frame causes the power thread's | + | TickRetryPaintIfPending to fire request_repaint_fn | + | between animation frames, colliding with the animation | + | loop and producing visible stalls. | + | | + | For the first-paint-after-claim case (needs_prep), | + | there IS no "next animation frame" guaranteed, so the | + | retry is the only mechanism to recover from a partial | + | commit during the reconnect-transient window. | + \*------------------------------------------------------*/ + if(full_commit || !needs_prep) + { + controller->CancelRetryPaint(); + } + else + { + controller->ScheduleRetryPaint(); + } + + /*-------------------------------------------------------*\ + | Ensure sent_colors is sized to the snapshot before | + | the commit loop writes by index. On the first frame | + | (or after a reinit clear) sent_colors is empty, and | + | the per-zone writes below would silently no-op, | + | leaving sent_colors empty and re-firing the prep call | + | on every subsequent frame. | + | | + | Initial fill is HIDPP20_UNCOMMITTED so any LED that we | + | did not touch this frame stays "uncommitted" and gets | + | scheduled for the next delta. | + \*-------------------------------------------------------*/ + if(sent_colors.size() != snapshot.size()) + { + sent_colors.assign(snapshot.size(), HIDPP20_UNCOMMITTED); + } + + /*-----------------------------------------------------*\ + | Build a fast lookup of acked zones for this frame. | + \*-----------------------------------------------------*/ + std::set acked_set(commit.acked_zones.begin(), + commit.acked_zones.end()); + + if(commit.frame_end_acked) + { + /*----------------------------------------------------*\ + | Frame end ACKed: any zone whose write packet also | + | ACKed is now committed — advance sent_colors for | + | that LED. Any zone we attempted but never saw an | + | ACK for goes to HIDPP20_UNCOMMITTED so the next | + | frame's delta picks it up. | + \*----------------------------------------------------*/ + for(uint8_t zid : commit.attempted_zones) + { + int led_idx = zone_id_to_led_idx[zid]; + if(led_idx < 0 || (size_t)led_idx >= sent_colors.size()) + { + continue; + } + + if(acked_set.count(zid)) + { + sent_colors[led_idx] = snapshot[led_idx]; + } + else + { + sent_colors[led_idx] = HIDPP20_UNCOMMITTED; + } + } + } + else + { + /*---------------------------------------------------*\ + | Frame end timed out: we don't know what the device | + | committed. Mark every attempted LED uncommitted so | + | the next frame re-pushes them all. Don't bother | + | with the per-zone ACK info here — if FrameEnd | + | didn't land, the per-key writes that did ACK still | + | sit in the staging buffer un-swapped. | + \*---------------------------------------------------*/ + for(uint8_t zid : commit.attempted_zones) + { + int led_idx = zone_id_to_led_idx[zid]; + if(led_idx >= 0 && (size_t)led_idx < sent_colors.size()) + { + sent_colors[led_idx] = HIDPP20_UNCOMMITTED; + } + } + } + + } +} + +void RGBController_LogitechHIDPP20::UpdateZoneLEDs(int /*zone*/) +{ + DeviceUpdateLEDs(); +} + +void RGBController_LogitechHIDPP20::UpdateSingleLED(int /*led*/) +{ + DeviceUpdateLEDs(); +} + +void RGBController_LogitechHIDPP20::DeviceUpdateMode() +{ + if(!controller->IsOnline()) + { + return; + } + + /*----------------------------------------------------------*\ + | Drop mode changes while the firmware is fading to off. | + | The device owns its own power state — we don't force-wake | + | it from software. active_mode stays tracked framework- | + | side, and the next wake (firmware onUserActivity) or | + | reconnect will re-apply it through the reinit callback. | + \*----------------------------------------------------------*/ + if(controller->GetPowerState() == HIDPP20_POWER_SLEEPING) + { + return; + } + + /*----------------------------------------------------------*\ + | Claim SW control on first mode set (deferred from init). | + \*----------------------------------------------------------*/ + controller->ClaimSWControlIfNeeded(); + + const HIDPP20DeviceCapabilities& caps = controller->GetCapabilities(); + + if((unsigned int)active_mode >= modes.size()) + { + return; + } + + const mode& current = modes[active_mode]; + + /*----------------------------------------------------------*\ + | Direct mode: invalidate delta tracking so the next | + | DeviceUpdateLEDs sends a full frame with actual colors. | + \*----------------------------------------------------------*/ + if(current.name == "Direct") + { + sent_colors.clear(); + DeviceUpdateLEDs(); + + /*------------------------------------------------------*\ + | Start power manager (reader + power threads) if not | + | already running. | + \*------------------------------------------------------*/ + controller->StartPowerManager(); + + if(caps.idx_wireless_status != 0 && !caps.has_power_mgmt) + { + controller->StartEventWatcher(); + } + + return; + } + + sent_colors.clear(); + + /*----------------------------------------------------------*\ + | On per-key devices, single-color non-animated modes (Off, | + | Static) are applied through the per-key path so the LEDs | + | track the mode color cleanly. Animated effects (anything | + | with HAS_SPEED — Breathing, Cycle, Wave, Ripple) fall | + | through to the zone-effect path below; in practice zone | + | effects render correctly alongside per-key on the devices | + | we have data for. | + \*----------------------------------------------------------*/ + if(caps.has_perkey) + { + /*------------------------------------------------------*\ + | Off mode via per-key: set all LEDs to black | + \*------------------------------------------------------*/ + if(current.value == 0xFF) + { + controller->SetAllPerKeyColor(ToRGBColor(0, 0, 0)); + controller->PerKeyFrameEnd(); + return; + } + + /*------------------------------------------------------*\ + | Static mode via per-key: only used as a fallback when | + | the device exposes per-key but no zone effects. When | + | both are available we prefer the zone-effect path | + | because per-key writes alone don't fully claim against | + | the firmware effect engine on some devices (G502), | + | leaving the firmware fade fighting our per-key colors | + | until something else (e.g. Cycle) force-claims. | + | Gated on !HAS_SPEED so animated colored effects like | + | Breathing don't get clipped to a static color. | + \*------------------------------------------------------*/ + if(!caps.has_zone_effects + && current.color_mode == MODE_COLORS_MODE_SPECIFIC + && current.colors.size() > 0 + && !(current.flags & MODE_FLAG_HAS_SPEED)) + { + controller->SetAllPerKeyColor(current.colors[0]); + controller->PerKeyFrameEnd(); + return; + } + + /*------------------------------------------------------*\ + | Animated effects (Breathing, Cycle, Wave, Ripple) on | + | per-key devices fall through to the zone effect path. | + \*------------------------------------------------------*/ + } + + /*----------------------------------------------------------*\ + | Zone effect modes (devices without per-key, or animated | + | effects that can't be done via per-key) | + | | + | persist branching: | + | 0x8070: ephemeral by default; becomes persist=true | + | only when DeviceSaveMode has set save_pending. | + | 0x8071/0x0600: keeps the pre-existing per-branch | + | hardcoded values (Off=false, Effect=true) | + | pending 0x8071 save research. | + \*----------------------------------------------------------*/ + const bool is_8070 = (caps.rgb_feature_page == HIDPP20_FEAT_COLOR_LED_EFFECTS); + + /*----------------------------------------------------------*\ + | Off mode: set static black on all clusters | + \*----------------------------------------------------------*/ + if(current.value == 0xFF) + { + const bool off_persist = is_8070 ? save_pending : false; + + for(size_t i = 0; i < caps.zone_clusters.size(); i++) + { + for(size_t j = 0; j < caps.zone_clusters[i].effects.size(); j++) + { + if(caps.zone_clusters[i].effects[j].effect_id == 0x0001) + { + controller->SetZoneEffect( + caps.zone_clusters[i].index, + caps.zone_clusters[i].effects[j].index, + 0x0001, 0, 0, 0, 0, 100, 0, off_persist); + break; + } + } + } + controller->UpgradeSwControlAfterFirstPaint(); + return; + } + + /*----------------------------------------------------------*\ + | Effect mode: apply to all clusters. | + | | + | Color source per cluster: | + | MODE_COLORS_PER_LED — current.colors[i] maps to | + | caps.zone_clusters[i] | + | (one LED per cluster on the | + | 0x8070 zone path) | + | MODE_COLORS_MODE_SPECIFIC — current.colors[0] for all | + | MODE_COLORS_NONE — zero (effect ignores RGB) | + \*----------------------------------------------------------*/ + uint16_t period = SpeedSliderToPeriodMs(current.speed); + + /*---------------------------------------------------------*\ + | Brightness defaults to 100 for modes that don't expose | + | a brightness slider — those modes ignore the value at the | + | wire level anyway. Modes flagged HAS_BRIGHTNESS take the | + | user-set value from current.brightness. | + \*---------------------------------------------------------*/ + unsigned char brightness = (current.flags & MODE_FLAG_HAS_BRIGHTNESS) + ? (unsigned char)current.brightness + : 100; + + for(size_t i = 0; i < caps.zone_clusters.size(); i++) + { + uint16_t eff_id = 0; + + const std::vector& cluster_effects = caps.zone_clusters[i].effects; + for(size_t j = 0; j < cluster_effects.size(); j++) + { + if(cluster_effects[j].index == (uint8_t)current.value) + { + eff_id = cluster_effects[j].effect_id; + break; + } + } + + unsigned char r = 0, g = 0, b = 0; + + if(current.color_mode == MODE_COLORS_PER_LED && i < current.colors.size()) + { + r = RGBGetRValue(current.colors[i]); + g = RGBGetGValue(current.colors[i]); + b = RGBGetBValue(current.colors[i]); + } + else if(current.color_mode == MODE_COLORS_MODE_SPECIFIC && !current.colors.empty()) + { + r = RGBGetRValue(current.colors[0]); + g = RGBGetGValue(current.colors[0]); + b = RGBGetBValue(current.colors[0]); + } + + /*------------------------------------------------------*\ + | Ripple wants a narrower, much faster period range | + | (2..200ms) than the breathing/wave baseline of 1..20s. | + | Both Ripple variants — 0x000B and the saturation | + | 0x0017 — use the fast range; this mirrors Solaar's | + | LEDEffects table, where only Ripple carries a period | + | range override. Cycle (0x0003/0x0015) and Wave | + | (0x0004/0x0016) stay on the standard 1..20s range. | + \*------------------------------------------------------*/ + uint16_t cluster_period = (eff_id == 0x000B + || eff_id == 0x0017) + ? RippleSpeedSliderToPeriodMs(current.speed) + : period; + + /*-----------------------------------------------------*\ + | 0x8071/0x0600: persist=true matches what the observed | + | vendor-app wire capture does for every mode-set on | + | these | + | devices. With persist=false the firmware appears to | + | accept the command without actually committing the | + | new effect, which is consistent with Static (which | + | gets prepped with persist=true at startup) being the | + | only effect that visibly works. | + | | + | 0x8070: ephemeral (persist=false) on live writes; | + | DeviceSaveMode flips save_pending true to replay the | + | active mode with persist=true and commit to NVM. | + \*-----------------------------------------------------*/ + const bool effect_persist = is_8070 ? save_pending : true; + + controller->SetZoneEffect( + caps.zone_clusters[i].index, + current.value, + eff_id, r, g, b, cluster_period, brightness, + WaveDirectionToWire(current.direction), effect_persist); + } + + /*-----------------------------------------------------------*\ + | The zone effects are now committed — safe to upgrade from | + | flags=6 to flags=5. Without this, devices that only use | + | zone effects (no per-key Direct path) would stay at | + | flags=6 forever and the firmware would never send | + | onUserActivity events for idle/sleep. | + \*-----------------------------------------------------------*/ + controller->UpgradeSwControlAfterFirstPaint(); +} + +void RGBController_LogitechHIDPP20::DeviceSaveMode() +{ + /*----------------------------------------------------------*\ + | 0x8071/0x0600 already write persist=true on every mode | + | change, so the Save button isn't exposed on those pages | + | (MODE_FLAG_MANUAL_SAVE is only set for 0x8070 modes in | + | the constructor). If a save ever lands here from those | + | pages anyway, nothing needs doing — the active mode is | + | already committed to NVM. | + | | + | 0x8070: replay the active mode through DeviceUpdateMode | + | with save_pending true so the zone effect writes go out | + | with persist=true, committing the currently-live effect | + | to flash. | + \*----------------------------------------------------------*/ + const HIDPP20DeviceCapabilities& caps = controller->GetCapabilities(); + if(caps.rgb_feature_page != HIDPP20_FEAT_COLOR_LED_EFFECTS) + { + return; + } + + save_pending = true; + DeviceUpdateMode(); + save_pending = false; +} + +bool RGBController_LogitechHIDPP20::ReapplyActiveMode() +{ + /*-----------------------------------------------------------*\ + | Re-establish the current active_mode on the device. Used | + | by the wake path (after SetRgbPowerMode(1) cancels the | + | firmware fade) and by the reconnect path (after a wireless | + | or USB reconnect). Handles both per-key Direct and zone | + | effect modes: | + | | + | 1. Claim SW control (host mode + flags + power mode). | + | Retried internally; returns true iff the final claim | + | ACKed. ReconnectDevice's fast-backoff loop uses this | + | as the accept signal. | + | 2. Clear sent_colors so the next frame is full-push. | + | Per the 0x8071 lifecycle, the device's LED buffer may | + | not survive mode 3→1 or a full reconnect, so we don't | + | trust it to remember any prior state. | + | 3. Route through DeviceUpdateMode so both per-key Direct | + | (full per-key frame via DeviceUpdateLEDs) and zone | + | effects (SetEffect per cluster) re-establish | + | correctly. Covers the case where the active_mode was | + | changed in the GUI while the device was fading — that | + | mode change was dropped at the time and needs to land | + | here on wake. | + \*-----------------------------------------------------------*/ + bool claimed = controller->ClaimSWControlIfNeeded(); + sent_colors.clear(); + DeviceUpdateMode(); + return claimed; +} diff --git a/Controllers/LogitechController/LogitechHIDPP20Controller/RGBController_LogitechHIDPP20.h b/Controllers/LogitechController/LogitechHIDPP20Controller/RGBController_LogitechHIDPP20.h new file mode 100644 index 000000000..616882d58 --- /dev/null +++ b/Controllers/LogitechController/LogitechHIDPP20Controller/RGBController_LogitechHIDPP20.h @@ -0,0 +1,72 @@ +/*---------------------------------------------------------*\ +| RGBController_LogitechHIDPP20.h | +| | +| RGBController for unified Logitech HID++ 2.0 devices | +| | +| This file is part of the OpenRGB project | +| SPDX-License-Identifier: GPL-2.0-or-later | +\*---------------------------------------------------------*/ + +#pragma once + +#include "RGBController.h" +#include "LogitechHIDPP20Controller.h" + +class RGBController_LogitechHIDPP20 : public RGBController +{ +public: + RGBController_LogitechHIDPP20(LogitechHIDPP20Controller* controller_ptr); + ~RGBController_LogitechHIDPP20(); + + void SetupZones(); + void ResizeZone(int zone, int new_size); + void DeviceUpdateLEDs(); + void UpdateZoneLEDs(int zone); + void UpdateSingleLED(int led); + void DeviceUpdateMode(); + void DeviceSaveMode(); + bool ReapplyActiveMode(); + +private: + LogitechHIDPP20Controller* controller; + + /*---------------------------------------------------------*\ + | Repaint callback handler. Registered with the controller | + | as request_repaint_fn and invoked from the power thread | + | for dim/wake when no animation is driving updates. | + \*---------------------------------------------------------*/ + void OnRepaintRequest(); + + /*---------------------------------------------------------*\ + | When true, the next DeviceUpdateMode cycle sends its | + | SetZoneEffect calls with persist=true instead of the | + | default ephemeral write. Used by DeviceSaveMode to replay | + | the active mode as a NVM-committed effect on 0x8070 | + | devices, which default to non-persistent live writes. | + \*---------------------------------------------------------*/ + bool save_pending = false; + + /*---------------------------------------------------------*\ + | Maps OpenRGB LED index -> HID++ per-key zone ID | + \*---------------------------------------------------------*/ + std::vector led_to_zone_id; + + /*---------------------------------------------------------*\ + | Reverse map: zone_id -> LED index (-1 if no LED). | + | Indexed 0..255 (zone IDs are bytes). Built once in | + | SetupZones to avoid scanning led_to_zone_id at commit | + | time, which would be O(N) per acked zone. | + \*---------------------------------------------------------*/ + std::vector zone_id_to_led_idx; + + /*---------------------------------------------------------*\ + | Last successfully committed colors for delta updates. | + | An entry of HIDPP20_UNCOMMITTED (0xFF000000) marks an LED | + | whose last write didn't ACK and which therefore needs to | + | be re-pushed in the next frame regardless of color delta. | + | The high byte (0xFF) is impossible for any value produced | + | by ToRGBColor() so it never collides with a real color. | + \*---------------------------------------------------------*/ + std::vector sent_colors; + uint32_t last_init_gen = 0; +}; diff --git a/StringUtils.cpp b/StringUtils.cpp index 3647de3c9..c388bee50 100644 --- a/StringUtils.cpp +++ b/StringUtils.cpp @@ -19,6 +19,7 @@ #pragma clang diagnostic ignored "-Wdeprecated-declarations" #endif +#include #include #include #include @@ -71,6 +72,16 @@ const char* StringUtils::wchar_to_char(const wchar_t* pwchar) return(filePathC); } +std::string StringUtils::wchar_to_string(const wchar_t* pwchar) +{ + if(pwchar == nullptr) + { + return std::string(); + } + + return wstring_to_string(std::wstring(pwchar)); +} + std::string StringUtils::wstring_to_string(const std::wstring wstring) { std::wstring_convert, wchar_t> converter; @@ -100,4 +111,20 @@ std::string StringUtils::u32int_to_hexString(unsigned int value) char hex_str[20] = {0}; snprintf(hex_str, sizeof(hex_str), "%X", value); return std::string(hex_str); -} \ No newline at end of file +} + +std::string StringUtils::normalize_hex_id(const std::string& id) +{ + std::string out; + out.reserve(id.size()); + + for(char c : id) + { + if(c != '-') + { + out += (char)tolower((unsigned char)c); + } + } + + return out; +} diff --git a/StringUtils.h b/StringUtils.h index c008d91de..dab199f1c 100644 --- a/StringUtils.h +++ b/StringUtils.h @@ -15,8 +15,10 @@ class StringUtils { public: static const char* wchar_to_char(const wchar_t* pwchar); + static std::string wchar_to_string(const wchar_t* pwchar); static std::string wstring_to_string(const std::wstring wstring); static std::string u16string_to_string(const std::u16string wstring); static const std::string remove_null_terminating_chars(std::string input); static std::string u32int_to_hexString(unsigned int value); + static std::string normalize_hex_id(const std::string& id); };