mirror of
https://github.com/CalcProgrammer1/OpenRGB.git
synced 2026-06-24 13:48:45 -04:00
5710 lines
224 KiB
C++
5710 lines
224 KiB
C++
/*---------------------------------------------------------*\
|
||
| 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);
|
||
}
|
||
}
|
||
|