/*---------------------------------------------------------*\ | LogitechHIDPP20Controller.cpp | | | | Unified Logitech HID++ 2.0 controller implementation | | | | This file is part of the OpenRGB project | | SPDX-License-Identifier: GPL-2.0-or-later | \*---------------------------------------------------------*/ #include #include #include #include #include #include #include "LogitechHIDPP20Controller.h" #include "LogitechHIDPP20ReceiverWatcher.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 x 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; /*---------------------------------------------------------*\ | Consecutive CenturionFeatureSet batches that answer | | nothing before the walk gives up. A wireless sub-device | | can drop a frame; one that has gone away drops them all. | \*---------------------------------------------------------*/ static const int CENTURION_FEATURE_MISS_BUDGET = 3; /*---------------------------------------------------------*\ | Feature entries in one CenturionFeatureSet reply. A 64 | | byte frame less the addressed header and the bridge | | wrapper leaves 53 payload bytes: one count byte and 13 | | four byte entries. | \*---------------------------------------------------------*/ static const int CENTURION_FEATURES_PER_FRAME = 13; /*---------------------------------------------------------*\ | Device-name helpers. A placeholder is empty or one of the | | HIDPP20_NAME_PLACEHOLDER_* strings. A name "looks real" | | when it is non-empty, printable ASCII, and a sane length. | | This rejects 0x0101 firmware-data responses that are | | not an actual name. | \*---------------------------------------------------------*/ static bool HIDPP20NameIsPlaceholder(const std::string& n) { return n.empty() || n == HIDPP20_NAME_PLACEHOLDER_STD || n == HIDPP20_NAME_PLACEHOLDER_CENTURION; } bool LogitechHIDPP20Controller::NameLooksReal(const std::string& n) { if(n.empty() || n.size() > 64) { return false; } for(unsigned char c : n) { if(c < 0x20 || c > 0x7E) { return false; } } return true; } /*---------------------------------------------------------*\ | 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, { 0, 1 }, 2 }, { 0x8070, { 0, 3, 5 }, 3 }, { 0x8071, { 0, 4 }, 2 }, { 0x8080, { 0 }, 1 }, { 0x8081, { 0, 2 }, 2 }, }; /*---------------------------------------------------------*\ | Returns true if feature_id is not tracked (silent) or if | | version appears in the tracked feature's approved set. | \*---------------------------------------------------------*/ static bool FeatureVersionIsObserved(uint16_t feature_id, uint8_t version) { size_t table_len = sizeof(HIDPP20_FEATURE_OBSERVED_VERSIONS) / sizeof(HIDPP20_FEATURE_OBSERVED_VERSIONS[0]); for(size_t r = 0; r < table_len; r++) { const HIDPP20FeatureVersionSet& row = HIDPP20_FEATURE_OBSERVED_VERSIONS[r]; if(row.feature_id != feature_id) { continue; } for(uint8_t i = 0; i < row.count; i++) { if(row.versions[i] == version) { return true; } } return false; /* tracked feature, unknown version */ } return true; /* feature not tracked, silent */ } LogitechHIDPP20Controller::LogitechHIDPP20Controller ( hid_device* dev, const char* path, uint8_t device_index, bool wireless, std::shared_ptr mutex_ptr, uint16_t usage_page, hid_device* perkey_vl_dev, bool bluetooth ) { this->dev = dev; this->dev_perkey_vl = perkey_vl_dev; this->location = path; this->device_index = device_index; this->wireless = wireless; this->transport.bluetooth = bluetooth; this->mutex = mutex_ptr; this->long_only = false; this->teardown_pending = false; this->initialized = false; this->sw_control_claimed = false; this->last_fap_error_ = 0; this->pipelining_claim_ = false; this->discovery_in_progress_.store(false); this->sw_control_needs_upgrade_to_5 = false; this->prep_applied = 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_power_check = false; this->device_online = true; this->consecutive_timeouts = 0; 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() { /*-----------------------------------------------------*\ | Waits, retries and backoff sleeps check this and | | return rather than run their course, so teardown | | takes milliseconds. | \*-----------------------------------------------------*/ teardown_pending.store(true); /*-----------------------------------------------------*\ | Deregister from the node watcher first, under its | | lock: after this returns no nudge can reach this | | controller. | \*-----------------------------------------------------*/ LogitechHIDPP20ReceiverWatcher::UnregisterSubDevice(this); if(initialized) { Shutdown(); } /*-----------------------------------------------------*\ | Event-watcher controllers run reader/power | | threads without ever initializing; | | Shutdown() won't have stopped them. | | Idempotent, so safe to call unconditionally. | \*-----------------------------------------------------*/ StopSenderThread(); StopPowerManager(); if(dev) { hid_close(dev); } if(dev_perkey_vl && dev_perkey_vl != dev) { hid_close(dev_perkey_vl); } } /*---------------------------------------------------------*\ | Transport-layer I/O | | | | SendMessage/ReadMessage dispatch to the appropriate | | transport implementation based on transport.type. | | SendAndReceive is a convenience wrapper. | \*---------------------------------------------------------*/ int LogitechHIDPP20Controller::SendMessage ( uint8_t feat_idx, uint8_t function, const uint8_t* data, size_t len ) { switch(transport.type) { case HIDPP20_TRANSPORT_CENTURION: return SendCenturion(feat_idx, function, data, len); case HIDPP20_TRANSPORT_STANDARD: default: return SendStandard(feat_idx, function, data, len); } } int LogitechHIDPP20Controller::ReadMessage ( uint8_t* feat_idx_out, uint8_t* function_out, uint8_t* data_out, size_t data_max, int timeout_ms ) { /*-----------------------------------------------------*\ | When the reader thread is running, it is the sole | | caller of hid_read_timeout. All other reads come from | | the queue. Before the reader starts (during | | Probe/Initialize), read directly from HID. | \*-----------------------------------------------------*/ if(reader_running.load()) { return ReadFromQueue(feat_idx_out, function_out, data_out, data_max, timeout_ms); } return ReadHIDDirect(feat_idx_out, function_out, data_out, data_max, timeout_ms); } int LogitechHIDPP20Controller::ReadHIDDirect ( uint8_t* feat_idx_out, uint8_t* function_out, uint8_t* data_out, size_t data_max, int timeout_ms ) { switch(transport.type) { case HIDPP20_TRANSPORT_CENTURION: return ReadCenturionDirect(feat_idx_out, function_out, data_out, data_max, timeout_ms); case HIDPP20_TRANSPORT_STANDARD: default: return ReadStandardDirect(feat_idx_out, function_out, data_out, data_max, timeout_ms); } } int LogitechHIDPP20Controller::ReadFromQueue ( uint8_t* feat_idx_out, uint8_t* function_out, uint8_t* data_out, size_t data_max, int timeout_ms ) { std::unique_lock lock(response_mutex); std::chrono::steady_clock::time_point deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); while(response_queue.empty()) { if(response_cv.wait_until(lock, deadline) == std::cv_status::timeout) { /*---------------------------------------------*\ | Offline detection lives at the | | SendAcked layer now: one tick per | | fully-failed call, not per per-attempt | | read window. Streaming policies that | | retry several times don't artificially | | accelerate the offline declaration. | \*---------------------------------------------*/ return 0; } if(!reader_running.load()) { return 0; } /*-------------------------------------------------*\ | Teardown notifies this cv so blocked readers | | return immediately instead of waiting out their | | read windows on a device being torn down. | \*-------------------------------------------------*/ if(teardown_pending.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; } /*---------------------------------------------------------*\ | An answer to a send that had already timed out arrives | | after the retry has been answered. Nothing in an IRoot | | reply says which feature it was asked about, so one left | | in the pipe becomes the next request's answer and the | | feature map takes a wrong index. Read them off before the | | next command goes out. | \*---------------------------------------------------------*/ void LogitechHIDPP20Controller::DrainLateAnswers(uint8_t feat_idx, uint8_t function, int expected) { std::chrono::steady_clock::time_point deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(HIDPP20_LATE_ANSWER_GRACE_MS); int drained = 0; while(drained < expected) { std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); if(now >= deadline) { break; } int remaining = (int)std::chrono::duration_cast( deadline - now).count(); 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) { break; } if(resp_feat == feat_idx && (resp_func & 0xF0) == (function & 0xF0) && (resp_func & 0x0F) == HIDPP20_SW_ID) { drained++; } } if(drained > 0) { LOG_DEBUG("%s Discarded %d late answer(s) for feat=0x%02X func=0x%02X", LOG_TAG, drained, feat_idx, function); } } /*---------------------------------------------------------*\ | Sleep delay_ms in slices, waking early when the link is | | about to change or the device went offline. Returns false | | when interrupted. | \*---------------------------------------------------------*/ bool LogitechHIDPP20Controller::InterruptibleBackoff(uint16_t delay_ms) { std::chrono::steady_clock::time_point deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(delay_ms); while(true) { if(teardown_pending.load() || !device_online.load()) { return false; } std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); if(now >= deadline) { return true; } int remaining = (int)std::chrono::duration_cast( deadline - now).count(); std::this_thread::sleep_for( std::chrono::milliseconds(remaining < 50 ? remaining : 50)); } } 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 ) { /*-----------------------------------------------------*\ | Send-and-ack with policy-driven retry: 7-attempt | | exponential backoff for reliable one-shot | | commands, tight 2-attempt for streaming frames. | | Per attempt: sleep backoff_ms[i]; bail if | | offline; send (wire errors retry); read within | | read_window_ms: | | matching response -> success | | our error: BUSY and retry_on_busy -> resend | | any other code -> fail (-1) | | foreign error / non-matching frame -> discard | | read timeout -> resend | | | | The flush below empties the shared response queue, | | so this call must be the only transaction on the | | wire while it runs. See TransactionMutex(). | \*-----------------------------------------------------*/ std::lock_guard transaction_guard(transaction_mutex); if(hidpp20_error_out) { *hidpp20_error_out = 0; } if(policy.flush_before) { FlushResponseQueue(); } int last_result = 0; uint8_t last_error = 0; bool long_latch_retry = false; for(int attempt = 0; attempt < (int)policy.attempts; attempt++) { /*-------------------------------------------------*\ | Backoff before each attempt (0 on first). | | Sliced so teardown interrupts the wait, the | | checks below then end the call. | \*-------------------------------------------------*/ uint16_t delay_ms = policy.backoff_ms[attempt]; if(delay_ms > 0) { InterruptibleBackoff(delay_ms); } /*-------------------------------------------------*\ | Bail early if device went offline mid-retry | \*-------------------------------------------------*/ if(!device_online.load()) { return 0; } /*-------------------------------------------------*\ | Bail if teardown started: reliable-policy | | retries against a device being torn down delay | | shutdown by seconds. | \*-------------------------------------------------*/ if(teardown_pending.load()) { LOG_TRACE("%s SendAcked[%s] abandoned, link change pending", LOG_TAG, policy.name); return 0; } const bool sent_short = (transport.type == HIDPP20_TRANSPORT_STANDARD) && PrefersShortFrame(send_len); 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) { if(teardown_pending.load()) { LOG_TRACE("%s SendAcked[%s] abandoned, link change pending", LOG_TAG, policy.name); return 0; } std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); if(now >= window_deadline) { LOG_TRACE("%s SendAcked[%s] window expired (attempt %d)", LOG_TAG, policy.name, attempt); last_result = 0; break; } int remaining = (int)std::chrono::duration_cast( window_deadline - now).count(); if(remaining <= 0) { last_result = 0; break; } uint8_t resp_feat = 0; uint8_t resp_func = 0; uint8_t resp_data[60] = {}; int rd = ReadMessage(&resp_feat, &resp_func, resp_data, sizeof(resp_data), remaining); if(rd < 0) { /* Wire error, propagate, don't retry */ return -2; } if(rd == 0) { /* Window drained with nothing matching, retry the send */ last_result = 0; break; } /*---------------------------------------------*\ | HID++ error frame | | feat=0xFF, func=err_feat, data[0]=err_func, | | data[1]=err_code | \*---------------------------------------------*/ if(resp_feat == 0xFF) { uint8_t err_feat = resp_func; uint8_t err_func = resp_data[0]; uint8_t err_code = resp_data[1]; /*-----------------------------------------*\ | Match: either a direct error for our | | request, or a Centurion bridge error | | attributed to the bridge feature index | | when we're routing through it. The bridge | | swallows the sub-device feat in the error | | response, so all bridge-routed failures | | look like errors from the bridge. | \*-----------------------------------------*/ bool is_our_error = (err_feat == feat_idx && (err_func & 0xF0) == (function & 0xF0) && (err_func & 0x0F) == HIDPP20_SW_ID) || (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); /*-------------------------------------*\ | Kept for the cache self-heal: | | 0x06/0x07 is a stale restored index. | \*-------------------------------------*/ last_fap_error_ = err_code; if(hidpp20_error_out) { *hidpp20_error_out = err_code; } return -1; } /* Error for a different request, stale, discard and keep reading */ continue; } /*---------------------------------------------*\ | Match the software id too; it is the | | only thing tying a HID++ reply to its | | requester. Without it, replies to any | | other program on this device (Solaar | | cycles 0x02..0x0F) are accepted as | | ours: same feature, same function. | \*---------------------------------------------*/ if(resp_feat == feat_idx && (resp_func & 0xF0) == (function & 0xF0) && (resp_func & 0x0F) == HIDPP20_SW_ID) { 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); DrainLateAnswers(feat_idx, function, attempt); } consecutive_timeouts.store(0); return rd; } /* Non-matching, non-error: stale unrelated frame, keep reading */ } /*-------------------------------------------------*\ | A collection with no short report answers nothing | | rather than rejecting the write: Linux hidraw | | takes the 0x10 frame and drops it. Silence to a | | short frame is the same evidence as a rejected | | write, so latch long and let the next attempt | | resend. | \*-------------------------------------------------*/ if(!need_resend && sent_short && !long_only.load()) { LOG_DEBUG("%s Short report went unanswered, using long frames", LOG_TAG); long_only.store(true); /*---------------------------------------------*\ | The frame the device could not receive says | | nothing about whether it answers, so repeat | | this attempt as long rather than spend one on | | the discovery. Once per call. | \*---------------------------------------------*/ if(!long_latch_retry) { long_latch_retry = true; attempt--; } } } 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(); /*-----------------------------------------------------*\ | Pipelined claim: fire the write, record it, don't | | block. VerifyClaimPipeline reconciles and re-sends | | any miss; claim commands ignore their response. | \*-----------------------------------------------------*/ if(pipelining_claim_) { int wr = SendMessage(feat_idx, function, send_data, send_len); if(wr > 0 && claim_pipeline_.size() < 16) { HIDPP20PendingClaimCmd cmd; cmd.feat = feat_idx; cmd.func = function; cmd.len = (send_len > sizeof(cmd.data)) ? sizeof(cmd.data) : send_len; if(send_data != nullptr && cmd.len > 0) { memcpy(cmd.data, send_data, cmd.len); } claim_pipeline_.push_back(cmd); } return wr; } 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) | \*---------------------------------------------------------*/ /*---------------------------------------------------------*\ | Budget for the first exchange with a node: wider for a | | device a receiver already named and for a Bluetooth link, | | whose connection interval puts the first answer hundreds | | of ms out. Everything else fails fast. | \*---------------------------------------------------------*/ const HIDPP20RetryPolicy& LogitechHIDPP20Controller::FirstContactPolicy() const { if(transport.bluetooth) { return HIDPP20_POLICY_BLUETOOTH; } return wireless ? HIDPP20_POLICY_FIRST_CONTACT : HIDPP20_POLICY_PROBE; } /*---------------------------------------------------------*\ | Frame choice for standard HID++: short (0x10) carries 3 | | payload bytes, long (0x11) carries 16. Windows opens the | | long-message collection only, and long_only latches a | | collection that has no short report at all. | \*---------------------------------------------------------*/ bool LogitechHIDPP20Controller::PrefersShortFrame(size_t len) const { #if defined(_WIN32) (void)len; return false; #else return (len <= 3) && !long_only.load(); #endif } 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; const bool prefer_short = PrefersShortFrame(len); if(prefer_short) { memset(buf, 0, LOGITECH_SHORT_MESSAGE_LEN); buf[0] = LOGITECH_SHORT_MESSAGE; buf[1] = device_index; buf[2] = feat_idx; buf[3] = function | HIDPP20_SW_ID; if(data && len > 0) { memcpy(buf + 4, data, len); } msg_len = LOGITECH_SHORT_MESSAGE_LEN; } else { memset(buf, 0, LOGITECH_LONG_MESSAGE_LEN); buf[0] = LOGITECH_LONG_MESSAGE; buf[1] = device_index; buf[2] = feat_idx; buf[3] = function | HIDPP20_SW_ID; if(data && len > 0) { size_t copy_len = (len > 16) ? 16 : len; memcpy(buf + 4, data, copy_len); } msg_len = LOGITECH_LONG_MESSAGE_LEN; } int result; if(mutex) { std::lock_guard lock(*mutex); result = hid_write(dev, buf, msg_len); } else { result = hid_write(dev, buf, msg_len); } /*-----------------------------------------------------*\ | A collection with no short report rejects the 0x10 | | write (G560, G933). Resend as long and stay long. | \*-----------------------------------------------------*/ if(result < 0 && msg_len == LOGITECH_SHORT_MESSAGE_LEN) { 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) { memcpy(buf + 4, data, len); } if(mutex) { std::lock_guard lock(*mutex); result = hid_write(dev, buf, LOGITECH_LONG_MESSAGE_LEN); } else { result = hid_write(dev, buf, LOGITECH_LONG_MESSAGE_LEN); } if(result >= 0) { LOG_DEBUG("%s Short report rejected, using long frames", LOG_TAG); long_only.store(true); } } return result; } /*---------------------------------------------------------*\ | Feature 0x8080 very-long (report 0x12) frame: fn3 | | SetKeyColors rides a 64-byte report that Windows HIDClass | | splits onto a second Top-Level-Collection (usage 0x0604); | | that handle is dev_perkey_vl; everything else stays on | | dev. Layout matches the long report, just wider. | | Fire-and-forget: the ACK lands on dev. | \*---------------------------------------------------------*/ int LogitechHIDPP20Controller::SendVeryLongFrame ( uint8_t feat_idx, uint8_t function, const uint8_t* data, size_t len ) { const size_t max_payload = LOGITECH_VERY_LONG_MESSAGE_LEN - 4; if(dev_perkey_vl == nullptr) { return -1; } uint8_t buf[LOGITECH_VERY_LONG_MESSAGE_LEN]; memset(buf, 0, sizeof(buf)); buf[0] = LOGITECH_VERY_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 > max_payload) ? max_payload : len; memcpy(buf + 4, data, copy_len); } int result; if(mutex) { std::lock_guard lock(*mutex); result = hid_write(dev_perkey_vl, buf, sizeof(buf)); } else { result = hid_write(dev_perkey_vl, buf, sizeof(buf)); } if(result < 0) { LOG_DEBUG("%s 0x8080 very-long frame write failed (result=%d) " "feat=0x%02X func=0x%02X", LOG_TAG, result, feat_idx, function); } return result; } int LogitechHIDPP20Controller::ReadStandardDirect ( uint8_t* feat_idx_out, uint8_t* function_out, uint8_t* data_out, size_t data_max, int timeout_ms ) { /*-----------------------------------------------------*\ | No mutex needed for reads, when the reader thread | | is running, it is the sole caller. Before the | | reader starts, all access is single-threaded. | | | | Loop within the timeout window draining non- | | HID++ reports (mouse motion, keystrokes, media | | keys, DJ events) until we either find a HID++ | | short/long frame or actually time out. A high- | | polling-rate device can put 50+ input reports | | in the hidraw buffer between our calls; without | | the drain loop the synchronous probe path can | | never get past them to find its response. | \*-----------------------------------------------------*/ std::chrono::steady_clock::time_point deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); int drained = 0; while(true) { std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); if(now >= deadline) { return 0; } int remaining_ms = (int)std::chrono::duration_cast( deadline - now).count(); if(remaining_ms <= 0) { return 0; } blankFAPmessage response; response.init(); int result = hid_read_timeout(dev, response.buffer, response.size(), remaining_ms); if(result < 0) { /* Real wire error (e.g. device removed). */ return result; } if(result == 0) { /* hidapi timeout, window expired with nothing pending. */ return 0; } /*-------------------------------------------------*\ | The hidraw also carries HID input reports. | | Drop anything that is not a HID++ short (0x10), | | long (0x11) or very-long (0x12) frame, or | | keystrokes/motion get parsed as HID++ events. | | Very-long matters: 0x8080 fn2 GetKeyColors | | answers a long request with a 64-byte 0x12 | | response (14 entries do not fit a long frame). | | | | Drop frames for a different device index: | | receiver slots share one node, so sibling | | replies and the receiver's own notifications | | (index 0xFF) land here too, and SendAcked | | matches only feature+function, so a foreign | | frame would be accepted as our reply. | \*-------------------------------------------------*/ if(response.buffer[0] == LOGITECH_SHORT_MESSAGE || response.buffer[0] == LOGITECH_LONG_MESSAGE || response.buffer[0] == LOGITECH_VERY_LONG_MESSAGE) { if(response.device_index != device_index) { if(++drained > HIDPP20_READ_DRAIN_BUDGET) { LOG_DEBUG("%s ReadStandardDirect: drain budget (%d) exceeded", LOG_TAG, HIDPP20_READ_DRAIN_BUDGET); return 0; } continue; } } if(response.buffer[0] != LOGITECH_SHORT_MESSAGE && response.buffer[0] != LOGITECH_LONG_MESSAGE && response.buffer[0] != LOGITECH_VERY_LONG_MESSAGE) { if(++drained > HIDPP20_READ_DRAIN_BUDGET) { LOG_DEBUG("%s ReadStandardDirect: drain budget (%d) exceeded", LOG_TAG, HIDPP20_READ_DRAIN_BUDGET); return 0; } continue; } if(feat_idx_out) { *feat_idx_out = response.feature_index; } if(function_out) { *function_out = response.feature_command; } if(data_out && data_max > 0) { size_t copy_len = (data_max > sizeof(response.data)) ? sizeof(response.data) : data_max; memcpy(data_out, response.data, copy_len); } return result; } } /*---------------------------------------------------------*\ | Centurion transport (0xFFA0) | | | | Wire format per protocol doc: | | 0x51 (direct): [reportId] [cplLen] [flags] [featIdx] | | [func|swid] [params...] | | 0x50 (addressed): [reportId] [devAddr] [cplLen] [flags] | | [featIdx] [func|swid] [params...] | | | | For sub-device access, the parent CentPPBridge wraps | | sub-device messages: | | params = [devId<<4|lenHi, lenLo, subCPL, subFeatIdx, | | subFunc|swid, subParams...] | | | | Selects direct (0x50/0x51) or bridge-wrapped framing | | based on transport; routes sub-devices via CentPPBridge. | \*---------------------------------------------------------*/ int LogitechHIDPP20Controller::SendCenturion ( uint8_t feat_idx, uint8_t function, const uint8_t* data, size_t len ) { uint8_t buf[64]; memset(buf, 0, sizeof(buf)); if(transport.bridge_feat_idx != 0) { /*-------------------------------------------------*\ | Sub-device message routed through | | CentPPBridge Parent message: feat=bridge, | | func=sendFragment(0x10) Payload: | | [devId<<4|lenHi, lenLo, subCPL=0x00, | | subFeatIdx, subFunc|swid, subParams...] | \*-------------------------------------------------*/ uint16_t sub_msg_len = 3 + (uint16_t)len; // subCPL + featIdx + func + data if(transport.addressed) { buf[0] = transport.report_id; buf[1] = transport.device_address; buf[2] = 5 + sub_msg_len; // cplLen buf[3] = 0x00; // flags (single fragment) buf[4] = transport.bridge_feat_idx; buf[5] = 0x10 | HIDPP20_SW_ID; // sendFragment (func 1) buf[6] = (transport.sub_device_id << 4) | ((sub_msg_len >> 8) & 0x0F); buf[7] = sub_msg_len & 0xFF; buf[8] = 0x00; // sub-CPL (single fragment) buf[9] = feat_idx; buf[10] = function | HIDPP20_SW_ID; if(data && len > 0) { memcpy(buf + 11, data, len); } } else { buf[0] = transport.report_id; buf[1] = 5 + sub_msg_len; // cplLen: flags(1) + feat(1) + func(1) + hdr(2) + sub buf[2] = 0x00; // flags buf[3] = transport.bridge_feat_idx; buf[4] = 0x10 | HIDPP20_SW_ID; // sendFragment (func 1) buf[5] = (transport.sub_device_id << 4) | ((sub_msg_len >> 8) & 0x0F); buf[6] = sub_msg_len & 0xFF; buf[7] = 0x00; // sub-CPL buf[8] = feat_idx; buf[9] = function | HIDPP20_SW_ID; if(data && len > 0) { memcpy(buf + 10, data, len); } } } else { /*-------------------------------------------------*\ | Direct parent device message (no bridge) | \*-------------------------------------------------*/ if(transport.addressed) { buf[0] = transport.report_id; buf[1] = transport.device_address; buf[2] = 3 + (uint8_t)len; // cplLen buf[3] = 0x00; // flags buf[4] = feat_idx; buf[5] = function | HIDPP20_SW_ID; if(data && len > 0) { memcpy(buf + 6, data, len); } } else { buf[0] = transport.report_id; buf[1] = 3 + (uint8_t)len; // cplLen: flags(1) + feat(1) + func(1) + data buf[2] = 0x00; // flags buf[3] = feat_idx; buf[4] = function | HIDPP20_SW_ID; if(data && len > 0) { memcpy(buf + 5, data, len); } } } int result; if(mutex) { std::lock_guard lock(*mutex); result = hid_write(dev, buf, 64); } else { result = hid_write(dev, buf, 64); } return result; } int LogitechHIDPP20Controller::ReadCenturionDirect ( uint8_t* feat_idx_out, uint8_t* function_out, uint8_t* data_out, size_t data_max, int timeout_ms ) { uint8_t buf[64]; memset(buf, 0, sizeof(buf)); /*-----------------------------------------------------*\ | Track an overall deadline so the bridge ACK + | | MessageEvent two-read sequence stays within | | timeout_ms total, without this each read could eat | | the full budget independently. Drain non-Centurion | | report IDs within the remaining window rather than | | bailing on the first non-matching frame. | \*-----------------------------------------------------*/ std::chrono::steady_clock::time_point deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); int drained = 0; int result = 0; while(true) { std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); if(now >= deadline) { return 0; } int remaining_ms = (int)std::chrono::duration_cast( deadline - now).count(); if(remaining_ms <= 0) { return 0; } result = hid_read_timeout(dev, buf, sizeof(buf), remaining_ms); if(result < 0) { return result; } if(result == 0) { return 0; } if(buf[0] == transport.report_id) { break; } if(++drained > HIDPP20_READ_DRAIN_BUDGET) { LOG_DEBUG("%s ReadCenturionDirect: drain budget (%d) exceeded", LOG_TAG, HIDPP20_READ_DRAIN_BUDGET); return 0; } } /*-----------------------------------------------------*\ | Parse based on transport variant | \*-----------------------------------------------------*/ int hdr_offset = transport.addressed ? 1 : 0; // skip device address byte uint8_t cpl_len = buf[1 + hdr_offset]; // uint8_t cpl_flags = buf[2 + hdr_offset]; // for fragmentation support uint8_t resp_feat = buf[3 + hdr_offset]; uint8_t resp_func = buf[4 + hdr_offset]; if(transport.bridge_feat_idx != 0 && resp_feat == transport.bridge_feat_idx) { /*-------------------------------------------------*\ | CentPPBridge: distinguish events from 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 detects them. | | | | Command responses use a two-response pattern: | | 1. ACK: bridge echoes feat+func with our swid | | 2. MessageEvent: func=1x, swid=0, wrapped | | sub-device response | \*-------------------------------------------------*/ if((resp_func & 0xF0) == 0x00 && (resp_func & 0x0F) != HIDPP20_SW_ID) { /*---------------------------------------------*\ | Bridge event, return feat/func/data as-is | \*---------------------------------------------*/ if(feat_idx_out) *feat_idx_out = resp_feat; if(function_out) *function_out = resp_func; if(data_out && data_max > 0) { size_t avail = (size_t)(cpl_len > 2 ? cpl_len - 2 : 0); size_t copy = (avail < data_max) ? avail : data_max; memcpy(data_out, buf + 5 + hdr_offset, copy); } return result; } if((resp_func & 0x0F) == HIDPP20_SW_ID) { /*---------------------------------------------*\ | This is the ACK, discard and read the | | MessageEvent Use the *remaining* window | | from the overall deadline so the two- | | read sequence stays bounded, and drain | | non-Centurion frames within that window. | \*---------------------------------------------*/ while(true) { std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); if(now >= deadline) { return 0; } int remaining_ms = (int)std::chrono::duration_cast( deadline - now).count(); if(remaining_ms <= 0) { return 0; } memset(buf, 0, sizeof(buf)); result = hid_read_timeout(dev, buf, sizeof(buf), remaining_ms); if(result < 0) { return result; } if(result == 0) { return 0; } if(buf[0] == transport.report_id) { break; } if(++drained > HIDPP20_READ_DRAIN_BUDGET) { LOG_DEBUG("%s ReadCenturionDirect: drain budget (%d) exceeded on bridge MessageEvent", LOG_TAG, HIDPP20_READ_DRAIN_BUDGET); return 0; } } resp_feat = buf[3 + hdr_offset]; resp_func = buf[4 + hdr_offset]; if(resp_feat != transport.bridge_feat_idx) { /*-----------------------------------------*\ | Not a bridge response, return as-is | \*-----------------------------------------*/ if(feat_idx_out) *feat_idx_out = resp_feat; if(function_out) *function_out = resp_func; if(data_out && data_max > 0) { size_t avail = (size_t)(buf[1 + hdr_offset] > 2 ? buf[1 + hdr_offset] - 2 : 0); size_t copy = (avail < data_max) ? avail : data_max; memcpy(data_out, buf + 5 + hdr_offset, copy); } return result; } } /*-------------------------------------------------*\ | MessageEvent: unwrap sub-device response. | | Bridge params: [devId<<4|lenHi, lenLo, subCPL, | | subFeatIdx, subFunc|swid, subData...] | \*-------------------------------------------------*/ int sub_offset = 5 + hdr_offset + 3; // past bridge header resp_feat = buf[sub_offset]; resp_func = buf[sub_offset + 1]; if(feat_idx_out) *feat_idx_out = resp_feat; if(function_out) *function_out = resp_func; if(data_out && data_max > 0) { size_t avail = (size_t)(result - sub_offset - 2); size_t copy = (avail < data_max) ? avail : data_max; memcpy(data_out, buf + sub_offset + 2, copy); } } else { /*-------------------------------------------------*\ | Direct response | \*-------------------------------------------------*/ if(feat_idx_out) *feat_idx_out = resp_feat; if(function_out) *function_out = resp_func; if(data_out && data_max > 0) { size_t avail = (size_t)(cpl_len > 2 ? cpl_len - 2 : 0); size_t copy = (avail < data_max) ? avail : data_max; memcpy(data_out, buf + 5 + hdr_offset, copy); } } (void)cpl_len; return result; } /*---------------------------------------------------------*\ | Feature Discovery | \*---------------------------------------------------------*/ uint8_t LogitechHIDPP20Controller::GetFeatureIndex(uint16_t feature_page, const HIDPP20RetryPolicy& policy) { /*-----------------------------------------------------*\ | Check cache first, both Centurion bulk and | | HID++ on-demand lookups store results here. | \*-----------------------------------------------------*/ std::map::const_iterator it = caps.feature_map.find(feature_page); if(it != caps.feature_map.end()) { return it->second; } /*-----------------------------------------------------*\ | Centurion bulk enumeration is complete, so a | | feature not in the map does not exist. No wire | | query is needed. | \*-----------------------------------------------------*/ if(caps.feature_map_complete) { return 0; } /*-----------------------------------------------------*\ | Standard HID++: on-demand IRoot query, result cached. | \*-----------------------------------------------------*/ 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 { /*---------------------------------------------*\ | The device answered: absent is an answer, | | cache it. | \*---------------------------------------------*/ caps.feature_map[feature_page] = 0; LOG_DEBUG("%s Feature 0x%04X not present", LOG_TAG, feature_page); } return index; } /*-----------------------------------------------------*\ | No answer describes the link, not the feature: do not | | cache it, or retries would answer from the map. | \*-----------------------------------------------------*/ LOG_DEBUG("%s Feature 0x%04X did not answer", LOG_TAG, feature_page); return 0; } /*---------------------------------------------------------*\ | Return the protocol version byte for a feature, or 0 if | | the feature isn't present in this device's feature set. | | Populated alongside feature_map during EnumerateFeatures | | (Centurion bulk) or GetFeatureIndex (standard HID++ | | on-demand IRoot.GetFeature). | \*---------------------------------------------------------*/ uint8_t LogitechHIDPP20Controller::GetFeatureVersion(uint16_t feature_page) const { std::map::const_iterator it = caps.feature_versions.find(feature_page); if(it != caps.feature_versions.end()) { return it->second; } return 0; } /*---------------------------------------------------------*\ | Feature/version summary in `solaar show` style, scoped | | to the features this controller uses, so firmware that | | moved or re-versioned one can be identified from the | | log alone. GetFeatureIndex also logs a tripwire for | | versions not seen before. Most entries are already | | cached; this adds at most a few IRoot queries. | \*---------------------------------------------------------*/ void LogitechHIDPP20Controller::LogFeatureSummary() { static const struct { uint16_t page; const char* name; } relevant_features[] = { { HIDPP20_FEAT_FEATURE_SET, "FEATURE_SET" }, { HIDPP20_FEAT_FIRMWARE_INFO, "FIRMWARE_INFO/BRIDGE" }, { HIDPP20_FEAT_DISABLE_KEYS_BY_USAGE, "DISABLE_KEYS_BY_USAGE" }, { HIDPP20_FEAT_COLOR_LED_EFFECTS, "COLOR_LED_EFFECTS" }, { HIDPP20_FEAT_RGB_EFFECTS, "RGB_EFFECTS" }, { HIDPP20_FEAT_PER_KEY_LIGHTING_V1, "PER_KEY_LIGHTING_V1" }, { HIDPP20_FEAT_PER_KEY_LIGHTING_V2, "PER_KEY_LIGHTING_V2" }, { HIDPP20_FEAT_KEYBOARD_LAYOUT, "KEYBOARD_LAYOUT" }, { HIDPP20_FEAT_ONBOARD_PROFILES, "ONBOARD_PROFILES" }, { HIDPP20_FEAT_PROFILE_MANAGEMENT, "PROFILE_MANAGEMENT" }, { HIDPP20_FEAT_CENTURION_RGB, "CENTURION_RGB_0x0600" }, { HIDPP20_FEAT_HEADSET_RGB_HOSTMODE, "HEADSET_RGB_HOSTMODE" }, { HIDPP20_FEAT_WIRELESS_STATUS, "WIRELESS_STATUS" }, }; LOG_DEBUG("%s ===== HID++ 2.0 feature summary (features OpenRGB uses) =====", LOG_TAG); for(size_t i = 0; i < sizeof(relevant_features) / sizeof(relevant_features[0]); i++) { uint16_t page = relevant_features[i].page; uint8_t idx = GetFeatureIndex(page); if(idx != 0) { LOG_DEBUG("%s feature 0x%04X %-22s present idx=0x%02X V%u", LOG_TAG, page, relevant_features[i].name, idx, GetFeatureVersion(page)); } else { LOG_DEBUG("%s feature 0x%04X %-22s absent", LOG_TAG, page, relevant_features[i].name); } } LOG_DEBUG("%s ============================================================", LOG_TAG); } /*---------------------------------------------------------*\ | Read a Centurion sub-device's name via DeviceName | | 0x0101 (bridge-routed). Handles both response | | shapes Solaar does: inline [len, name...] in the | | fn0 reply, or chunked [len] then fn1 fragments. | | Returns false if 0x0101 is absent or yields | | nothing. Mirrors Solaar get_name_centurion. | \*---------------------------------------------------------*/ bool LogitechHIDPP20Controller::ReadCenturionDeviceName0101(std::string& out) { out.clear(); uint8_t idx = GetFeatureIndex(HIDPP20_FEAT_CENTURION_DEVICE_NAME); if(idx == 0) { return false; } /*-----------------------------------------------------*\ | fn0 getName -> recv[0] = name length (+ inline bytes | | on devices that return the whole name in one reply). | \*-----------------------------------------------------*/ uint8_t recv[64] = {}; int rd = SendAcked(idx, 0x00, nullptr, 0, recv, sizeof(recv)); if(rd <= 0) { return false; } unsigned int name_length = recv[0]; LOG_DEBUG("%s 0x0101 getName: len=%u rd=%d raw=[%02X %02X %02X %02X %02X %02X %02X %02X]", LOG_TAG, name_length, rd, recv[0], recv[1], recv[2], recv[3], recv[4], recv[5], recv[6], recv[7]); if(name_length == 0 || name_length > 64) { return false; } /*-----------------------------------------------------*\ | Use the fn0 reply inline when it already carries | | the whole name (length covered and the byte after | | it printable; rejects a chunked fn0 that returned | | only length + zeros); else fetch in chunks via fn1. | \*-----------------------------------------------------*/ if((unsigned int)rd >= 1u + name_length && name_length <= sizeof(recv) - 1 && recv[1] >= 0x20 && recv[1] <= 0x7E) { out.assign((char*)recv + 1, name_length); } else { for(unsigned int offset = 0; offset < name_length; offset += 16) { uint8_t send_data[1] = { (uint8_t)offset }; uint8_t chunk[20] = {}; int cr = SendAcked(idx, 0x10, send_data, 1, chunk, sizeof(chunk)); if(cr <= 0) { break; } unsigned int chunk_len = name_length - offset; if(chunk_len > 16) { chunk_len = 16; } out.append((char*)chunk, chunk_len); } } while(!out.empty() && out.back() == '\0') { out.pop_back(); } return !out.empty(); } void LogitechHIDPP20Controller::DiscoverDeviceName() { /*-----------------------------------------------------*\ | Centurion sub-devices use 0x0101 (DeviceName). | | Standard HID++ uses 0x0005 (DeviceNameType). | \*-----------------------------------------------------*/ if(transport.type == HIDPP20_TRANSPORT_CENTURION) { /*-------------------------------------------------*\ | Try the HID++ name (0x0101) first so a | | spec-compliant sub-device self-names; | | fall back to the OS/USB name for hardware | | that doesn't return a usable name there. | \*-------------------------------------------------*/ std::string hidpp_name; if(ReadCenturionDeviceName0101(hidpp_name) && LogitechHIDPP20Controller::NameLooksReal(hidpp_name)) { caps.device_name = hidpp_name; } else { std::string friendly = GetCenturionSubDeviceName(location); caps.device_name = friendly.empty() ? HIDPP20_NAME_PLACEHOLDER_CENTURION : friendly; } 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) { /*-------------------------------------------------*\ | No name feature, or the lookup for it failed. | | Either way the receiver has already told us what | | this device is called, use that before falling | | back to a name that tells the user nothing. | \*-------------------------------------------------*/ caps.device_name = LogitechHIDPP20Controller::NameLooksReal(pairing_name) ? pairing_name : HIDPP20_NAME_PLACEHOLDER_STD; LOG_WARNING("%s DeviceNameType (0x0005) not available, using '%s'", LOG_TAG, caps.device_name.c_str()); return; } /*-----------------------------------------------------*\ | Rescan churn (notification bursts from the previous | | session's teardown) can garble or time out the first | | reads, so validate the length and retry the whole | | discovery. Never leave the name blank, a failed read | | falls back to the placeholder. | \*-----------------------------------------------------*/ for(int attempt = 0; attempt < 3; attempt++) { uint8_t recv[16] = {}; int result = SendAcked(feat_idx, LOTITECH_CMD_DEVICE_NAME_TYPE_GET_COUNT, nullptr, 0, recv, sizeof(recv)); if(result <= 0) { continue; } unsigned int name_length = recv[0]; if(name_length == 0 || name_length > 64) { LOG_DEBUG("%s GetCount returned implausible name length %u (attempt %d)", LOG_TAG, name_length, attempt); continue; } std::string name; bool complete = true; 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) { complete = false; break; } unsigned int chunk_len = name_length - offset; if(chunk_len > 16) { chunk_len = 16; } name.append((char*)recv, chunk_len); } while(!name.empty() && name.back() == '\0') { name.pop_back(); } /*-------------------------------------------------*\ | A garbled read can return the right | | length but junk bytes. Require printable | | ASCII, otherwise the device registers | | under an unprintable name that reads as | | blank and never matches a saved profile. | \*-------------------------------------------------*/ if(!complete || !LogitechHIDPP20Controller::NameLooksReal(name)) { LOG_DEBUG("%s Device name read attempt %d unusable (len=%zu), retrying", LOG_TAG, attempt, name.size()); continue; } caps.device_name = name; LOG_VERBOSE("%s Device name: %s", LOG_TAG, caps.device_name.c_str()); return; } /*-----------------------------------------------------*\ | Use the receiver-stored pairing codename, not the | | generic placeholder, when the FAP name read fails. | \*-----------------------------------------------------*/ caps.device_name = LogitechHIDPP20Controller::NameLooksReal(pairing_name) ? pairing_name : HIDPP20_NAME_PLACEHOLDER_STD; LOG_WARNING("%s Device name discovery failed after retries, using '%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; } /*-----------------------------------------------------*\ | Retry like the name read: a timed-out type read under | | rescan churn would register a keyboard with a mouse | | layout. | \*-----------------------------------------------------*/ for(int attempt = 0; attempt < 3; attempt++) { 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); return; } } caps.device_type = LOGITECH_DEVICE_TYPE_MOUSE; LOG_WARNING("%s Device type discovery failed after retries, assuming mouse", LOG_TAG); } 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. The first address to respond is taken, and the | | real address is 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 | | GetCount (fn0) for the total, then GetFeatureId | | (fn1), which answers [remaining, (feat_hi, | | feat_lo, type, version) x N] listing from the | | requested index. | \*-------------------------------------------------*/ uint8_t count_resp[16] = {}; int result = SendAcked(feature_set_idx, FN_0001_GET_COUNT, nullptr, 0, count_resp, sizeof(count_resp)); if(result <= 0) { return; } uint8_t count = count_resp[0]; unsigned int resolved = 0; unsigned int misses = 0; uint8_t next = 0; LOG_DEBUG("%s CenturionFeatureSet: %u features", LOG_TAG, count); while(next < count) { uint8_t send_idx = next; uint8_t recv_data[60] = {}; /*---------------------------------------------*\ | Probe policy: the device has just answered | | GetCount, so a batch that goes quiet means it | | left. Reliable retries per batch would stall | | detection for minutes. | \*---------------------------------------------*/ int batch_result = SendAcked(feature_set_idx, FN_0001_GET_FEATURE_ID, &send_idx, 1, recv_data, sizeof(recv_data), HIDPP20_POLICY_PROBE); uint8_t parsed = 0; if(batch_result > 0) { uint8_t in_frame = CENTURION_FEATURES_PER_FRAME; if(recv_data[0] < in_frame) { in_frame = recv_data[0]; } for(uint8_t j = 0; j < in_frame && (next + j) < count; j++) { int offset = 1 + j * 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 = next + j; /*-------------------------------------*\ | Root is index 0. A 0x0000 at any | | other index is frame padding past the | | last entry, not a feature. | \*-------------------------------------*/ if(feat_id == HIDPP20_FEAT_IROOT && feat_idx != 0) { break; } caps.feature_map[feat_id] = feat_idx; caps.feature_versions[feat_id] = feat_version; parsed++; resolved++; 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); } } } if(parsed == 0) { if(++misses > CENTURION_FEATURE_MISS_BUDGET) { LOG_DEBUG("%s CenturionFeatureSet: no entries from index %u, " "stopping enumeration", LOG_TAG, next); break; } continue; } misses = 0; next = next + parsed; } /*-------------------------------------------------*\ | Nothing read is an unreachable sub-device: leave | | the map incomplete for the caller. A partial read | | is usable, but an unread feature reads as absent. | \*-------------------------------------------------*/ if(resolved == 0) { return; } if(resolved < count) { LOG_INFO("%s CenturionFeatureSet: read %u of %u features, " "the rest are treated as absent", LOG_TAG, resolved, count); } 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; char serial[16] = {}; memcpy(serial, &recv_data[1], slen); /*-----------------------------------------*\ | A dongle with no stored serial reports a | | length over zeroed bytes, stop at the | | first non-printable so those stay empty. | \*-----------------------------------------*/ for(uint8_t i = 0; i < slen; i++) { if(serial[i] < 0x21 || serial[i] > 0x7E) { serial[i] = '\0'; break; } } caps.serial_number = serial; 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, the stable hardware identity | | across all 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]; caps.pid_third = ((uint16_t)recv_data[11] << 8) | recv_data[12]; /*---------------------------------------------*\ | Use unitId as serial if none is reported | \*---------------------------------------------*/ 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 PID3=0x%04X", LOG_TAG, caps.unit_id.c_str(), caps.pid_wireless, caps.pid_wired, caps.pid_third); /*---------------------------------------------*\ | Resolve per-model quirks. The modelId | | slots shift with the device's transports, | | so match an entry against any slot. | \*---------------------------------------------*/ caps.quirks = 0; uint16_t device_pids[3] = { caps.pid_wireless, caps.pid_wired, caps.pid_third }; 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]; for(size_t p = 0; p < 3; p++) { if(device_pids[p] != 0 && (entry.pid_wireless == device_pids[p] || entry.pid_wired == device_pids[p])) { caps.quirks |= entry.quirks; break; } } } if(caps.quirks & HIDPP20_QUIRK_KEEP_ONBOARD_MODE) { LogitechHIDPP20IdleSettings::instance()->load(); if(LogitechHIDPP20IdleSettings::instance()->forceHostMode()) { caps.quirks &= ~HIDPP20_QUIRK_KEEP_ONBOARD_MODE; LOG_INFO("%s force_host_mode set: KEEP_ONBOARD_MODE quirk disabled", LOG_TAG); } } 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, 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, 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; } /*---------------------------------------------*\ | 0x8070 GetZoneEffectInfo and 0x8071/0x0600 | | GetEffectInfo share one response shape: | | [0..1] echo (zone/cluster << 8 | effect | | index) [2..3] effect type id [4..5] | | capability bitmask [6..7] default period | | (ms) G810-confirmed. The prior 0x8070 | | layout read the echo at [0..1] as the id; | | it only matched on zone 0. | \*---------------------------------------------*/ 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 " "[raw %02X %02X %02X %02X %02X %02X %02X %02X]", LOG_TAG, j, effect.effect_id, effect.capabilities, effect.default_period, response.data[0], response.data[1], response.data[2], response.data[3], response.data[4], response.data[5], response.data[6], response.data[7]); cluster.effects.push_back(effect); } caps.zone_clusters.push_back(cluster); } /*-----------------------------------------------------*\ | 0x8070, trace level: read back each zone's | | live effect before anything is claimed, the | | onboard default is still running, so this | | dumps real firmware effect settings (param | | layouts for effects the docs don't cover). | \*-----------------------------------------------------*/ if(caps.rgb_feature_page == HIDPP20_FEAT_COLOR_LED_EFFECTS && LogManager::get()->GetLogLevel() >= LL_TRACE) { for(const HIDPP20ZoneCluster& zc : caps.zone_clusters) { uint8_t zone = zc.index; uint8_t fns[2] = { LOGITECH_FP8070_GET_EFFECT, LOGITECH_FP8070_GET_COLOUR }; for(int f = 0; f < 2; f++) { result = SendAckedIntoFAP(caps.idx_rgb_effects, fns[f], &zone, 1, response); LOG_TRACE("%s zone %u fn 0x%02X readback (result=%d): " "%02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X", LOG_TAG, zone, fns[f], result, 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]); } } } 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); /*-----------------------------------------------------*\ | Claim host mode (fn8) before reading zone info, | | under firmware control GetRGBZoneInfo returns | | count=0 (matches Solaar's discover_zones). Read | | the prior mode (fn7) first and only claim if it | | wasn't already host-controlled, so the restore | | below puts it back exactly as we found it. | \*-----------------------------------------------------*/ blankFAPmessage hm_prior_resp; int prior_result = SendAckedIntoFAP(caps.idx_headset_rgb_hostmode, FN_0620_GET_HOST_MODE_STATE, nullptr, 0, hm_prior_resp); int prior_mode = (prior_result > 0) ? hm_prior_resp.data[0] : -1; bool claimed = false; if(prior_mode != 0x01) { uint8_t hostmode_on = 0x01; blankFAPmessage hm_claim_resp; if(SendAckedIntoFAP(caps.idx_headset_rgb_hostmode, FN_0620_SET_HOST_MODE_STATE, &hostmode_on, 1, hm_claim_resp) > 0) { claimed = true; } } /*-----------------------------------------------------*\ | fn1 GetRGBZoneInfo: empty request, returns a zone | | list. Packing not pinned, log the raw response. | \*-----------------------------------------------------*/ 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); } /*-----------------------------------------------------*\ | Restore the prior host mode if we changed | | it (Solaar's discover_zones finally- | | block). The apply path re-claims host mode | | on first paint via ClaimSWControlIfNeeded. | \*-----------------------------------------------------*/ if(claimed && prior_mode >= 0) { uint8_t restore_mode = (uint8_t)prior_mode; blankFAPmessage hm_restore_resp; SendAckedIntoFAP(caps.idx_headset_rgb_hostmode, FN_0620_SET_HOST_MODE_STATE, &restore_mode, 1, hm_restore_resp); } 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() { /*-----------------------------------------------------*\ | 0x8081 (V2) only: 0x8080 (V1) is a separate | | feature (DiscoverPerKey8080). Probing it with | | 0x8081 functions misreads its GetInfo as a | | zone bitmap, sets has_perkey, and fires the | | 0x8081 FrameEnd path at a device without it. | \*-----------------------------------------------------*/ caps.idx_perkey_v2 = GetFeatureIndex(HIDPP20_FEAT_PER_KEY_LIGHTING_V2); uint8_t perkey_idx = caps.idx_perkey_v2; 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"); } /*---------------------------------------------------------*\ | fn2 GetKeyColors readback for one keyType, paginated in | | 14-entry frames. expected_key_count > 0 (fn1 keyCount) | | reads exactly ceil(count/14) pages, matching the official | | app's walk. 0 is probe mode for the fn1-failure fallback: | | page until a frame yields no live keyId, bounded by fn0 | | maxKeyCount with a floor because maxKeyCount can be | | misreported as 0 (seen on the G810). Skips the 4-byte | | header, drops keyId==0 padding, appends live keyIds to | | out_ids, returns the count. | \*---------------------------------------------------------*/ size_t LogitechHIDPP20Controller::ReadPerKey8080Colors ( uint8_t feat_idx, uint16_t key_type, uint16_t expected_key_count, std::vector& out_ids, bool log_colors ) { size_t start_index = 0; size_t guard_pages; if(expected_key_count > 0) { guard_pages = ((size_t)expected_key_count + HIDPP20_8080_KEYS_PER_FRAME - 1) / HIDPP20_8080_KEYS_PER_FRAME; } else { /*-------------------------------------------------*\ | Probe mode: the found==0 break is the real | | terminator, guard_pages only bounds the loop. | \*-------------------------------------------------*/ guard_pages = (caps.perkey_8080_max_key_count / HIDPP20_8080_KEYS_PER_FRAME) + 4; if(guard_pages < 16) { guard_pages = 16; } } for(size_t page = 0; page < guard_pages; page++) { uint8_t query[5] = { (uint8_t)(key_type >> 8), (uint8_t)(key_type & 0xFF), (uint8_t)(start_index >> 8), (uint8_t)(start_index & 0xFF), 0x00 /* persistenceOptions: default volatile */ }; uint8_t recv[60] = {}; int result = SendAcked(feat_idx, FN_8080_GET_KEY_COLORS, query, 5, recv, sizeof(recv), HIDPP20_POLICY_PROBE); if(result <= 8) { break; /* headers only / error / empty */ } /*-------------------------------------------------*\ | result is the raw report length. Subtract the | | 4-byte HID++ header, cap to the copied payload, | | then skip fn2's own 4-byte response header. A | | 64-byte 0x12 response carries exactly 14 | | entries; the old result-based math read one | | entry past the buffer. | \*-------------------------------------------------*/ size_t payload = (size_t)result - 4; if(payload > sizeof(recv)) { payload = sizeof(recv); } size_t entries = (payload - 4) / 4; size_t found = 0; for(size_t e = 0; e < entries; e++) { const uint8_t* entry = recv + 4 + e * 4; uint8_t key_id = entry[0]; if(key_id == 0) { continue; /* padding / empty slot */ } out_ids.push_back(key_id); found++; if(log_colors) { LOG_DEBUG("%s keyType 0x%04X keyId 0x%02X = RGB(%02X,%02X,%02X)", LOG_TAG, key_type, key_id, entry[1], entry[2], entry[3]); } } if(found == 0) { break; /* exhausted this keyType */ } start_index += HIDPP20_8080_KEYS_PER_FRAME; } return out_ids.size(); } /*---------------------------------------------------------*\ | Feature 0x8080 (Per Key Lighting) discovery. Separate | | from 0x8081: keys are (keyType u16 BE, keyId u8 = USB | | HID usage on the keyboard keyType); SetKeyColors rides | | the 0x12 very-long report. Walks the feature the way the | | official app does: fn0 typeFlags is a bitmask of single- | | bit keyTypes, fn1 GetKeyTypeInfo returns that keyType's | | keyCount (u16 BE at byte 0; NOT a keyType echo), and fn2 | | GetKeyColors pages the keyIds 14 per frame only where | | keyCount >= 1. keyCount == 0 is a valid empty success. | | One deviation: on an fn1 failure we probe fn2 directly, | | because unlike the official app we have no out-of-band | | key list to fall back on. Read-only: fn0/fn1/fn2, all | | on dev. | \*---------------------------------------------------------*/ void LogitechHIDPP20Controller::DiscoverPerKey8080() { caps.idx_perkey_8080 = 0; caps.has_perkey_8080 = false; caps.perkey_8080_type_flags = 0; caps.perkey_8080_type_count = 0; caps.perkey_8080_max_key_count = 0; caps.perkey_8080_types.clear(); /*-----------------------------------------------------*\ | Discovery via IRoot, any version, no hardcoded index | | fallback. If the device doesn't advertise 0x8080 the | | path simply doesn't engage. | \*-----------------------------------------------------*/ uint8_t idx = GetFeatureIndex(HIDPP20_FEAT_PER_KEY_LIGHTING_V1); if(idx == 0) { LOG_DEBUG("%s 0x8080 Per Key Lighting not advertised by this device", LOG_TAG); return; } caps.idx_perkey_8080 = idx; /*-----------------------------------------------------*\ | fn0 GetInfo: typeFlags(u16 BE)@0, reserved@2, | | keyTypeCount(u16 BE)@3, maxKeyCount(u16 BE)@5. | \*-----------------------------------------------------*/ uint8_t info[20] = {}; int r0 = SendAcked(idx, FN_8080_GET_INFO, nullptr, 0, info, sizeof(info), HIDPP20_POLICY_RELIABLE); if(r0 <= 0) { LOG_DEBUG("%s 0x8080 idx=0x%02X fn0 GetInfo failed (r=%d), " "feature advertised but unreadable", LOG_TAG, idx, r0); return; } uint16_t raw_type_flags = (uint16_t)((info[0] << 8) | info[1]); uint16_t key_type_count = (uint16_t)((info[3] << 8) | info[4]); uint16_t max_key_count = (uint16_t)((info[5] << 8) | info[6]); uint16_t type_flags = raw_type_flags; /*-----------------------------------------------------*\ | G910 over-reports typeFlags bit 0x2; mask the logged | | / oracle value only. The walk still visits the raw | | bit and lets fn1/fn2 decide, so a real media keyType | | is never dropped by the mask. | \*-----------------------------------------------------*/ if(caps.quirks & HIDPP20_QUIRK_8080_OVERREPORTS_MEDIA) { type_flags &= ~0x0002; } caps.perkey_8080_type_flags = type_flags; caps.perkey_8080_type_count = key_type_count; caps.perkey_8080_max_key_count = max_key_count; LOG_DEBUG("%s ===== 0x8080 Per Key Lighting structure dump =====", LOG_TAG); LOG_DEBUG("%s idx=0x%02X typeFlags=0x%04X keyTypeCount=%u maxKeyCount=%u", LOG_TAG, idx, type_flags, key_type_count, max_key_count); /*-----------------------------------------------------*\ | Walk the set bits of typeFlags across the known | | single-bit keyTypes (0x01 keyboard, 0x02 consumer/ | | media, 0x04 G-keys, 0x08 buttons, 0x10 logo, 0x40 | | indicators). Set bits outside the known six are | | logged, never probed: the official app has no legend | | for them either. | \*-----------------------------------------------------*/ static const uint16_t known_key_types[] = { 0x0001, 0x0002, 0x0004, 0x0008, 0x0010, 0x0040 }; uint16_t known_mask = 0; for(uint16_t kt : known_key_types) { known_mask |= kt; } if(raw_type_flags & ~known_mask) { LOG_DEBUG("%s typeFlags sets unknown bit(s) 0x%04X; not probed", LOG_TAG, (uint16_t)(raw_type_flags & ~known_mask)); } for(uint16_t key_type : known_key_types) { if(!(raw_type_flags & key_type)) { continue; } /*-------------------------------------------------*\ | fn1 GetKeyTypeInfo: keyCount(u16 BE)@0, param | | bytes @2. keyCount == 0 is a valid empty success | | (an advertised keyType with no keys), skip it | | without treating it as an error. | \*-------------------------------------------------*/ uint8_t q1[2] = { (uint8_t)(key_type >> 8), (uint8_t)(key_type & 0xFF) }; uint8_t resp1[16] = {}; int r1 = SendAcked(idx, FN_8080_GET_KEY_TYPE_INFO, q1, 2, resp1, sizeof(resp1), HIDPP20_POLICY_PROBE); bool fn1_ok = (r1 >= 2); uint16_t key_count = fn1_ok ? (uint16_t)((resp1[0] << 8) | resp1[1]) : 0; if(fn1_ok && key_count == 0) { LOG_DEBUG("%s keyType 0x%04X: fn1 keyCount=0, skipped", LOG_TAG, key_type); continue; } std::vector key_ids; size_t live; if(fn1_ok) { live = ReadPerKey8080Colors(idx, key_type, key_count, key_ids, true); } else { /*---------------------------------------------*\ | Deviation from the official app: it has an | | out-of-band key list and can drop a keyType | | whose fn1 fails, we cannot, so probe fn2 | | directly, bounded by fn0 maxKeyCount. | \*---------------------------------------------*/ LOG_DEBUG("%s keyType 0x%04X: fn1 failed (r=%d), probing fn2 directly", LOG_TAG, key_type, r1); live = ReadPerKey8080Colors(idx, key_type, 0, key_ids, true); } if(live == 0) { LOG_DEBUG("%s keyType 0x%04X: advertised but no live keyIds%s", LOG_TAG, key_type, fn1_ok ? " (fn2 disagrees with fn1 keyCount)" : ""); continue; } HIDPP20PerKey8080Type entry; entry.key_type = key_type; entry.param[0] = fn1_ok ? resp1[2] : 0; entry.param[1] = fn1_ok ? resp1[3] : 0; entry.param[2] = fn1_ok ? resp1[4] : 0; entry.key_ids = key_ids; caps.perkey_8080_types.push_back(entry); LOG_DEBUG("%s CONFIRMED keyType 0x%04X with %zu keyId(s)%s", LOG_TAG, key_type, key_ids.size(), fn1_ok ? "" : " (fn1 unavailable; fn2-probed)"); } caps.has_perkey_8080 = !caps.perkey_8080_types.empty(); /*-----------------------------------------------------*\ | Numpad presence: full-size if any standard numpad HID | | usage (0x53 NumLock .. 0x63 Keypad .) was enumerated; | | drives FULL vs TKL matrix sizing in SetupZones8080. | \*-----------------------------------------------------*/ for(size_t t = 0; t < caps.perkey_8080_types.size() && !caps.has_numpad; t++) { for(uint8_t key_id : caps.perkey_8080_types[t].key_ids) { if(key_id >= 0x53 && key_id <= 0x63) { caps.has_numpad = true; break; } } } /*-----------------------------------------------------*\ | Diagnostic only: count advertised vs confirmed | | keyTypes. The per-keyType lines above localise any | | gap (empty keyCount, fn1 failure, fn2 disagreement). | | Compared against the quirk-masked oracle value so | | known over-reporters don't warn every scan. | \*-----------------------------------------------------*/ unsigned int flag_count = 0; for(int bit = 0; bit < 16; bit++) { if(type_flags & (1u << bit)) { flag_count++; } } if(caps.perkey_8080_types.size() != flag_count) { LOG_DEBUG("%s 0x8080 confirmed %zu keyType(s); typeFlags=0x%04X " "advertises %u; see the per-keyType lines above for " "which advertised keyType(s) yielded no keys.", LOG_TAG, caps.perkey_8080_types.size(), type_flags, flag_count); } LOG_DEBUG("%s 0x8080 enumeration: %zu keyType(s) confirmed, has_perkey_8080=%s%s", LOG_TAG, caps.perkey_8080_types.size(), caps.has_perkey_8080 ? "yes" : "no", (dev_perkey_vl == nullptr) ? " (NOTE: no 0x12 very-long handle, read-only, cannot paint)" : ""); } 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; const HIDPP20RetryPolicy& first_contact = FirstContactPolicy(); 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, first_contact); } } else { /*-------------------------------------------------*\ | Standard HID++: probe FeatureSet (0x0001). The | | policy retries on the wire; the outer loop adds a | | buffer flush between bursts to clear stale | | queued responses. | \*-------------------------------------------------*/ 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, first_contact); } } 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(); DiscoverPerKey8080(); 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); /*-----------------------------------------------------*\ | Diagnostic: log the OpenRGB-relevant | | feature/version map (solaar-show-style) | | for firmware-mismatch identification. | \*-----------------------------------------------------*/ LogFeatureSummary(); if(!caps.has_zone_effects && !caps.has_perkey && !caps.has_perkey_8080) { LOG_DEBUG("%s %s: no RGB features found", LOG_TAG, caps.device_name.c_str()); /*-------------------------------------------------*\ | Centurion dongles with a 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; } std::string LogitechHIDPP20Controller::ProbeIdentity() { LOG_DEBUG("%s Identifying device at %s (index=0x%02X)", LOG_TAG, location.c_str(), device_index); DiscoverTransport(); FlushResponseQueue(); /*-----------------------------------------------------*\ | Nothing else is worth asking until IRoot answers. | \*-----------------------------------------------------*/ if(GetFeatureIndex(HIDPP20_FEAT_FEATURE_SET, FirstContactPolicy()) == 0) { return ""; } DiscoverFirmwareInfo(); /*-----------------------------------------------------*\ | Centurion nodes have no FirmwareInfo unitId; | | they identify by their DeviceInfo serial. A | | node that answers but stores no serial (PRO | | X 2 dongle) books under its path, so it | | cannot be recognized over a second link. | \*-----------------------------------------------------*/ if(caps.unit_id.empty()) { if(!caps.serial_number.empty()) { return caps.serial_number; } if(transport.type == HIDPP20_TRANSPORT_CENTURION) { LOG_DEBUG("%s No stored serial; booking by node path", LOG_TAG); return location + "#node"; } } return caps.unit_id; } std::string LogitechHIDPP20Controller::ProbeName() { DiscoverDeviceName(); /*-----------------------------------------------------*\ | DiscoverDeviceName falls back to a placeholder | | when the device has nothing to say. Placeholders | | are for the device list, where they are replaced | | the moment a real name turns up. They are not | | identities: a caller writing one into settings | | would leave behind an entry naming nothing. | \*-----------------------------------------------------*/ if(caps.device_name == HIDPP20_NAME_PLACEHOLDER_STD || caps.device_name == HIDPP20_NAME_PLACEHOLDER_CENTURION || !LogitechHIDPP20Controller::NameLooksReal(caps.device_name)) { return ""; } return caps.device_name; } 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; } /*-----------------------------------------------------*\ | Sender first: no frame may be mid-flight | | while SW control is released below. | \*-----------------------------------------------------*/ StopSenderThread(); StopPowerManager(); /*-----------------------------------------------------*\ | Release SW control | \*-----------------------------------------------------*/ if(caps.idx_rgb_effects != 0) { SetSWControl(0, 0); } /*-----------------------------------------------------*\ | Restore firmware mode (skip if we never left onboard) | \*-----------------------------------------------------*/ if(!(caps.quirks & HIDPP20_QUIRK_KEEP_ONBOARD_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; pairs with the SetHostModeState(1) | | claim in ClaimSWControlIfNeeded. | \*-----------------------------------------------------*/ 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, persist]. the | | official app/legacy send [1,1] to claim and | | [0,0] to release, persist mirrors enabled. | \*-------------------------------------------------*/ uint8_t en = (uint8_t)(mode > 0 ? 0x01 : 0x00); uint8_t data[2] = { en, en }; 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) { uint8_t set_data[1] = { 0x02 }; blankFAPmessage set_response; SendAckedIntoFAP(caps.idx_onboard_profiles, FN_8100_SET_ONBOARD_MODE, set_data, 1, set_response); LOG_DEBUG("%s OnboardProfiles set to host mode", LOG_TAG); } } bool LogitechHIDPP20Controller::ClaimSWControlIfNeeded() { if(sw_control_claimed) { return true; } /*-----------------------------------------------------*\ | 0x0620 headset (G522/G321/G315): the claim is | | a single SetHostModeState(1); none of the | | 0x8070/0x8071/0x0600 paths apply. Sticky; wake | | resets the flag so first paint re-claims. | | Released by SetHostModeState(0) in Shutdown. | \*-----------------------------------------------------*/ if(caps.idx_headset_rgb_hostmode != 0) { if(!device_online.load()) { return false; } uint8_t on = 0x01; blankFAPmessage resp; int result = SendAckedIntoFAP(caps.idx_headset_rgb_hostmode, FN_0620_SET_HOST_MODE_STATE, &on, 1, resp); if(result <= 0) { LOG_DEBUG("%s 0x0620 SetHostModeState(1) claim failed (result=%d)", LOG_TAG, result); return false; } sw_control_claimed = true; LOG_DEBUG("%s 0x0620 host mode claimed", LOG_TAG); return true; } /*-----------------------------------------------------*\ | 0x8070 per-key keyboards (those exposing 0x8080: G810 | | family) write per-key directly with NO claim, like | | the legacy controllers. FIXME: a partial SetSWControl | | claim breaks per-key on the G810; the full sequence | | is host-mode -> SetSWControl(1,1) -> SetZoneEffect | | off per zone -> per-key. Test on real hardware which | | parts each model supports before claiming here. | \*-----------------------------------------------------*/ if(caps.rgb_feature_page == HIDPP20_FEAT_COLOR_LED_EFFECTS && caps.idx_perkey_8080 != 0) { return true; } if(caps.idx_rgb_effects == 0 || !device_online.load()) { return false; } /*-----------------------------------------------------*\ | 0x8070 ZONE-ONLY devices (no 0x8080: G560/G933, | | 0x8070 mice): the claim is just SetSWControl(1,1), | | the official app and Solaar both do exactly this, | | with no power/host-mode tail. No per-key layer to | | conflict, so the zone layer is not suppressed. | \*-----------------------------------------------------*/ if(caps.rgb_feature_page == HIDPP20_FEAT_COLOR_LED_EFFECTS) { int sw_result = SetSWControl(1, 1); if(sw_result <= 0) { LOG_DEBUG("%s 0x8070 SetSWControl(1,1) claim failed (result=%d)", LOG_TAG, sw_result); return false; } sw_control_claimed = true; return true; } /*-----------------------------------------------------*\ | Two-phase claim to avoid the visible | | onboard->host flash. | | | | SW Control flags bits (from a wire capture, the | | doc's overview mislabels them): bit 0 = effect | | control, bit 1 = power management, bit 2 = NV | | config. Setting the effect bit suspends the firmware | | effect engine, and anything not painted since | | renders as a firmware default (the 0xFFFFFF per-key | | buffer, or the saved effect card on bad input). | | | | So claim with flags=6: the firmware engine keeps | | rendering until we replace its output. We cannot | | stay there, the idle/wake machine needs flags=5/3 | | as its active/idle signals, so after the first | | complete per-key frame, when per-key masks zone | | output anyway, upgrade 6->5 invisibly. | | | | No power-timer writes at claim: our | | StartSleep trigger fires SetRgbPowerMode(3) | | explicitly when a fade is wanted. | | | | Fire the whole claim back-to-back, no per-command ACK | | wait; VerifyClaimPipeline reconciles it. | \*-----------------------------------------------------*/ claim_pipeline_.clear(); pipelining_claim_ = true; int claim_result = SetSWControl(3, 6); if(claim_result <= 0) { pipelining_claim_ = false; claim_pipeline_.clear(); 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); /*-----------------------------------------------------*\ | KEEP_ONBOARD_MODE (G915 TKL): host mode breaks the | | F-row (onboard-profile-mapped G-keys); per-key | | works onboard. | \*-----------------------------------------------------*/ if(caps.quirks & HIDPP20_QUIRK_KEEP_ONBOARD_MODE) { LOG_INFO("%s KEEP_ONBOARD_MODE quirk: skipping onboard->host switch", LOG_TAG); } else { SetHostMode(); } /*-----------------------------------------------------*\ | Reconcile the batch's ACKs; blocking-retry any miss. | \*-----------------------------------------------------*/ pipelining_claim_ = false; VerifyClaimPipeline(); written_idle_s = idle_timeout_s; written_sleep_s = sleep_timeout_s; sw_control_claimed = true; sw_control_needs_upgrade_to_5 = true; prep_applied = false; LOG_DEBUG("%s Claimed SW control at flags=6 " "(effect engine still autonomous until first per-key frame)", LOG_TAG); return true; } void LogitechHIDPP20Controller::VerifyClaimPipeline() { /*-----------------------------------------------------*\ | Reconcile the pipelined claim's ACKs. Acks arrive | | interleaved across features, so match each echoed | | (feat, func, sw_id) to any outstanding pending, not | | in send order; re-send an unmatched one blocking. | \*-----------------------------------------------------*/ std::vector acked(claim_pipeline_.size(), false); size_t need = claim_pipeline_.size(); size_t got = 0; size_t max_read = claim_pipeline_.size() + 4; /* slack for stray frames */ for(size_t r = 0; r < max_read && got < need; r++) { uint8_t rfeat = 0; uint8_t rfunc = 0; uint8_t rdata[60] = {}; int rd = ReadMessage(&rfeat, &rfunc, rdata, sizeof(rdata), 60); if(rd <= 0) { break; /* no more responses within the window */ } if((rfunc & 0x0F) != HIDPP20_SW_ID) { continue; /* not one of our acks */ } for(size_t i = 0; i < claim_pipeline_.size(); i++) { if(!acked[i] && rfeat == claim_pipeline_[i].feat && (rfunc & 0xF0) == (claim_pipeline_[i].func & 0xF0)) { acked[i] = true; got++; break; } } } for(size_t i = 0; i < claim_pipeline_.size(); i++) { if(!acked[i]) { LOG_DEBUG("%s claim pipeline: re-sending feat=0x%02X func=0x%02X blocking", LOG_TAG, claim_pipeline_[i].feat, claim_pipeline_[i].func); blankFAPmessage response; SendAckedIntoFAP(claim_pipeline_[i].feat, claim_pipeline_[i].func, claim_pipeline_[i].data, claim_pipeline_[i].len, response); } } claim_pipeline_.clear(); } 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, so the 6->5 | | transition does not expose 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: | | | | First, ~260ms 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 | | | | Second, ~95ms after the first: | | 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 x 10] | | persist=0x01 | | | | The function we call is caps.fn_set_effect (0x10 on | | 0x8071, 0x30 on 0x8070, same as SetZoneEffect uses). | | | | The values are not derived from the effect param layout | | tables: they are observed-working bytes, and that is the | | contract. DeviceUpdateLEDs gates which devices run this. | \*---------------------------------------------------------*/ 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 at the first out-of-range | | slot above the last enumerated effect, all params | | zero (G502: effectIdx=4, matching the capture). | | Best understanding: an unadvertised custom/direct- | | mode slot, without writing it the per-key pipeline | | does not engage and the firmware renders the saved | | effect card during the claim->paint window. | \*-----------------------------------------------------*/ 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, unlike the G515 static-black fallback | | which leaves the 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 present. G502 / G515 fail | | the 0x4522 side; older keyboards without 0x8081 | | fail the per-key side. | \*-----------------------------------------------------*/ if(caps.idx_rgb_effects == 0 || caps.idx_perkey_v2 == 0 || !device_online.load()) { return; } for(size_t i = 0; i < caps.zone_clusters.size(); i++) { uint8_t cluster_off[16] = { caps.zone_clusters[i].index, 0x00, /* cluster, effectIdx=0 (Off) */ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, /* persist */ 0x00, 0x00, 0x00 }; blankFAPmessage cluster_resp; SendAckedIntoFAP(caps.idx_rgb_effects, caps.fn_set_effect, cluster_off, 16, cluster_resp); } if(caps.perkey_zone_ids.empty()) { LOG_DEBUG("%s DoKeyboardFamilyPerKeyPrep: no per-key zones enumerated, " "skipping primer key", LOG_TAG); return; } uint8_t primer_zone = (uint8_t)(caps.perkey_zone_ids[0] & 0xFF); uint8_t primer[4] = { primer_zone, 0x00, 0x00, 0x00 }; std::vector primer_zones; primer_zones.push_back(primer_zone); SendPerKeyData(caps.idx_perkey_v2, FN_8081_SET_INDIVIDUAL, primer, 4, primer_zones); PerKeyFrameEnd(); LOG_DEBUG("%s DoKeyboardFamilyPerKeyPrep: %zu clusters -> Off, " "primer zone=0x%02X, FrameEnd committed", LOG_TAG, caps.zone_clusters.size(), primer_zone); } /*---------------------------------------------------------*\ | Retry-paint scheduling | | | | Called by RGBController_LogitechHIDPP20::DeviceUpdateLEDs | | when a full pass completes with `acked_zones.size() != | | attempted_zones.size()` (partial commit). The retry | | re-runs a whole DeviceUpdateLEDs cycle from the power | | thread so the uncommitted zones (marked | | HIDPP20_UNCOMMITTED in sent_colors) get another shot. | | | | Streaming animation frames also call ScheduleRetryPaint | | on partial commit, but the next animation frame almost | | always CancelRetryPaint()s before the deadline fires, | | so the retry is a free no-op in the streaming path. | | The retry only actually fires when no follow-up frame | | arrives, which matches our two problem cases: | | 1. First frame after a reconnect-transient claim | | (Direct mode, no animation timer). | | 2. Last frame of an animation that then stops. | \*---------------------------------------------------------*/ bool 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 false; } 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); return true; } void LogitechHIDPP20Controller::CancelRetryPaint() { retry_paint_deadline_.store(std::chrono::steady_clock::time_point{}); retry_paint_attempt_.store(0); } void LogitechHIDPP20Controller::TickRetryPaintIfPending() { /*-----------------------------------------------------*\ | Called from the power thread's main loop | | each tick. Checks the retry deadline and | | fires the repaint callback when it expires. | | The callback runs DeviceUpdateLEDs on the | | power thread's context, not recursively | | from inside another DeviceUpdateLEDs call. | \*-----------------------------------------------------*/ std::chrono::steady_clock::time_point deadline = retry_paint_deadline_.load(); if(deadline == std::chrono::steady_clock::time_point{}) { return; } if(std::chrono::steady_clock::now() < deadline) { return; } /*-----------------------------------------------------*\ | Clear the deadline before firing so a concurrent | | ScheduleRetryPaint (from a different thread) doesn't | | double-fire on the same tick. Advance the attempt | | counter so the next ScheduleRetryPaint (if this | | retry also fails) picks the next backoff slot. | \*-----------------------------------------------------*/ retry_paint_deadline_.store(std::chrono::steady_clock::time_point{}); retry_paint_attempt_.fetch_add(1); LOG_DEBUG("%s retry paint firing", LOG_TAG); if(request_repaint_fn) { request_repaint_fn(); } } /*---------------------------------------------------------*\ | Per-key lighting (0x8081) | \*---------------------------------------------------------*/ void LogitechHIDPP20Controller::SetPerKeyColors ( const std::vector>& zone_colors ) { if(!device_online.load()) return; uint8_t perkey_idx = (caps.idx_perkey_v2 != 0) ? caps.idx_perkey_v2 : caps.idx_perkey_v1; if(perkey_idx == 0) { return; } /*-----------------------------------------------------*\ | Batch into SetIndividualRgbZones (fn1): 4 | | entries/packet Each entry = [zone_id, R, | | G, B]. Track the zones in each batch so | | PerKeyFrameEnd can report which committed. | \*-----------------------------------------------------*/ uint8_t data[16]; std::vector batch_zones; int count = 0; for(size_t i = 0; i < zone_colors.size(); i++) { int offset = count * 4; data[offset + 0] = (uint8_t)zone_colors[i].first; data[offset + 1] = RGBGetRValue(zone_colors[i].second); data[offset + 2] = RGBGetGValue(zone_colors[i].second); data[offset + 3] = RGBGetBValue(zone_colors[i].second); batch_zones.push_back((uint8_t)zone_colors[i].first); count++; if(count == 4 || i == zone_colors.size() - 1) { SendPerKeyData(perkey_idx, FN_8081_SET_INDIVIDUAL, data, count * 4, batch_zones); memset(data, 0, sizeof(data)); batch_zones.clear(); count = 0; } } } void LogitechHIDPP20Controller::SetAllPerKeyColor(RGBColor color) { if(!device_online.load()) return; uint8_t perkey_idx = (caps.idx_perkey_v2 != 0) ? caps.idx_perkey_v2 : caps.idx_perkey_v1; if(perkey_idx == 0) { return; } uint8_t r = RGBGetRValue(color); uint8_t g = RGBGetGValue(color); uint8_t b = RGBGetBValue(color); /*-----------------------------------------------------*\ | Use SetRangeRgbZones (fn5): [start, end, R, | | G, B] x 3 per packet. Sets all zones in a | | contiguous range to one color. Gaps in zone | | IDs are silently ignored by firmware. For | | uniform color this is far more efficient than | | fn6: 1-2 packets vs 8 packets for 94 zones. | \*-----------------------------------------------------*/ uint8_t min_zone = 255, max_zone = 0; for(uint16_t zid : caps.perkey_zone_ids) { if(zid > 0 && zid <= 255) { if((uint8_t)zid < min_zone) min_zone = (uint8_t)zid; if((uint8_t)zid > max_zone) max_zone = (uint8_t)zid; } } if(min_zone <= max_zone) { uint8_t data[5] = { min_zone, max_zone, r, g, b }; std::vector batch_zones; for(uint16_t zid : caps.perkey_zone_ids) { if(zid >= min_zone && zid <= max_zone) { batch_zones.push_back((uint8_t)zid); } } SendPerKeyData(perkey_idx, FN_8081_SET_RANGE, data, 5, batch_zones); } } void LogitechHIDPP20Controller::SendPerKeyData ( uint8_t perkey_idx, uint8_t function, const uint8_t* data, size_t len, const std::vector& zone_ids ) { /*-----------------------------------------------------*\ | Pipelined within the frame, paced by the device's ACK | | stream: at most HIDPP20_PERKEY_WINDOW packets in | | flight unacked, on USB this is what stops the | | firmware dropping responses. A stalled stream aborts | | the frame; unacked zones ride the next delta. | \*-----------------------------------------------------*/ if(frame_attempted_zones.empty() && frame_packets.empty()) { frame_first_write = std::chrono::steady_clock::now(); } frame_attempted_zones.insert(frame_attempted_zones.end(), zone_ids.begin(), zone_ids.end()); if(frame_aborted) { return; } while(frame_packets.size() - frame_responses_seen >= perkey_window) { if(ProcessOnePerKeyResponse(HIDPP20_PERKEY_ACK_WAIT_MS, perkey_idx) <= 0) { LOG_DEBUG("%s per-key ACK stream stalled (%zu in flight), aborting frame", LOG_TAG, frame_packets.size() - frame_responses_seen); frame_aborted = true; return; } } int send_result = SendMessage(perkey_idx, function, data, len); if(send_result < 0) { /*-------------------------------------------------*\ | Never left the host, no ACK will come. Not | | recorded as a packet, so its zones simply | | never ack and ride the next frame's delta. | \*-------------------------------------------------*/ LOG_DEBUG("%s per-key write wire send failed (result=%d) func=0x%02X", LOG_TAG, send_result, function); return; } PerKeyPacket packet; packet.function = function; packet.len = (len > sizeof(packet.payload)) ? sizeof(packet.payload) : len; packet.zone_ids = zone_ids; packet.acked = false; memset(packet.payload, 0, sizeof(packet.payload)); memcpy(packet.payload, data, packet.len); frame_packets.push_back(std::move(packet)); fe_staged_zones += zone_ids.size(); } int LogitechHIDPP20Controller::ProcessOnePerKeyResponse(int timeout_ms, uint8_t perkey_idx) { 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), timeout_ms); if(rd <= 0) { return rd; } /*-----------------------------------------------------*\ | 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]; if(err_feat == perkey_idx && (err_func & 0x0F) == HIDPP20_SW_ID && (err_func & 0xF0) != FN_8081_FRAME_END) { /*---------------------------------------------*\ | A write was rejected outright. It answered, | | but nothing it carried committed. | \*---------------------------------------------*/ LOG_DEBUG("%s per-key write error 0x%02X func=0x%02X", LOG_TAG, err_code, err_func); frame_responses_seen++; } return 1; } if(resp_feat != perkey_idx || (resp_func & 0x0F) != HIDPP20_SW_ID || (resp_func & 0xF0) == FN_8081_FRAME_END) { return 1; } frame_responses_seen++; if(MatchPerKeyAck(resp_func & 0xF0, resp_data, frame_acked_zones) >= 0) { frame_exact_acks++; } else { /*-------------------------------------------------*\ | Echo format unknown for this function, count | | it toward the group. | \*-------------------------------------------------*/ frame_unmatched_acks[resp_func & 0xF0]++; } return 1; } void LogitechHIDPP20Controller::ResetPerKeyFrameState() { frame_attempted_zones.clear(); frame_packets.clear(); frame_acked_zones.clear(); frame_unmatched_acks.clear(); frame_responses_seen = 0; frame_exact_acks = 0; frame_aborted = false; } void LogitechHIDPP20Controller::DrainStaleResponses() { std::chrono::steady_clock::time_point deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(150); while(std::chrono::steady_clock::now() < deadline) { uint8_t resp_data[60]; if(ReadMessage(nullptr, nullptr, resp_data, sizeof(resp_data), 25) <= 0) { return; } } } /*---------------------------------------------------------*\ | Per-key frame sender thread: the effect engine | | submits snapshots at whatever rate it likes; | | the sender pushes the newest at the pace the | | link sustains and skips the rest. Wire time is | | paid here, never on the animating thread. | \*---------------------------------------------------------*/ void LogitechHIDPP20Controller::SetPerKeyFrameSender(std::function&)> fn) { perkey_frame_fn = fn; sender_running.store(true); sender_thread = new std::thread(&LogitechHIDPP20Controller::SenderThreadFunc, this); } bool LogitechHIDPP20Controller::HasPerKeyFrameSender() const { return sender_running.load(); } void LogitechHIDPP20Controller::SubmitPerKeyFrame(const std::vector& frame) { if(!sender_running.load()) { return; } { std::lock_guard lock(pending_frame_mutex); pending_frame = frame; pending_frame_valid = true; } pending_frame_cv.notify_one(); } void LogitechHIDPP20Controller::SenderThreadFunc() { while(sender_running.load()) { std::vector frame; { std::unique_lock lock(pending_frame_mutex); pending_frame_cv.wait(lock, [this] { return pending_frame_valid || !sender_running.load(); }); if(!sender_running.load()) { return; } frame = std::move(pending_frame); pending_frame_valid = false; } /*-------------------------------------------------*\ | Teardown started. The frame cannot land and | | would hold the transaction mutex that stopping | | the threads needs, so keep it and let the loop | | exit. | \*-------------------------------------------------*/ if(teardown_pending.load()) { { std::lock_guard lock(pending_frame_mutex); if(!pending_frame_valid) { pending_frame = std::move(frame); pending_frame_valid = true; } } std::this_thread::sleep_for(std::chrono::milliseconds(10)); continue; } if(perkey_frame_fn) { perkey_frame_fn(frame); } } } void LogitechHIDPP20Controller::StopSenderThread() { sender_running.store(false); pending_frame_cv.notify_all(); if(sender_thread && sender_thread->joinable()) { sender_thread->join(); } delete sender_thread; sender_thread = nullptr; } /*---------------------------------------------------------*\ | Attribute one write ACK to one outstanding packet by | | payload echo (decoded from captures, G515/G502): | | fn1 SetIndividualRgbZones: resp[i] = zone of entry i, | | packed contiguously; 0xFF = that zone rejected | | fn5 SetRangeRgbZones: resp[k] = start zone of | | range entry k | | fn6 SetRgbZonesSingleValue: resp = [R, G, B, zone] | | Anything else falls back to per-function count | | matching in the caller. Returns the matched | | packet index or -1, appending zones the echo | | proves to acked_zones_out. | \*---------------------------------------------------------*/ int LogitechHIDPP20Controller::MatchPerKeyAck ( uint8_t resp_func_hi, const uint8_t* resp_data, std::vector& acked_zones_out ) { for(size_t p = 0; p < frame_packets.size(); p++) { PerKeyPacket& packet = frame_packets[p]; if(packet.acked || packet.function != resp_func_hi) { continue; } if(packet.function == FN_8081_SET_INDIVIDUAL) { size_t entries = packet.len / 4; bool matches = true; size_t echoed = 0; for(size_t j = 0; j < entries; j++) { uint8_t req_zone = packet.payload[j * 4]; uint8_t resp_zone = resp_data[j]; if(resp_zone == req_zone) { echoed++; } else if(resp_zone != 0xFF) { matches = false; break; } } if(!matches || echoed == 0) { continue; } for(size_t j = 0; j < entries; j++) { if(resp_data[j] == packet.payload[j * 4]) { acked_zones_out.push_back(packet.payload[j * 4]); } else { LOG_DEBUG("%s per-key zone 0x%02X rejected by firmware", LOG_TAG, packet.payload[j * 4]); } } packet.acked = true; return (int)p; } if(packet.function == FN_8081_SET_RANGE) { size_t entries = packet.len / 5; bool matches = (entries > 0); for(size_t k = 0; k < entries; k++) { if(resp_data[k] != packet.payload[k * 5]) { matches = false; break; } } if(!matches) { continue; } acked_zones_out.insert(acked_zones_out.end(), packet.zone_ids.begin(), packet.zone_ids.end()); packet.acked = true; return (int)p; } if(packet.function == FN_8081_SET_SINGLE_VALUE) { if(memcmp(resp_data, packet.payload, 3) != 0) { continue; } bool zone_in_packet = false; for(uint8_t z : packet.zone_ids) { if(z == resp_data[3]) { zone_in_packet = true; break; } } if(!zone_in_packet) { continue; } acked_zones_out.insert(acked_zones_out.end(), packet.zone_ids.begin(), packet.zone_ids.end()); packet.acked = true; return (int)p; } /*-------------------------------------------------*\ | Unknown function, echo the request verbatim. | \*-------------------------------------------------*/ if(memcmp(resp_data, packet.payload, packet.len) == 0) { acked_zones_out.insert(acked_zones_out.end(), packet.zone_ids.begin(), packet.zone_ids.end()); packet.acked = true; return (int)p; } } return -1; } PerKeyFrameResult LogitechHIDPP20Controller::PerKeyFrameEnd() { PerKeyFrameResult result; result.frame_end_acked = false; result.attempted_zones = std::move(frame_attempted_zones); uint8_t perkey_idx = (caps.idx_perkey_v2 != 0) ? caps.idx_perkey_v2 : caps.idx_perkey_v1; if(!device_online.load() || perkey_idx == 0) { result.acked_zones = std::move(frame_acked_zones); ResetPerKeyFrameState(); return result; } std::chrono::steady_clock::time_point send_done = std::chrono::steady_clock::now(); if(!frame_aborted) { /*-------------------------------------------------*\ | Phase A: collect the remaining write ACKs, a | | short tail, the window kept the outstanding | | count small. The per-read timeout is a quiet | | guard: a dropped ACK is never coming. | \*-------------------------------------------------*/ std::chrono::steady_clock::time_point ack_deadline = send_done + std::chrono::milliseconds(250); while(frame_responses_seen < frame_packets.size()) { int remaining = (int)std::chrono::duration_cast( ack_deadline - std::chrono::steady_clock::now()).count(); if(remaining <= 0) { break; } if(remaining > (int)HIDPP20_PERKEY_ACK_TAIL_MS) { remaining = (int)HIDPP20_PERKEY_ACK_TAIL_MS; } if(ProcessOnePerKeyResponse(remaining, perkey_idx) <= 0) { break; } } } std::chrono::steady_clock::time_point acks_done = std::chrono::steady_clock::now(); int busy_polls = 0; uint16_t predicted_ms = 0; bool fe_probe = false; /*-----------------------------------------------------*\ | Phase B: commit. LONG message (0x11), 16 | | zero bytes; short-format hits intermittent | | BUSY. Skipped when the frame aborted (landed | | writes latch with the next FrameEnd). | | | | BUSY here is frame-rate backpressure, not | | failure: the not-ready window scales with keys | | written. Sleep the learned prediction first (the | | link stays free for input), then poll the | | remainder. The wait is the frame cadence and | | paces the sender, never the effect engine. | \*-----------------------------------------------------*/ size_t commit_zones = fe_staged_zones; if(!frame_aborted) { float predicted = fe_busy_us_per_zone * (float)commit_zones / 1000.0f; if(predicted > 100.0f) { predicted = 100.0f; } predicted_ms = (uint16_t)predicted; /*-------------------------------------------------*\ | Probe: every so often, sleep deliberately | | short to test whether the device got faster. A | | miss costs a few ms (ready is within the | | shaved step, the NEAR poll picks it up); a | | clean accept adopts the shorter window. | \*-------------------------------------------------*/ if(predicted_ms > HIDPP20_FE_PROBE_STEP_MS && ++fe_frames_since_probe >= HIDPP20_FE_PROBE_INTERVAL) { fe_frames_since_probe = 0; predicted_ms -= HIDPP20_FE_PROBE_STEP_MS; fe_probe = true; } if(predicted_ms > 0) { std::this_thread::sleep_for(std::chrono::milliseconds(predicted_ms)); } 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); } else { std::chrono::steady_clock::time_point fe_deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(250); while(!result.frame_end_acked) { int remaining = (int)std::chrono::duration_cast( fe_deadline - std::chrono::steady_clock::now()).count(); if(remaining <= 0) { LOG_DEBUG("%s FrameEnd timed out waiting for ACK", LOG_TAG); 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) { LOG_DEBUG("%s FrameEnd timed out waiting for ACK", LOG_TAG); break; } if(resp_feat == 0xFF) { uint8_t err_feat = resp_func; uint8_t err_func = resp_data[0]; uint8_t err_code = resp_data[1]; if(err_feat != perkey_idx || (err_func & 0x0F) != HIDPP20_SW_ID) { continue; } if((err_func & 0xF0) != FN_8081_FRAME_END) { LOG_DEBUG("%s per-key write error 0x%02X func=0x%02X", LOG_TAG, err_code, err_func); frame_responses_seen++; continue; } if(err_code == 0x08) { busy_polls++; std::this_thread::sleep_for(std::chrono::milliseconds( (predicted_ms > 0) ? HIDPP20_FRAME_END_BUSY_POLL_NEAR_MS : HIDPP20_FRAME_END_BUSY_POLL_MS)); SendMessage(perkey_idx, FN_8081_FRAME_END, data, sizeof(data)); continue; } LOG_DEBUG("%s FrameEnd error 0x%02X (polls=%d)", LOG_TAG, err_code, busy_polls); break; } if(resp_feat != perkey_idx || (resp_func & 0x0F) != HIDPP20_SW_ID) { continue; } if((resp_func & 0xF0) == FN_8081_FRAME_END) { result.frame_end_acked = true; break; } /*-----------------------------------------*\ | A straggler write ACK: attribute it. | \*-----------------------------------------*/ frame_responses_seen++; if(MatchPerKeyAck(resp_func & 0xF0, resp_data, frame_acked_zones) >= 0) { frame_exact_acks++; } else { frame_unmatched_acks[(uint8_t)(resp_func & 0xF0)]++; } } } } /*-----------------------------------------------------*\ | Learn the busy window from what happened, last | | write ACK to accepted FrameEnd. | | probe frame, clean accept -> adopt shorter window | | probe frame, BUSY -> deliberate miss | | normal frame, BUSY -> ran short, pull | | toward measurement | \*-----------------------------------------------------*/ if(result.frame_end_acked) { /*-------------------------------------------------*\ | Committed; the staging is latched and | | the next commit starts from empty. | \*-------------------------------------------------*/ fe_staged_zones = 0; } if(result.frame_end_acked && commit_zones > 0) { float zones = (float)commit_zones; if(fe_probe) { if(busy_polls == 0) { fe_busy_us_per_zone = 0.7f * fe_busy_us_per_zone + 0.3f * ((float)predicted_ms * 1000.0f / zones); } } else if(busy_polls > 0) { int ready_ms = (int)std::chrono::duration_cast( std::chrono::steady_clock::now() - acks_done).count(); fe_busy_us_per_zone = 0.7f * fe_busy_us_per_zone + 0.3f * ((float)ready_ms * 1000.0f / zones); if(fe_busy_us_per_zone > 2000.0f) { fe_busy_us_per_zone = 2000.0f; } } } /*-----------------------------------------------------*\ | Settle the fallback groups: for each | | function whose ACKs failed the echo compare, | | commit the group's unacked packets only if | | the response count covers all of them. | \*-----------------------------------------------------*/ for(std::pair& group : frame_unmatched_acks) { size_t unacked = 0; for(const PerKeyPacket& packet : frame_packets) { if(!packet.acked && packet.function == group.first) { unacked++; } } if(group.second == unacked) { for(PerKeyPacket& packet : frame_packets) { if(!packet.acked && packet.function == group.first) { packet.acked = true; frame_acked_zones.insert(frame_acked_zones.end(), packet.zone_ids.begin(), packet.zone_ids.end()); } } } } result.acked_zones = std::move(frame_acked_zones); /*-----------------------------------------------------*\ | Adapt the write window. ACK loss is backpressure | | too: a sent packet that never answered means | | this link outran the device's response | | generation, halve the window. A run of loss-free | | frames earns it back one step at a time. | \*-----------------------------------------------------*/ if(!frame_packets.empty()) { size_t lost = 0; for(const PerKeyPacket& packet : frame_packets) { if(!packet.acked) { lost++; } } if(lost > 0) { perkey_clean_frames = 0; perkey_acks_lost += lost; if(perkey_window > HIDPP20_PERKEY_WINDOW_MIN) { perkey_window /= 2; if(perkey_window < HIDPP20_PERKEY_WINDOW_MIN) { perkey_window = HIDPP20_PERKEY_WINDOW_MIN; } } } else if(perkey_window < HIDPP20_PERKEY_WINDOW_MAX && ++perkey_clean_frames >= HIDPP20_PERKEY_WINDOW_GROW_AFTER) { perkey_clean_frames = 0; perkey_window++; } } bool complete = result.frame_end_acked && (result.acked_zones.size() == result.attempted_zones.size()); if(!complete) { /*-------------------------------------------------*\ | Late ACKs from this frame may still be in | | flight. Consume them so the next frame's | | matching starts clean. | \*-------------------------------------------------*/ DrainStaleResponses(); } /*-----------------------------------------------------*\ | Link quality. A lost ACK or an unfinished frame is | | the link's normal backpressure and the next frame | | resends what did not land, so single frames are not | | logged. Counts are summarized once per interval, | | and only when something was lost. | \*-----------------------------------------------------*/ if(!result.attempted_zones.empty()) { perkey_frames_seen++; if(frame_aborted) { perkey_frames_aborted++; } else if(!complete) { perkey_frames_partial++; } if(!result.frame_end_acked) { perkey_frames_no_end++; } std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); if(perkey_summary_time == std::chrono::steady_clock::time_point()) { perkey_summary_time = now; } else if(now - perkey_summary_time >= std::chrono::seconds(HIDPP20_PERKEY_SUMMARY_S)) { if(perkey_frames_partial > 0 || perkey_frames_aborted > 0 || perkey_frames_no_end > 0 || perkey_acks_lost > 0) { LOG_DEBUG("%s per-key link, last %ds: %zu frames, %zu partial, %zu aborted, " "%zu without FrameEnd ack, %zu ACKs lost, window %zu", LOG_TAG, HIDPP20_PERKEY_SUMMARY_S, perkey_frames_seen, perkey_frames_partial, perkey_frames_aborted, perkey_frames_no_end, perkey_acks_lost, perkey_window); } perkey_frames_seen = 0; perkey_frames_partial = 0; perkey_frames_aborted = 0; perkey_frames_no_end = 0; perkey_acks_lost = 0; perkey_summary_time = now; } } ResetPerKeyFrameState(); /*-----------------------------------------------------*\ | Deep-sleep detection: FrameEnd failures while | | SLEEPING may mean the firmware fade finished. | | Count consecutive failures (an aborted frame | | counts, a stalled stream and a sleeping device | | look alike); at the threshold, suppress sends | | until Wake(). Any ACK resets the count. | \*-----------------------------------------------------*/ 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); } } return result; } /*---------------------------------------------------------*\ | Feature 0x8080 set + commit: fn3 SetKeyColors on the | | 0x12 very-long report via dev_perkey_vl, payload | | [keyType u16 BE, count u16 BE, (keyId,R,G,B) x | | count], <=14 tuples per packet (larger groups | | split). Colors appear only after fn5 FlushLEDS. | \*---------------------------------------------------------*/ void LogitechHIDPP20Controller::SetPerKey8080 ( const std::vector>>>& by_type ) { if(caps.idx_perkey_8080 == 0 || dev_perkey_vl == nullptr) { return; } bool kt40_id12_only = (caps.quirks & HIDPP20_QUIRK_8080_KEYTYPE40_ID12_ONLY) != 0; for(size_t t = 0; t < by_type.size(); t++) { uint16_t key_type = by_type[t].first; const std::vector>& src_keys = by_type[t].second; /*-------------------------------------------------*\ | G410 keyType 0x40 accepts only keyId 1 | | and 2; drop any others before encoding. | \*-------------------------------------------------*/ std::vector> filtered; const std::vector>* keys = &src_keys; if(kt40_id12_only && key_type == 0x40) { for(size_t k = 0; k < src_keys.size(); k++) { if(src_keys[k].first == 1 || src_keys[k].first == 2) { filtered.push_back(src_keys[k]); } } keys = &filtered; } for(size_t off = 0; off < keys->size(); off += HIDPP20_8080_KEYS_PER_FRAME) { size_t count = keys->size() - off; if(count > HIDPP20_8080_KEYS_PER_FRAME) { count = HIDPP20_8080_KEYS_PER_FRAME; } /*---------------------------------------------*\ | Payload: keyType(u16 BE) + count(u16 BE) + | | count x (keyId, R, G, B). SendVeryLongFrame | | prepends the 4-byte HID++ header. | \*---------------------------------------------*/ uint8_t payload[LOGITECH_VERY_LONG_MESSAGE_LEN - 4]; memset(payload, 0, sizeof(payload)); payload[0] = (uint8_t)(key_type >> 8); payload[1] = (uint8_t)(key_type & 0xFF); payload[2] = (uint8_t)(count >> 8); payload[3] = (uint8_t)(count & 0xFF); size_t pos = 4; for(size_t k = 0; k < count; k++) { RGBColor color = (*keys)[off + k].second; payload[pos++] = (*keys)[off + k].first; /* keyId (USB HID usage) */ payload[pos++] = RGBGetRValue(color); payload[pos++] = RGBGetGValue(color); payload[pos++] = RGBGetBValue(color); } SendVeryLongFrame(caps.idx_perkey_8080, FN_8080_SET_KEY_COLORS, payload, pos); } } } /*---------------------------------------------------------*\ | fn5 FlushLEDS: commit. Empty body (persist = default), | | sent on the 0x11 long report via dev. | \*---------------------------------------------------------*/ void LogitechHIDPP20Controller::PerKeyCommit8080() { if(caps.idx_perkey_8080 == 0) { return; } uint8_t recv[20] = {}; int result = SendAcked(caps.idx_perkey_8080, FN_8080_FLUSH_LEDS, nullptr, 0, recv, sizeof(recv), HIDPP20_POLICY_RELIABLE); if(result <= 0) { LOG_DEBUG("%s 0x8080 FlushLEDS commit not acked (result=%d), " "colors may not have latched", LOG_TAG, 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. Shared across pages: | | 0x8070's func-3 Static tail is R, G, B, | | 0x02 at byte 5, same position as 0x8071. | \*---------------------------------------------*/ 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 0x0007: // Audio visualizer /*------------------------------------------------------*\ | Effect param layout (10 bytes, indices into data[]): | | data[2] = control: 0 = fixed color, 1 = cycle | | colors (factory default); >=2 stops | | the audio pulse | | data[3..5] = R, G, B (fixed-color pulse) | | data[7..8] = periodHi, periodLo (BE16 milliseconds) | \*------------------------------------------------------*/ if(r != 0 || g != 0 || b != 0) { data[2] = 0x00; data[3] = r; data[4] = g; data[5] = b; } else { data[2] = 0x01; } data[7] = (period >> 8) & 0xFF; data[8] = period & 0xFF; break; case 0x0004: // Color Wave /*---------------------------------------------*\ | Plain (non-saturation) wave: the period is | | split NON-contiguously and there is no | | saturation byte: | | data[8] period low | | data[9] direction (1..8) | | data[10] brightness 1..100 | | data[11] period high | | Distinct from the 0x0016 saturation-wave | | layout below (saturation@3, period BE@8..9, | | direction@11); mixing them gets | | InvalidArgument. | \*---------------------------------------------*/ data[8] = period & 0xFF; data[9] = direction; data[10] = brightness ? brightness : 1; data[11] = (period >> 8) & 0xFF; break; case 0x000B: // Ripple data[2] = r; data[3] = g; data[4] = b; data[6] = (period >> 8) & 0xFF; data[7] = period & 0xFF; break; case 0x000E: // Decomposition: no color; period BE@8-9, intensity@10 data[8] = (period >> 8) & 0xFF; data[9] = period & 0xFF; data[10] = brightness; break; case 0x000F: // Signature1: no color; period BE@7-8, intensity@9 case 0x0010: // Signature2: same layout data[7] = (period >> 8) & 0xFF; data[8] = period & 0xFF; data[9] = brightness; 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; } /*-----------------------------------------------------*\ | Inline persist byte at [12]: Persistence bits 1-0, | | Power bits 2-3. Only a Save sets it, so live painting | | stays volatile. | \*-----------------------------------------------------*/ data[12] = persist ? 0x01 : 0x00; blankFAPmessage response; SendAckedIntoFAP(caps.idx_rgb_effects, caps.fn_set_effect, data, 16, response); } /*---------------------------------------------------------*\ | Feature 0x0620 Headset RGB Hostmode: static color write. | | | | Claim is sticky from SetHostMode(); this function | | only writes colors + FrameEnd. Picks fn5 | | SetRgbZonesSingleValue when all zones share a | | color, else fn2 SetIndividualRgbZones. FrameEnd | | byte 0 is always 0x01 (transient), 0x02 was | | tested and does not work on G522 firmware. | \*---------------------------------------------------------*/ void LogitechHIDPP20Controller::SetHeadsetRGBHostmodeColors ( const std::vector& zone_colors ) { if(caps.idx_headset_rgb_hostmode == 0 || !device_online.load()) { return; } const std::vector& zones = caps.headset_rgb_hostmode_zone_ids; if(zones.empty()) { return; } if(zone_colors.empty()) { return; } /*-----------------------------------------------------*\ | Group zones by color, one fn5 per distinct color, | | then one FrameEnd: Solaar's write_zone_map byte-for- | | byte. Host mode is already claimed (sticky); the | | frame is data + FrameEnd only. Zone i takes | | zone_colors[i], the last color filling any shortfall. | \*-----------------------------------------------------*/ std::map> groups; for(size_t i = 0; i < zones.size(); i++) { RGBColor c = (i < zone_colors.size()) ? zone_colors[i] : zone_colors.back(); groups[c].push_back(zones[i]); } blankFAPmessage response; for(const std::pair>& group : groups) { RGBColor color = group.first; const std::vector& ids = group.second; /*-------------------------------------------------*\ | fn5 SetRgbZonesSingleValue: | | [R, G, B, count, zones...] | \*-------------------------------------------------*/ uint8_t payload[16]; payload[0] = RGBGetRValue(color); payload[1] = RGBGetGValue(color); payload[2] = RGBGetBValue(color); size_t n = ids.size(); if(n > sizeof(payload) - 4) n = sizeof(payload) - 4; payload[3] = (uint8_t)n; for(size_t i = 0; i < n; i++) { payload[4 + i] = ids[i]; } SendAckedIntoFAP(caps.idx_headset_rgb_hostmode, FN_0620_SET_RGB_ZONES_SINGLE_VALUE, payload, 4 + n, 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); } /*---------------------------------------------------------*\ | Power management (idle/dim/sleep/wake) | | | | Matches Solaar's RGBPowerManager state machine: | | ACTIVE -> DIMMING -> IDLE -> SLEEPING | | | | Uses firmware onUserActivity events from 0x8071 for | | idle/active detection. SW control flags cycle: | | 7 (init) -> 5 (active, monitor idle) -> | | 3 (idle, monitor active) -> 5 (wake) | \*---------------------------------------------------------*/ void LogitechHIDPP20Controller::SetRepaintCallback(std::function repaint) { request_repaint_fn = repaint; } void LogitechHIDPP20Controller::SetReapplyActiveModeCallback(std::function cb) { reapply_active_mode_fn = cb; } void LogitechHIDPP20Controller::SetPairingName(const std::string& name) { if(!LogitechHIDPP20Controller::NameLooksReal(name)) { return; } pairing_name = name; /*-----------------------------------------------------*\ | Label the controller while the device is asleep. | | The real 0x0005 name read replaces this once the | | device answers. | \*-----------------------------------------------------*/ if(caps.device_name.empty()) { caps.device_name = name; log_tag = "[LogitechHID++ " + name + "]"; } } 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 the 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); /*-----------------------------------------------------*\ | Let the sub-device 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; } /*-----------------------------------------------------*\ | The sub-device's real name only becomes | | readable now that it's reachable, so always re- | | discover, but never let a failed re-read | | downgrade a good name to a placeholder. | \*-----------------------------------------------------*/ std::string old_name = caps.device_name; DiscoverDeviceName(); if(HIDPP20NameIsPlaceholder(caps.device_name) && !HIDPP20NameIsPlaceholder(old_name)) { caps.device_name = old_name; } 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(); DiscoverPerKey8080(); DiscoverKeyboardLayout(); if(!caps.has_zone_effects && !caps.has_perkey && !caps.has_perkey_8080) { 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()); /*-----------------------------------------------------*\ | Bring the sub-device back up on the existing | | controller. A dongle with no sub-device at | | detection never got a controller, so | | DetectionManager re-probes until one answers. | \*-----------------------------------------------------*/ Initialize(); } /*---------------------------------------------------------*\ | Take software control and retry until it sticks: a | | device that just came back is booting its onboard | | profile, and a claim in that window is dropped silently; | | we would paint at a device that is not listening. Retry | | on a fast backoff until ReapplyActiveMode reports the | | claim ACKed (~50ms in practice). | \*---------------------------------------------------------*/ bool LogitechHIDPP20Controller::ReclaimSWControl(const char* reason, bool force) { /*-----------------------------------------------------*\ | force (reconnect): the device just rebooted its | | onboard profile, so any claim flag is stale, tear | | it down so every attempt runs the whole sequence | | instead of believing it already holds control. | | | | !force: a set flag means the effect engine's own | | repaint re-claimed here first. Leave it: redoing the | | flags 6->5 claim re-exposes the firmware's onboard | | colors for ~50ms, a visible flash. The reapply below | | still runs DeviceUpdateMode (per-key prep lands | | either way); the ClaimSWControlIfNeeded inside it | | no-ops while claimed. | \*-----------------------------------------------------*/ if(force || !sw_control_claimed) { sw_control_claimed = false; sw_control_needs_upgrade_to_5 = false; prep_applied = false; retry_paint_deadline_.store(std::chrono::steady_clock::time_point{}); retry_paint_attempt_.store(0); } 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) { InterruptibleBackoff(HIDPP20_RECLAIM_BACKOFF_MS[i]); } if(!device_online.load()) { return false; } if(teardown_pending.load()) { LOG_DEBUG("%s SW control claim abandoned, link change pending", LOG_TAG); return false; } if(reapply_active_mode_fn && reapply_active_mode_fn()) { LOG_INFO("%s SW control claimed after %s (attempt %zu/%zu)", LOG_TAG, reason, i + 1, attempt_count); return true; } } LOG_WARNING("%s SW control claim failed after %s (%zu attempts); the device is still " "showing its onboard profile", LOG_TAG, reason, attempt_count); return false; } 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). | | | | Both firmware events (reconnect=1/config_needed=1 | | then config_needed=0) drive the same retry loop. One | | push per event is not enough: it either races the | | boot animation or lands after it, and an unretried | | claim lets the 10s firmware watchdog drop the device | | back to onboard mode. | \*-----------------------------------------------------*/ LOG_DEBUG("%s Reconnecting device", LOG_TAG); FlushResponseQueue(); bool first_event = !device_online.load(); if(first_event) { device_online.store(true); consecutive_timeouts.store(0); frame_counter = 0; { std::lock_guard lock(power_mutex); dim_brightness_pct.store(100); power_state = HIDPP20_POWER_ACTIVE; } } last_fap_error_ = 0; ReclaimSWControl("reconnect", true); /*-----------------------------------------------------*\ | Catch a stale restored map whose reclaim raced the | | radio and didn't surface the mismatch. | \*-----------------------------------------------------*/ HealStaleMapIfNeeded("reconnect (post-heal)"); if(first_event && caps.has_power_mgmt) { ReadFirmwareTimers(); ReadNvSleepRampConfig(); } } bool LogitechHIDPP20Controller::DiscoveryInProgress() const { return discovery_in_progress_.load(); } void LogitechHIDPP20Controller::RediscoverFeatures() { /*-----------------------------------------------------*\ | Clear the cached feature map and its derivations, | | then re-run discovery on the current handle. Each | | link assigns different indices to the same | | features (G515: RGBEffects at 0x09 wireless, | | elsewhere on USB); a stale map turns every cached | | lookup into error 0x07. Caller owns state outside | | the map (claim flag, counters, threads). | | | | Fence the effect engine out during discovery: | | has_perkey and the zone/effect data flap false, so a | | paint/claim landing here would reach flags=5 with an | | empty per-key layer. DeviceUpdateLEDs gates on this. | \*-----------------------------------------------------*/ discovery_in_progress_.store(true); 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; /*-----------------------------------------------------*\ | Clearing the index disarms the broadcast match until | | a read resolves it again, and the source often | | changed with the link. | \*-----------------------------------------------------*/ pending_power_check.store(true); /*-----------------------------------------------------*\ | 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(); DiscoverPerKey8080(); DiscoverKeyboardLayout(); /*-----------------------------------------------------*\ | Discovery done and the map is whole, so let the | | effect engine paint again and cache this link | | for a switch-back. | \*-----------------------------------------------------*/ discovery_in_progress_.store(false); CacheCurrentLinkIndexMap(); } /*---------------------------------------------------------*\ | Per-link feature-index cache: indices are assigned per | | transport, so a switch-back restores instead of | | rediscovering. | \*---------------------------------------------------------*/ std::string LogitechHIDPP20Controller::CurrentLinkKey() const { /*-----------------------------------------------------*\ | Key the link: rx#page#slot over the dongle, | | bt#page#idx over a Bluetooth radio, usb#page#idx on a | | cable or a dongle of the device's own. hidraw paths | | are reused by the kernel so aren't used. Every direct | | link is device index 0xFF, so the page is what | | separates two of them, a cable at 0xFF00 from a | | Centurion dongle at 0xFFA0. A slot collision across | | dongles is caught by the reclaim self-heal. | \*-----------------------------------------------------*/ const char* link = wireless ? "rx#" : transport.bluetooth ? "bt#" : "usb#"; char key[32]; snprintf(key, sizeof(key), "%s%04X#%d", link, transport.usage_page, (int)device_index); return std::string(key); } HIDPP20LinkIndexMap LogitechHIDPP20Controller::SnapshotLinkIndexMap() const { /*-----------------------------------------------------*\ | Snapshot all of caps, not just indices: the effect- | | card template, zone clusters and per-key data are | | cleared and rediscovered per link too, so a partial- | | index restore would run prep with an empty template. | \*-----------------------------------------------------*/ HIDPP20LinkIndexMap m; m.caps = caps; m.idx_unified_battery = idx_unified_battery; m.valid = true; return m; } void LogitechHIDPP20Controller::RestoreLinkIndexMap(const HIDPP20LinkIndexMap& m) { caps = m.caps; idx_unified_battery = m.idx_unified_battery; } void LogitechHIDPP20Controller::CacheCurrentLinkIndexMap() { /*-----------------------------------------------------*\ | Skip an empty map and Centurion bridge sub-devices | | (their indices route through the bridge). | \*-----------------------------------------------------*/ if(caps.idx_rgb_effects == 0 || transport.bridge_feat_idx != 0) { return; } /*-----------------------------------------------------*\ | Don't cache a partial discovery over a good | | entry: a flaky link can find RGB effects but | | read back an empty effect-card template. | \*-----------------------------------------------------*/ if(caps.has_effect_cards && caps.effect_card_template[0] == 0 && caps.effect_card_template[1] == 0) { return; } link_index_cache_[CurrentLinkKey()] = SnapshotLinkIndexMap(); } void LogitechHIDPP20Controller::HealStaleMapIfNeeded(const char* reclaim_reason) { /*-----------------------------------------------------*\ | A reclaim hitting 0x06/0x07 means the loaded map is | | wrong (re-paired slot). Drop the entry, rediscover, | | reclaim once. No re-check, so it can't loop. A | | no-response failure leaves last_fap_error_ 0, so this | | only fires on a real index mismatch. | \*-----------------------------------------------------*/ if(last_fap_error_ != 0x06 && last_fap_error_ != 0x07) { return; } LOG_WARNING("%s Feature map for %s rejected (err 0x%02X), rediscovering", LOG_TAG, CurrentLinkKey().c_str(), last_fap_error_); link_index_cache_.erase(CurrentLinkKey()); RediscoverFeatures(); last_fap_error_ = 0; ReclaimSWControl(reclaim_reason, false); } void LogitechHIDPP20Controller::StartEventWatcher() { /*-----------------------------------------------------*\ | Reader and power threads without power management, | | for devices whose only events are WirelessStatus, | | and for receiver-slot devices with no events of | | their own, so the node watcher's connection nudge | | lands on a power thread. | \*-----------------------------------------------------*/ if(reader_running) { return; } 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 Event watcher started", LOG_TAG); } void LogitechHIDPP20Controller::StartPowerManager() { if(caps.idx_rgb_effects == 0 || !caps.has_power_mgmt) { return; } if(reader_running) { return; } /*-----------------------------------------------------*\ | Cache the boot link's index map (initial | | discovery ran via Initialize, not | | RediscoverFeatures) so the first switch away and | | back restores instead of rediscovering. | \*-----------------------------------------------------*/ CacheCurrentLinkIndexMap(); 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(); last_power_poll = last_idle_poll; pending_power_check.store(false); /*-----------------------------------------------------*\ | 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 the 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; } /*---------------------------------------------*\ | Feature 0x1D4B event 0: WirelessStatus | | Device reconnected after power cycle. Use | | cached map lookup only, reader thread | | must never send commands (deadlock risk). | \*---------------------------------------------*/ { std::map::const_iterator it = caps.feature_map.find(0x1D4B); uint8_t ws_idx = (it != caps.feature_map.end()) ? it->second : 0; if(ws_idx != 0 && feat == ws_idx && (func & 0xF0) == 0x00 && (func & 0x0F) != HIDPP20_SW_ID) { uint8_t reconnect = data[0]; uint8_t config_needed = data[1]; LOG_DEBUG("%s WirelessStatus event: reconnect=%d config_needed=%d", LOG_TAG, reconnect, config_needed); /*-------------------------------------*\ | Forward both events to the power | | thread. Each call into | | ReconnectDevice runs the fast- | | backoff reclaim loop, so the | | second event acts as a belt-and- | | suspenders re-claim once the | | firmware boot fully settles. | \*-------------------------------------*/ pending_connection.store(1); continue; } } /*---------------------------------------------*\ | Feature 0x1004 broadcast: the power source | | changed. Flag a re-read rather than decode | | the event, so GetStatus stays the only | | reader of the layout. Cached index only, | | this thread must never send commands. | \*---------------------------------------------*/ if(idx_unified_battery != 0 && feat == idx_unified_battery && (func & 0x0F) != HIDPP20_SW_ID) { pending_power_check.store(true); continue; } /*---------------------------------------------*\ | Only queue responses to OUR commands. Our | | commands use HIDPP20_SW_ID (0x0A) in the | | low nibble. Firmware-generated messages | | (battery, sync, etc.) use SW_ID 0, drop | | those silently. Error responses | | (feat=0xFF) are always queued. | \*---------------------------------------------*/ if(feat != 0xFF && (func & 0x0F) != HIDPP20_SW_ID) { continue; } { std::lock_guard lock(response_mutex); HIDPP20RawMessage msg; msg.feat = feat; msg.func = func; msg.result = result; memcpy(msg.data, data, sizeof(msg.data)); response_queue.push_back(msg); } response_cv.notify_all(); } } } void LogitechHIDPP20Controller::PowerThreadFunc() { /*-----------------------------------------------------*\ | Handles power state machine and sends commands. Reads | | responses from the queue (filled by reader thread). | \*-----------------------------------------------------*/ while(power_thread_running.load()) { /*-------------------------------------------------*\ | 1. Check for pending firmware events | \*-------------------------------------------------*/ int activity = pending_activity.exchange(-1); if(activity >= 0) { std::lock_guard lock(power_mutex); OnUserActivity((uint8_t)activity); } /*-------------------------------------------------*\ | 1b. Check for connection state changes | \*-------------------------------------------------*/ int connection = pending_connection.exchange(0); if(connection > 0) { if(HasBridge()) { ReprobeSubDevice(); } else { ReconnectDevice(); } } else if(connection < 0) { LOG_DEBUG("%s Device disconnected", LOG_TAG); device_online.store(false); } /*-------------------------------------------------*\ | 2. Power management timing | \*-------------------------------------------------*/ { std::lock_guard lock(power_mutex); switch(power_state) { case HIDPP20_POWER_DIMMING: { std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); if(now >= next_dim_time) { DimRampStep(); next_dim_time = now + std::chrono::milliseconds(DIM_INTERVAL_MS); } break; } case HIDPP20_POWER_IDLE: /*-------------------------------------*\ | Poll dim brightness target, if the | | user is dragging the slider, | | ps_dim_target_pct updates in- | | memory and we pick it up here on | | the next 50ms tick without any | | callback/repaint chain. | | | | Gated on ps_dim_enabled: a profile | | (or the default unconfigured | | fallback) can enter IDLE state via | | the skip-dim path in OnUserActivity, | | and we must not dim in that case: | | only sleep when the deadline hits. | \*-------------------------------------*/ if(ps_dim_enabled && dim_brightness_pct.load() != ps_dim_target_pct) { dim_brightness_pct.store(ps_dim_target_pct); if(request_repaint_fn) { request_repaint_fn(); } } if(sleep_timeout_s > 0 && ps_sleep_enabled && std::chrono::steady_clock::now() >= sleep_deadline) { StartSleep(); } break; default: break; } } /*-------------------------------------------------*\ | Read the power source on the device's broadcast. | | Each read is a 0x1004 GetStatus on the link that | | also carries paint, so the interval is only the | | backstop for devices that do not broadcast and | | for changes made while asleep. | | | | The idle-settings reload keeps its own fast tick: | | it is an in-memory lookup and only reaches the | | wire when a timer value changes. | \*-------------------------------------------------*/ if(caps.has_power_mgmt) { std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); if(pending_power_check.exchange(false) || now - last_power_poll >= std::chrono::milliseconds(POWER_POLL_INTERVAL_MS)) { last_power_poll = now; QueryExternalPower(); } if(now - last_idle_poll >= std::chrono::milliseconds(500)) { last_idle_poll = now; 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::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 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() when the device broadcasts a | | change, and on the backstop interval. | \*-----------------------------------------------------*/ const LogitechHIDPP20IdleProfile& profile = ps_on_external_power ? settings->pluggedIn() : settings->onBattery(); ps_dim_enabled = profile.dim_when_idle; ps_dim_target_pct = profile.dim_when_idle ? profile.dim_brightness : DIM_TARGET_PCT; ps_sleep_enabled = profile.allow_sleep; if(profile.dim_when_idle) { idle_timeout_s = (uint16_t)profile.idle_timeout_s; } /* else: idle_timeout_s stays at fw_idle_timeout_s from above */ if(profile.allow_sleep) { sleep_timeout_s = (uint16_t)profile.sleep_timeout_s; } else { /*-------------------------------------------------*\ | Signal "don't sleep" to the state machine. | | The IDLE branch of PowerThreadFunc gates | | on sleep_timeout_s>0. | \*-------------------------------------------------*/ sleep_timeout_s = 0; } /*-----------------------------------------------------*\ | Write our timer values to the device RAM so | | the firmware's idle detection aligns with | | our host-side state machine. Only writes | | when values actually change to avoid | | spamming the bus on every 500ms poll tick. | \*-----------------------------------------------------*/ if(idle_timeout_s != written_idle_s || sleep_timeout_s != written_sleep_s) { WritePowerConfig(idle_timeout_s, sleep_timeout_s); written_idle_s = idle_timeout_s; written_sleep_s = sleep_timeout_s; } if(prev_dim != ps_dim_enabled || prev_sleep != ps_sleep_enabled || ps_last_logged_pct != ps_dim_target_pct || ps_last_logged_idle != (int)idle_timeout_s || ps_last_logged_sleep != (int)sleep_timeout_s || ps_last_logged_external != ps_on_external_power) { ps_last_logged_pct = ps_dim_target_pct; ps_last_logged_idle = idle_timeout_s; ps_last_logged_sleep = sleep_timeout_s; ps_last_logged_external = ps_on_external_power; LOG_DEBUG("%s Idle management: power=%s dim=%s(%d%%) idle=%us sleep=%s(%us)", LOG_TAG, ps_on_external_power ? "external" : "battery", ps_dim_enabled ? "on" : "off", ps_dim_target_pct, idle_timeout_s, ps_sleep_enabled ? "on" : "off", sleep_timeout_s); } } void LogitechHIDPP20Controller::FlushResponseQueue() { { std::lock_guard lock(response_mutex); response_queue.clear(); } /*-----------------------------------------------------*\ | Before the reader thread exists, stale frames sit in | | the hidraw fd buffer. A HID++ reply carries nothing | | tying it to its request, every IRoot answer has the | | same 0x00/0x00 header, so a late reply from a timed- | | out call is accepted as the next call's answer and | | every lookup after it is off by one. Drain first. | \*-----------------------------------------------------*/ if(reader_running.load() || dev == nullptr) { return; } uint8_t scratch[64]; for(int drained = 0; drained < 64; drained++) { if(hid_read_timeout(dev, scratch, sizeof(scratch), 0) <= 0) { break; } } } 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(); /*-------------------------------------------------*\ | flags=3 (EFFECT|POWER): keep effect control | | and monitor for user activity. TODO: Solaar | | uses 0x02 idle / 0x04 active and still gets | | onUserActivity, our EFFECT bit may be | | unneeded. Test on hardware before changing. | \*-------------------------------------------------*/ 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 | | reports user input. Works for DIMMING, IDLE and | | SLEEPING uniformly; the only wrinkle is that | | SLEEPING means we previously sent | | SetRgbPowerMode(3) to start the fade, so we must | | cancel it with SetRgbPowerMode(1) first. | | | | Per the 0x8071 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 and per-key prep. | | 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: reruns ClaimSWControlIfNeeded -> | | SetOnboardMode -> per-key prep sequence -> | | DeviceUpdateMode). On 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 so | | 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/lo, sleep_hi/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 7x 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; it is hardcoded because this path is diagnostic. | \*----------------------------------------------------------*/ 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); } }