Files
OpenRGB/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller.cpp

5710 lines
224 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*---------------------------------------------------------*\
| 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 <cstring>
#include <thread>
#include <chrono>
#include <set>
#include <vector>
#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<std::mutex> 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<std::mutex> 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<std::chrono::milliseconds>(
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<std::mutex> 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<std::chrono::milliseconds>(
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<std::mutex> 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<std::chrono::milliseconds>(
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<std::chrono::milliseconds>(
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<uint16_t, uint8_t>::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<uint16_t, uint8_t>::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<uint8_t> 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<std::pair<uint16_t, RGBColor>>& 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<uint8_t> 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<uint8_t> 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<uint8_t>& 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<uint8_t>& 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<std::chrono::milliseconds>(
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<RGBColor>& zone_colors
)
{
if(caps.idx_headset_rgb_hostmode == 0 || !device_online.load())
{
return;
}
const std::vector<uint8_t>& 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<void()> repaint)
{
request_repaint_fn = repaint;
}
void LogitechHIDPP20Controller::SetReapplyActiveModeCallback(std::function<bool()> cb)
{
reapply_active_mode_fn = cb;
}
void LogitechHIDPP20Controller::SetRegisterCallback(std::function<void(RGBController*)> 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<std::mutex> 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<std::mutex> 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<uint16_t, uint8_t>::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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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);
}
}