diff --git a/Controllers/LogitechController/LogitechControllerDetect.cpp b/Controllers/LogitechController/LogitechControllerDetect.cpp index d5c78bd77..febde2241 100644 --- a/Controllers/LogitechController/LogitechControllerDetect.cpp +++ b/Controllers/LogitechController/LogitechControllerDetect.cpp @@ -7,14 +7,17 @@ | SPDX-License-Identifier: GPL-2.0-or-later | \*---------------------------------------------------------*/ +#include +#include #include #include #include "DetectionManager.h" #include "LogManager.h" +#include "ResourceManager.h" +#include "SettingsManager.h" #include "LogitechProtocolCommon.h" #include "LogitechG203LController.h" #include "LogitechG213Controller.h" -#include "LogitechG560Controller.h" #include "LogitechG600Controller.h" #include "LogitechG933Controller.h" #include "LogitechG810Controller.h" @@ -27,7 +30,6 @@ #include "LogitechX56Controller.h" #include "RGBController_LogitechG203L.h" #include "RGBController_LogitechG213.h" -#include "RGBController_LogitechG560.h" #include "RGBController_LogitechG600.h" #include "RGBController_LogitechG933.h" #include "RGBController_LogitechG810.h" @@ -41,9 +43,12 @@ #include "RGBController_LogitechGPowerPlay.h" // Linux-only #include "RGBController_LogitechX56.h" #include "LogitechHIDPP20Controller.h" +#include "LogitechHIDPP20ReceiverWatcher.h" #include "RGBController_LogitechHIDPP20.h" #include "StringUtils.h" +#include + using namespace std::chrono_literals; /*---------------------------------------------------------*\ @@ -105,11 +110,6 @@ using namespace std::chrono_literals; \*---------------------------------------------------------*/ #define LOGITECH_G_LIGHTSPEED_POWERPLAY_PID 0xC53A -/*---------------------------------------------------------*\ -| Speaker product IDs | -\*---------------------------------------------------------*/ -#define LOGITECH_G560_PID 0x0A78 - /*---------------------------------------------------------*\ | Headset product IDs | \*---------------------------------------------------------*/ @@ -423,11 +423,21 @@ DetectedControllers DetectLogitechKeyboardG915Receiver2(hid_device_info* info, c if(ok) { - /*-------------------------------------------------*\ - | Route based on probed name. Check for TKL before | - | full G915 since both contain "G915". | - \*-------------------------------------------------*/ - if(probed_name.find("G915 TKL") != std::string::npos) + /*---------------------------------------------*\ + | Route on probed name. G915 X first, then | + | TKL before full G915 (all contain "G915"). | + \*---------------------------------------------*/ + if(probed_name.find("G915 X") != std::string::npos) + { + /*-----------------------------------------*\ + | G915 X family: the unified HID++ 2.0 | + | controller handles it. Skip so the C547 | + | detector claims it. | + \*-----------------------------------------*/ + LOG_DEBUG("[LogitechControllerDetect] 0xC547 G915 X -> unified controller, skipping legacy"); + hid_close(dev); + } + else if(probed_name.find("G915 TKL") != std::string::npos) { LogitechG915Controller* controller = new LogitechG915Controller(dev, false, name); RGBController_LogitechG915* rgb_controller = new RGBController_LogitechG915(controller, true); @@ -692,27 +702,6 @@ DetectedControllers DetectLogitechMouseGPRO(hid_device_info* info, const std::st /*---------------------------------------------------------*\ | Other Logitech Devices | \*---------------------------------------------------------*/ -DetectedControllers DetectLogitechG560(hid_device_info* info, const std::string& name) -{ - DetectedControllers detected_controllers; - hid_device* dev; - - dev = hid_open_path(info->path); - - if(dev) - { - /*-------------------------------------------------*\ - | Add G560 Speaker | - \*-------------------------------------------------*/ - LogitechG560Controller* controller = new LogitechG560Controller(dev, info->path, name); - RGBController_LogitechG560* rgb_controller = new RGBController_LogitechG560(controller); - - detected_controllers.push_back(rgb_controller); - } - - return(detected_controllers); -} - DetectedControllers DetectLogitechG933(hid_device_info* info, const std::string& name) { DetectedControllers detected_controllers; @@ -755,221 +744,945 @@ DetectedControllers DetectLogitechX56(hid_device_info* info, const std::string& return(detected_controllers); } -/*------------------------------------------------------------------------------*\ -| Unified HID++ 2.0 Detection | -| Probes IRoot (feature 0x0000) to determine if the device speaks HID++ 2.0. | -| If it does and has RGB features, the unified controller handles it. | -| If not, the device is released for legacy controllers. | -\*------------------------------------------------------------------------------*/ -DetectedControllers DetectLogitechHIDPP20(hid_device_info* info, const std::string& /*name*/) +/*---------------------------------------------------------*\ +| Group the nodes of one physical device by path. Windows | +| splits a multi-collection HID interface into one node per | +| collection (the RAP usage-1 and FAP usage-2 handles are | +| separate nodes); the paths differ only in the collection | +| token, so stripping it names the physical device. On | +| Linux/macOS the interface is one node and a path names | +| itself. | +\*---------------------------------------------------------*/ +static std::string LogitechDevicePathKey(const char* path) { - DetectedControllers detected_controllers; - hid_device* dev; + std::string key = (path != nullptr) ? path : ""; - dev = hid_open_path(info->path); - - if(dev) + for(size_t pos = 0; pos + 4 <= key.size(); pos++) { - LogitechHIDPP20Controller* controller = new LogitechHIDPP20Controller(dev, info->path, LOGITECH_DEFAULT_DEVICE_INDEX, false, nullptr, info->usage_page); - - if(controller->Probe()) + if((key[pos] == '&') + && (key[pos + 1] == 'c' || key[pos + 1] == 'C') + && (key[pos + 2] == 'o' || key[pos + 2] == 'O') + && (key[pos + 3] == 'l' || key[pos + 3] == 'L')) { - controller->Initialize(); + size_t end = pos + 4; - const HIDPP20DeviceCapabilities& caps = controller->GetCapabilities(); - - if(caps.has_zone_effects || caps.has_perkey) + while(end < key.size() && isxdigit((unsigned char)key[end])) { - /*-------------------------------------------------*\ - | Device has RGB features — create and register | - | RGBController for the UI. | - \*-------------------------------------------------*/ - RGBController_LogitechHIDPP20* rgb_controller = new RGBController_LogitechHIDPP20(controller); - - detected_controllers.push_back(rgb_controller); - - /*--------------------------------------------------*\ - | Start reader + power threads immediately so we | - | detect connection events and handle power mgmt | - | from the start — not deferred to DeviceUpdateMode. | - \*--------------------------------------------------*/ - if(caps.has_power_mgmt || caps.idx_wireless_status != 0) - { - controller->StartPowerManager(); - - if(!caps.has_power_mgmt && caps.idx_wireless_status != 0) - { - controller->StartEventWatcher(); - } - } + end++; } - else if(controller->HasBridge()) + + key.erase(pos, end - pos); + break; + } + } + + return key; +} + +usages BundleLogitechUsages(hid_device_info* info) +{ + /*-----------------------------------------------------*\ + | Grab every usage of the device that triggered this | + | callback (usage 1, 2 and 4 on normal FAP devices). | + | Match by path, not VID/PID: two identical receivers | + | share a VID/PID, and bundling their handles sent | + | one receiver's pairing-table read to the other. | + \*-----------------------------------------------------*/ + usages temp_usages; + + std::string device_key = LogitechDevicePathKey(info->path); + hid_device_info* temp_info = hid_enumerate(info->vendor_id, info->product_id); + hid_device_info* enumerated = temp_info; + + while(temp_info) + { + if(temp_info->interface_number == 2 + && LogitechDevicePathKey(temp_info->path) == device_key) + { + LOG_DEBUG("Attempting to open dev path: %s", temp_info->path); + hid_device* dev = hid_open_path(temp_info->path); + + if(dev) { - /*--------------------------------------------------*\ - | Centurion dongle with no sub-device — keep the | - | controller alive and start reader thread to watch | - | for sub-device connection events. | - \*--------------------------------------------------*/ -// TODO: this behavior does not work on hotplug-aware DetectionManager -// LOG_INFO("[%s] Dongle registered, watching for sub-device", -// caps.device_name.c_str()); -// -// controller->SetRegisterCallback([](RGBController* rgb) -// { -// DetectionManager::get()->RegisterRGBController(rgb); -// }); -// -// controller->StartEventWatcher(); + LOG_DEBUG("Success! Adding Usage %i for device @ path %s", temp_info->usage, temp_info->path); + temp_usages.emplace((uint8_t)temp_info->usage, dev); } else { - /*--------------------------------------------------*\ - | Device probed successfully but has no RGB and no | - | bridge — nothing to do (e.g., headset without RGB) | - \*--------------------------------------------------*/ - LOG_INFO("[%s] No RGB features, skipping", caps.device_name.c_str()); - delete controller; + LOG_INFO("FAILED! Can not add Usage %i for device @ path %s", temp_info->usage, temp_info->path); } } - else + temp_info = temp_info->next; + } + + hid_free_enumeration(enumerated); + + return temp_usages; +} + +/*---------------------------------------------------------*\ +| Paired slots owned by a legacy controller: the virtual | +| PID the receiver reports for the slot, mapped to the | +| name its legacy detector registers under. A slot is | +| left to legacy only while that detector is enabled, | +| turn it off in Settings and the slot comes here on the | +| next detection, same as the wired nodes. A wireless | +| PID with no legacy detector does not belong here. | +\*---------------------------------------------------------*/ +static const std::map hidpp20_legacy_wireless_pids = +{ + { 0x4053, "Logitech G900 Wireless Gaming Mouse" }, + { 0x405D, "Logitech G403 Wireless Gaming Mouse" }, + { 0x405F, "Logitech Powerplay Mat" }, + { 0x4067, "Logitech G903 Wireless Gaming Mouse" }, + { 0x4070, "Logitech G703 Wireless Gaming Mouse" }, + { 0x4079, "Logitech G Pro Wireless Gaming Mouse" }, + { 0x407F, "Logitech G502 Wireless Gaming Mouse" }, + { 0x4086, "Logitech G703 HERO Wireless Gaming Mouse" }, + { 0x4087, "Logitech G903 HERO Wireless Gaming Mouse" }, +}; + +/*---------------------------------------------------------*\ +| Is the legacy controller that owns this slot still | +| enabled? Detectors are enabled unless Settings says | +| otherwise, which is also what DetectionManager | +| assumes for a detector it has not seen before. | +\*---------------------------------------------------------*/ +static bool HIDPP20SlotOwnedByLegacy(uint16_t dev_pid, std::string& detector_name) +{ + std::map::const_iterator legacy = hidpp20_legacy_wireless_pids.find(dev_pid); + + if(legacy == hidpp20_legacy_wireless_pids.end()) + { + return(false); + } + + detector_name = legacy->second; + + json detector_settings = ResourceManager::get()->GetSettingsManager()->GetSettings("Detectors"); + + if(detector_settings.contains("detectors") + && detector_settings["detectors"].contains(detector_name)) + { + return(detector_settings["detectors"][detector_name]); + } + + return(true); +} + +/*---------------------------------------------------------*\ +| A paired slot on a receiver, keyed by the serial the | +| receiver stores for it, the same string the device | +| reports as its unit id, so cable and receiver sightings | +| land on one identity without either being awake. Slots | +| share the receiver's node and the mutex serializing it. | +\*---------------------------------------------------------*/ +class HIDPP20Slot +{ +public: + std::string node_path; + uint8_t index; + uint16_t usage_page; + std::string pairing_name; + std::shared_ptr node_mutex; +}; + +static std::map hidpp20_slots; +static std::map> hidpp20_node_mutexes; +static std::mutex hidpp20_registry_mutex; +static bool hidpp20_hook_registered = false; + +/*---------------------------------------------------------*\ +| Devices already built this pass, by unit id. A device | +| reachable on more than one node reports the same id on | +| each, so the first node to build it registers it and | +| later nodes skip it. Cleared by the pre-detection hook. | +\*---------------------------------------------------------*/ +static std::set hidpp20_claimed_devices; + +/*---------------------------------------------------------*\ +| One persistent watcher per receiver node. Watchers | +| outlive detection passes, so a device waking between | +| them is still detected. A watcher that lost its node is | +| held for destruction on the detection thread: | +| destroying one joins its threads, and its own worker | +| may be in Create right now, wanting the same lock. | +\*---------------------------------------------------------*/ +static std::map> hidpp20_watchers; +static std::vector> hidpp20_watchers_to_destroy; +static std::mutex hidpp20_watcher_mutex; +static bool hidpp20_watchers_enabled = true; + +static void HIDPP20PreDetectionReset() +{ + { + std::lock_guard lock(hidpp20_registry_mutex); + + hidpp20_slots.clear(); + hidpp20_node_mutexes.clear(); + hidpp20_claimed_devices.clear(); + } + + /*-----------------------------------------------------*\ + | Destroy dead watchers outside the lock so a watcher | + | mid-build never deadlocks against ensure-watcher. | + \*-----------------------------------------------------*/ + std::vector> to_destroy; + + { + std::lock_guard lock(hidpp20_watcher_mutex); + + to_destroy.swap(hidpp20_watchers_to_destroy); + + for(std::map>::iterator it = hidpp20_watchers.begin(); + it != hidpp20_watchers.end();) { - /*--------------------------------------------------*\ - | Probe failed. Could be an offline paired device, | - | a stale pairing slot, or a receiver itself. | - | | - | Only skip if the name explicitly says "Receiver". | - | Everything else gets a watcher — devices can come | - | back at any time (power cycle, dongle swap, etc.) | - \*--------------------------------------------------*/ - std::string hid_name; - - if(info->product_string) + if(!it->second->IsAlive()) { - hid_name = StringUtils::wstring_to_string(info->product_string); - } - - if(hid_name.find("Receiver") != std::string::npos || hid_name.find("receiver") != std::string::npos) - { - delete controller; + to_destroy.push_back(std::move(it->second)); + it = hidpp20_watchers.erase(it); } else { -// TODO: this behavior does not work on hotplug-aware DetectionManager -// LOG_INFO("[HID++2.0 %s] Probe failed — watching for device (name='%s')", info->path, hid_name.c_str()); - -// controller->SetRegisterCallback([](RGBController* rgb) -// { -// DetectionManager::get()->RegisterRGBController(rgb); -// }); - -// controller->StartProbeWatcher(); - - delete controller; + ++it; } } } - return(detected_controllers); + to_destroy.clear(); } -#if defined(_WIN32) || defined(__APPLE__) -/*-------------------------------------------------------------------------------------------------------------------------------------------------*\ -| Unified HID++ 2.0 Lightspeed Receiver Detection (Windows / macOS) | -| | -| On Linux, hid-logitech-dj splits receiver traffic into per-slot virtual child hidraw nodes with their own 0x40XX PIDs, so Linux detection can | -| use DetectLogitechHIDPP20 directly against the virtual PIDs. Windows and macOS have no DJ driver — the receiver appears as a single HID device | -| and we must probe each paired slot by hand, addressing it via the HID++ device_index header byte. | -| | -| Iterates device indices 0x01..0x06. c547 is dual-pair, but the loop covers Unifying-style receivers and any future wider-pair variants. Each | -| responding slot gets its own hid_device handle and a shared std::mutex so sibling slots serialize HID writes — matching the pattern in the | -| legacy LogitechLightspeedController (see CreateLogitechLightspeedDevice around line 860). | -| | -| TODO (untested on Windows): runtime reader-thread coordination. Each slot controller starts its own reader thread; on a shared receiver both | -| threads will see both slots' incoming packets. The reader needs to drop frames whose device_index doesn't match its own, or dispatch across | -| sibling controllers. Safe during probe (mutex serializes writes, reads are direct); becomes an issue post-StartPowerManager. | -\*-------------------------------------------------------------------------------------------------------------------------------------------------*/ -DetectedControllers DetectLogitechHIDPP20LightspeedReceiver(hid_device_info* info, const std::string& /*name*/) +static void HIDPP20ShutdownWatchers() { - DetectedControllers detected_controllers; - std::shared_ptr receiver_mutex = std::make_shared(); + std::map> live; + std::vector> dead; - for(uint8_t idx = 0x01; idx <= 0x06; idx++) { - hid_device* dev = hid_open_path(info->path); + std::lock_guard lock(hidpp20_watcher_mutex); - if(!dev) + hidpp20_watchers_enabled = false; + + live.swap(hidpp20_watchers); + dead.swap(hidpp20_watchers_to_destroy); + } + + live.clear(); + dead.clear(); +} + +/*---------------------------------------------------------*\ +| Has this device already been built this pass? | +\*---------------------------------------------------------*/ +static bool HIDPP20ClaimDevice(const std::string& device_id) +{ + std::lock_guard lock(hidpp20_registry_mutex); + + return hidpp20_claimed_devices.insert(device_id).second; +} + +/*---------------------------------------------------------*\ +| Watchers own threads and a node handle, so they have to | +| be stopped before the process exits. | +\*---------------------------------------------------------*/ +class HIDPP20WatcherShutdown +{ +public: + ~HIDPP20WatcherShutdown() + { + HIDPP20ShutdownWatchers(); + } +}; + +static HIDPP20WatcherShutdown hidpp20_watcher_shutdown; + +static bool HIDPP20BuildForWatcher(const std::string& node_path, uint8_t index); + +static void HIDPP20RegisterHook() +{ + if(!hidpp20_hook_registered) + { + DetectionManager::get()->RegisterPreDetectionHook(HIDPP20PreDetectionReset); + LogitechHIDPP20ReceiverWatcher::SetBuilder(HIDPP20BuildForWatcher); + hidpp20_hook_registered = true; + } +} + +/*---------------------------------------------------------*\ +| Make sure this receiver node has a live watcher. A dead | +| one (node unplugged) is destroyed and replaced, the same | +| path reappearing means the receiver was replugged. | +\*---------------------------------------------------------*/ +static void HIDPP20EnsureWatcher(const std::string& node_path, uint8_t bridge_feat_idx, + uint8_t bridge_report_id, bool bridge_addressed) +{ + std::lock_guard lock(hidpp20_watcher_mutex); + + if(!hidpp20_watchers_enabled) + { + return; + } + + std::map>::iterator it = hidpp20_watchers.find(node_path); + + if(it != hidpp20_watchers.end()) + { + if(it->second->IsAlive()) { - continue; + return; } - LogitechHIDPP20Controller* controller = new LogitechHIDPP20Controller(dev, info->path, idx, true, receiver_mutex, info->usage_page); + hidpp20_watchers_to_destroy.push_back(std::move(it->second)); + hidpp20_watchers.erase(it); + } - if(!controller->Probe()) + std::unique_ptr watcher( + new LogitechHIDPP20ReceiverWatcher(node_path, bridge_feat_idx, bridge_report_id, bridge_addressed)); + + if(watcher->Start()) + { + hidpp20_watchers[node_path] = std::move(watcher); + } +} + +static std::shared_ptr HIDPP20NodeMutex(const std::string& node_path) +{ + std::lock_guard lock(hidpp20_registry_mutex); + + std::map>::iterator it = hidpp20_node_mutexes.find(node_path); + + if(it != hidpp20_node_mutexes.end()) + { + return it->second; + } + + std::shared_ptr node_mutex = std::make_shared(); + + hidpp20_node_mutexes[node_path] = node_mutex; + + return node_mutex; +} + +/*---------------------------------------------------------*\ +| Is the node in hand this device's receiver slot? | +| | +| Keyed on the node as well as the device, because a | +| device reachable over its receiver AND over its | +| cable answers to the same id on both. Ask only | +| whether THIS node is the slot, otherwise the cable | +| gets mistaken for the receiver it is also paired | +| to, and the link we were handed is never taken. | +\*---------------------------------------------------------*/ +static bool HIDPP20LookupSlot(const std::string& device_id, const std::string& node_path, HIDPP20Slot& slot_out) +{ + std::lock_guard lock(hidpp20_registry_mutex); + + std::map::iterator it = hidpp20_slots.find(device_id); + + if(it == hidpp20_slots.end() || it->second.node_path != node_path) + { + return false; + } + + slot_out = it->second; + + return true; +} + +/*---------------------------------------------------------*\ +| Enumerate: which devices does this node reach? A device | +| answers for itself (unit id). A receiver answers for | +| everything paired to it out of its own registers, so a | +| device that is asleep or away on its cable is still | +| known, under the same identity it has everywhere else. | +\*---------------------------------------------------------*/ +static std::vector HIDPP20Enumerate(hid_device_info* info) +{ + std::vector device_ids; + + if(info->vendor_id != LOGITECH_VID) + { + return(device_ids); + } + +#ifdef __linux__ + /*-----------------------------------------------------*\ + | hid-logitech-dj virtual child nodes (0x40XX) are the | + | kernel's view of devices the receiver's own node | + | already reaches. Driving one device over two | + | unsynchronized handles helps nobody. | + \*-----------------------------------------------------*/ + if((info->product_id & 0xFF00) == 0x4000) + { + LOG_DEBUG("[Logitech HID++ 2.0] skipping DJ virtual node %04X @ %s (the receiver's node owns it)", + info->product_id, info->path); + return(device_ids); + } +#endif + + HIDPP20RegisterHook(); + + /*-----------------------------------------------------*\ + | Is the node itself a device? The probe controller | + | is a stack object that is never Initialize()d; it | + | changes nothing on the device, so it is safe | + | against hardware another controller is driving. | + \*-----------------------------------------------------*/ + hid_device* dev = hid_open_path(info->path); + + if(dev == nullptr) + { + return(device_ids); + } + + { + LogitechHIDPP20Controller probe(dev, info->path, LOGITECH_DEFAULT_DEVICE_INDEX, false, nullptr, info->usage_page); + + std::string unit_id = probe.ProbeIdentity(); + + if(!unit_id.empty()) { - /*--------------------------------------------------*\ - | Slot is empty, stale, or not HID++ 2.0. Destructor | - | closes the per-slot handle we opened above. | - \*--------------------------------------------------*/ - delete controller; - continue; + LOG_DEBUG("[Logitech HID++ 2.0] %s is device %s", info->path, unit_id.c_str()); + + device_ids.push_back(unit_id); + + return(device_ids); + } + } + + /*-----------------------------------------------------*\ + | Not a device. On the standard transport that is the | + | receiver signature, ask it who is paired to it. | + \*-----------------------------------------------------*/ + if(info->usage_page != 0xFF00) + { + return(device_ids); + } + + usages bundle = BundleLogitechUsages(info); + + /*-----------------------------------------------------*\ + | Linux and macOS expose the whole interface as one | + | node that accepts every report ID, so it may not key | + | a usage-1 handle. One handle serves RAP and FAP both. | + \*-----------------------------------------------------*/ + if(bundle.find(1) == bundle.end()) + { + hid_device* rap = hid_open_path(info->path); + + if(rap) + { + bundle.emplace((uint8_t)1, rap); + } + } + + wireless_map wireless_devices; + std::map online; + int count = 0; + + if(bundle.find(1) != bundle.end()) + { + count = getWirelessDevice(bundle, info->product_id, &wireless_devices, &online); + } + + if(count > 0) + { + LOG_INFO("[Logitech receiver %04X @ %s] %d paired device(s)", info->product_id, info->path, count); + + std::shared_ptr node_mutex = HIDPP20NodeMutex(info->path); + + for(wireless_map::iterator wd = wireless_devices.begin(); wd != wireless_devices.end(); wd++) + { + uint16_t dev_pid = wd->first; + uint8_t idx = wd->second; + + std::string legacy_name; + + if(HIDPP20SlotOwnedByLegacy(dev_pid, legacy_name)) + { + LOG_INFO("[Logitech receiver %04X] slot=%u PID=%04X is owned by '%s', leaving it alone " + "(disable that detector to hand it to HID++ 2.0)", + info->product_id, idx, dev_pid, legacy_name.c_str()); + continue; + } + + if(!legacy_name.empty()) + { + LOG_INFO("[Logitech receiver %04X] slot=%u PID=%04X: '%s' is disabled, taking the slot", + info->product_id, idx, dev_pid, legacy_name.c_str()); + } + + std::string serial = getWirelessDeviceSerial(bundle, idx); + std::string name = getWirelessDeviceName(bundle, idx); + + /*---------------------------------------------*\ + | Some receivers store no serial. The slot is | + | still a device, it just cannot be recognized | + | as the same one over another link, name it | + | after where it lives. | + \*---------------------------------------------*/ + if(serial.empty()) + { + serial = std::string(info->path) + "#" + std::to_string(idx); + + LOG_INFO("[Logitech receiver %04X] slot=%u '%s' has no stored serial; it will not be recognized over a second link", + info->product_id, idx, name.c_str()); + } + + HIDPP20Slot slot; + + slot.node_path = info->path; + slot.index = idx; + slot.usage_page = info->usage_page; + slot.pairing_name = name; + slot.node_mutex = node_mutex; + + { + std::lock_guard lock(hidpp20_registry_mutex); + hidpp20_slots[serial] = slot; + } + + LOG_INFO("[Logitech receiver %04X] slot=%u PID=%04X '%s' is device %s", + info->product_id, idx, dev_pid, name.c_str(), serial.c_str()); + + device_ids.push_back(serial); + } + } + else + { + LOG_DEBUG("[Logitech HID++ 2.0] %04X @ %s answered neither the device probe nor the pairing read, ignoring", + info->product_id, info->path); + } + + for(usages::iterator u = bundle.begin(); u != bundle.end(); u++) + { + if(u->second) + { + hid_close(u->second); + } + } + + return(device_ids); +} + +/*---------------------------------------------------------*\ +| What it takes to build one device: the node, which slot | +| on it, and how to talk to it. Kept past the pass that | +| found it so the node watcher can build the same device | +| when it turns up later. | +\*---------------------------------------------------------*/ +class HIDPP20BuildTarget +{ +public: + std::string node_path; + uint8_t index = LOGITECH_DEFAULT_DEVICE_INDEX; + uint16_t usage_page = 0; + uint16_t vendor_id = 0; + uint16_t product_id = 0; + bool behind_receiver = false; + std::shared_ptr node_mutex; + std::string pairing_name; +}; + +static std::map, HIDPP20BuildTarget> hidpp20_build_targets; + +/*---------------------------------------------------------*\ +| Slots being built right now. A detection pass and a | +| watcher can reach the same slot at the same moment; the | +| first to claim it builds, the second skips it. | +\*---------------------------------------------------------*/ +static std::set> hidpp20_building; + +class HIDPP20BuildClaim +{ +public: + HIDPP20BuildClaim(const std::string& node_path, uint8_t index) + { + key = std::make_pair(node_path, index); + + std::lock_guard lock(hidpp20_registry_mutex); + + held = hidpp20_building.insert(key).second; + } + + ~HIDPP20BuildClaim() + { + if(!held) + { + return; } + std::lock_guard lock(hidpp20_registry_mutex); + + hidpp20_building.erase(key); + } + + bool Held() const { return held; } + +private: + std::pair key; + bool held; +}; + +static void HIDPP20RecordTarget(const HIDPP20BuildTarget& target) +{ + std::lock_guard lock(hidpp20_registry_mutex); + + hidpp20_build_targets[std::make_pair(target.node_path, target.index)] = target; +} + +static bool HIDPP20LookupTarget(const std::string& node_path, uint8_t index, HIDPP20BuildTarget& target_out) +{ + std::lock_guard lock(hidpp20_registry_mutex); + + std::map, HIDPP20BuildTarget>::iterator it = + hidpp20_build_targets.find(std::make_pair(node_path, index)); + + if(it == hidpp20_build_targets.end()) + { + return false; + } + + target_out = it->second; + + return true; +} + +/*---------------------------------------------------------*\ +| Build one device. Returns nullptr for a device that is | +| not reachable yet and for one that answers with no | +| lighting; both leave the target on file, so a device | +| that was asleep is built when its dongle reports it. | +\*---------------------------------------------------------*/ +static RGBController_LogitechHIDPP20* HIDPP20BuildController(const HIDPP20BuildTarget& target) +{ + HIDPP20BuildClaim claim(target.node_path, target.index); + + if(!claim.Held()) + { + LOG_DEBUG("[Logitech HID++ 2.0] %s index=0x%02X is already being built", + target.node_path.c_str(), target.index); + return(nullptr); + } + + hid_device* dev = hid_open_path(target.node_path.c_str()); + + if(dev == nullptr) + { + return(nullptr); + } + + /*-----------------------------------------------------*\ + | 0x8080 fn3 rides the 0x12 very-long report, which | + | Windows splits onto a separate usage 0x0604 | + | collection on the same interface (the legacy G810 / | + | G910 controllers opened it as a second handle). Open | + | that node when it exists; otherwise the main handle | + | carries every report ID (single node per interface | + | on Linux/macOS). Match by path key, not VID/PID, so | + | two identical keyboards do not cross wires. | + \*-----------------------------------------------------*/ + hid_device* perkey_vl = nullptr; + + if(target.usage_page == 0xFF43) + { + std::string device_key = LogitechDevicePathKey(target.node_path.c_str()); + hid_device_info* enumerated = hid_enumerate(target.vendor_id, target.product_id); + + for(hid_device_info* n = enumerated; n != nullptr; n = n->next) + { + if(n->usage_page == 0xFF43 && n->usage == 0x0604 + && LogitechDevicePathKey(n->path) == device_key) + { + perkey_vl = hid_open_path(n->path); + break; + } + } + + hid_free_enumeration(enumerated); + } + + if(perkey_vl == nullptr) + { + perkey_vl = dev; + } + + LogitechHIDPP20Controller* controller = new LogitechHIDPP20Controller(dev, target.node_path.c_str(), target.index, + target.behind_receiver, target.node_mutex, + target.usage_page, perkey_vl); + + if(target.behind_receiver) + { + controller->SetPairingName(target.pairing_name); + } + + if(!controller->Probe()) + { + /*-------------------------------------------------*\ + | The receiver reports it as paired here, but it | + | is not answering. The watcher builds it on the | + | 0x41 connection notification. | + \*-------------------------------------------------*/ + delete controller; + + if(target.behind_receiver) + { + HIDPP20EnsureWatcher(target.node_path, 0, 0, false); + } + + return(nullptr); + } + + const HIDPP20DeviceCapabilities& caps = controller->GetCapabilities(); + + if(caps.has_zone_effects || caps.has_perkey) + { controller->Initialize(); - const HIDPP20DeviceCapabilities& caps = controller->GetCapabilities(); + RGBController_LogitechHIDPP20* rgb_controller = new RGBController_LogitechHIDPP20(controller); - if(caps.has_zone_effects || caps.has_perkey) + /*-------------------------------------------------*\ + | Reader and power threads from the start, so we | + | see connection events and handle power management | + | without waiting for a mode update. | + \*-------------------------------------------------*/ + if(caps.has_power_mgmt || caps.idx_wireless_status != 0) { - RGBController_LogitechHIDPP20* rgb_controller = new RGBController_LogitechHIDPP20(controller); + controller->StartPowerManager(); - LOG_INFO("[%s slot=%u] Registering RGB controller", caps.device_name.c_str(), idx); - - detected_controllers.push_back(rgb_controller); - - if(caps.has_power_mgmt || caps.idx_wireless_status != 0) + if(!caps.has_power_mgmt && caps.idx_wireless_status != 0) { - controller->StartPowerManager(); - - if(!caps.has_power_mgmt && caps.idx_wireless_status != 0) - { - controller->StartEventWatcher(); - } + controller->StartEventWatcher(); } } - else + else if(target.behind_receiver || controller->HasBridge()) { - LOG_INFO("[%s slot=%u] No RGB features, skipping", caps.device_name.c_str(), idx); - delete controller; + /*---------------------------------------------*\ + | No power management and no WirelessStatus; | + | threads anyway, so the node watcher's nudge | + | has a power thread to land on. | + \*---------------------------------------------*/ + controller->StartEventWatcher(); + } + + /*-------------------------------------------------*\ + | Wireless devices answer to the node watcher's | + | nudges; keep it current on who is built here. | + \*-------------------------------------------------*/ + if(target.behind_receiver) + { + LogitechHIDPP20ReceiverWatcher::RegisterSubDevice(target.node_path, target.index, controller); + HIDPP20EnsureWatcher(target.node_path, 0, 0, false); + } + else if(controller->HasBridge()) + { + const HIDPP20Transport& transport = controller->GetTransport(); + + LogitechHIDPP20ReceiverWatcher::RegisterSubDevice(target.node_path, target.index, controller); + HIDPP20EnsureWatcher(target.node_path, transport.bridge_feat_idx, transport.report_id, + transport.addressed); + } + + return(rgb_controller); + } + + if(controller->HasBridge() && controller->GetTransport().bridge_mtu == 0) + { + /*-------------------------------------------------*\ + | A dongle whose sub-device is not powered on. The | + | watcher builds it on the bridge's | + | ConnectionStateChangedEvent. | + \*-------------------------------------------------*/ + LOG_INFO("[%s] Dongle has no sub-device yet", caps.device_name.c_str()); + + const HIDPP20Transport& transport = controller->GetTransport(); + + HIDPP20EnsureWatcher(target.node_path, transport.bridge_feat_idx, transport.report_id, + transport.addressed); + + delete controller; + + return(nullptr); + } + + /*-----------------------------------------------------*\ + | It answered, and it has no lighting. Not ours. | + \*-----------------------------------------------------*/ + LOG_INFO("[%s] No RGB features, skipping", caps.device_name.c_str()); + + delete controller; + + return(nullptr); +} + +/*---------------------------------------------------------*\ +| Create: build the controller for a device this pass has | +| not built yet. | +| | +| Returning nothing means "it is there, we just cannot | +| reach it yet", asleep, switched off, paired to another | +| host. The target is on file either way. | +\*---------------------------------------------------------*/ +static DetectedControllers HIDPP20Create(hid_device_info* info, const std::string& device_id) +{ + DetectedControllers detected; + + HIDPP20Slot slot; + HIDPP20BuildTarget target; + + target.behind_receiver = HIDPP20LookupSlot(device_id, std::string(info->path), slot); + target.node_path = std::string(info->path); + target.index = target.behind_receiver ? slot.index : (uint8_t)LOGITECH_DEFAULT_DEVICE_INDEX; + target.usage_page = (uint16_t)info->usage_page; + target.vendor_id = (uint16_t)info->vendor_id; + target.product_id = (uint16_t)info->product_id; + target.node_mutex = slot.node_mutex; + target.pairing_name = slot.pairing_name; + + HIDPP20RecordTarget(target); + + RGBController_LogitechHIDPP20* rgb_controller = HIDPP20BuildController(target); + + if(rgb_controller != nullptr) + { + LOG_INFO("[%s] Registering RGB controller (device %s)", + rgb_controller->GetController()->GetCapabilities().device_name.c_str(), device_id.c_str()); + + detected.push_back(rgb_controller); + } + + return(detected); +} + +/*---------------------------------------------------------*\ +| Build a device the node watcher reports as connected. | +| Runs on the watcher's worker thread, never on its reader. | +| | +| A pass in flight may be building this very device, so | +| wait it out, then check the sub-device registry, which | +| is the live answer to whether this slot has a controller. | +| A link established is not a device ready to answer: a | +| failed build leaves the target recorded for the next | +| connection event. | +\*---------------------------------------------------------*/ +static bool HIDPP20BuildForWatcher(const std::string& node_path, uint8_t index) +{ + HIDPP20BuildTarget target; + + if(!HIDPP20LookupTarget(node_path, index, target)) + { + LOG_DEBUG("[Logitech HID++ 2.0] %s index=0x%02X connected but was never enumerated, leaving it to the next detection", + node_path.c_str(), index); + return false; + } + + DetectionManager::get()->WaitForDetection(); + + if(LogitechHIDPP20ReceiverWatcher::HasSubDevice(node_path, index)) + { + return false; + } + + /*-----------------------------------------------------*\ + | Nothing is registered once teardown starts. | + \*-----------------------------------------------------*/ + { + std::lock_guard lock(hidpp20_watcher_mutex); + + if(!hidpp20_watchers_enabled) + { + return false; } } - return(detected_controllers); + RGBController_LogitechHIDPP20* rgb_controller = HIDPP20BuildController(target); + + if(rgb_controller == nullptr) + { + return false; + } + + LOG_INFO("[%s] Connected, registering RGB controller", + rgb_controller->GetController()->GetCapabilities().device_name.c_str()); + + /*-----------------------------------------------------*\ + | The node it was built on, so the unplug callback | + | unregisters it with everything else on that node. | + \*-----------------------------------------------------*/ + DetectionManager::get()->RegisterRGBController(rgb_controller, node_path); + + return true; } -#endif -/*-------------------------------------------------------------------------------------------------------------------------------------------------*\ -| Unified HID++ 2.0 Devices | -| PID-specific registrations for devices tested with the unified controller. | -| Wired paths use the device's own USB PID; wireless paths on Linux use the hid-logitech-dj virtual child PIDs (0x40XX range). | -\*-------------------------------------------------------------------------------------------------------------------------------------------------*/ -REGISTER_HID_DETECTOR_IPU("Logitech HID++ 2.0 G502 X Plus (wired)", DetectLogitechHIDPP20, LOGITECH_VID, LOGITECH_G502_X_PLUS_PID, 2, 0xFF00, 2); -REGISTER_HID_DETECTOR_IPU("Logitech HID++ 2.0 G515 LS TKL (wired)", DetectLogitechHIDPP20, LOGITECH_VID, LOGITECH_G515_LS_TKL_PID, 2, 0xFF00, 2); -#ifdef __linux__ -REGISTER_HID_DETECTOR_IPU("Logitech HID++ 2.0 G502 X Plus (wireless)", DetectLogitechHIDPP20, LOGITECH_VID, LOGITECH_G502_X_PLUS_LIGHTSPEED_VIRTUAL_PID, 2, 0xFF00, 2); -REGISTER_HID_DETECTOR_IPU("Logitech HID++ 2.0 G515 LS TKL (wireless)", DetectLogitechHIDPP20, LOGITECH_VID, LOGITECH_G515_LS_TKL_LIGHTSPEED_VIRTUAL_PID, 2, 0xFF00, 2); -#endif -#if defined(_WIN32) || defined(__APPLE__) -REGISTER_HID_DETECTOR_IPU("Logitech HID++ 2.0 Lightspeed Receiver (C547)", DetectLogitechHIDPP20LightspeedReceiver, LOGITECH_VID, 0xC547, 2, 0xFF00, 2); -#endif +/*---------------------------------------------------------*\ +| Every device this node reaches, built once each. A device | +| on more than one node reports the same unit id on each, | +| so the first node to build it registers it and later | +| nodes skip it; the node it was built on is the one | +| DetectionManager unregisters it from. | +\*---------------------------------------------------------*/ +DetectedControllers DetectLogitechHIDPP20(hid_device_info* info, const std::string& /*name*/) +{ + DetectedControllers detected; + std::vector device_ids = HIDPP20Enumerate(info); -/*-------------------------------------------------------------------------------------------------------------------------------------------------*\ -| Centurion-transport devices (63-byte reports on usage page 0xFFA0, 0x50 addressed or 0x51 direct). | -| Centurion receivers are not DJ-style Lightspeed receivers and are not split by hid-logitech-dj — the dongle PID enumerates as a single hidraw | -| on all platforms. The controller's DiscoverTransport parses the report descriptor to pick the 0x50/0x51 variant and runs a 0x00..0xFF address | -| sweep for the 0x50 (addressed) variant, so the detector only needs VID/PID + usage page 0xFFA0. | -\*-------------------------------------------------------------------------------------------------------------------------------------------------*/ -REGISTER_HID_DETECTOR_P("Logitech HID++ 2.0 G522 Lightspeed (wired)", DetectLogitechHIDPP20, LOGITECH_VID, LOGITECH_G522_LIGHTSPEED_USB_PID, 0xFFA0); -REGISTER_HID_DETECTOR_P("Logitech HID++ 2.0 G522 Lightspeed (dongle)", DetectLogitechHIDPP20, LOGITECH_VID, LOGITECH_G522_LIGHTSPEED_DONGLE_PID, 0xFFA0); + for(size_t i = 0; i < device_ids.size(); i++) + { + const std::string& device_id = device_ids[i]; + + if(!HIDPP20ClaimDevice(device_id)) + { + LOG_DEBUG("[Logitech HID++ 2.0] device %s is already built this pass, skipping %s", + device_id.c_str(), info->path); + continue; + } + + DetectedControllers built = HIDPP20Create(info, device_id); + + if(built.empty()) + { + /*---------------------------------------------*\ + | Nothing to drive, or nothing answering yet. | + | Let another node try it this pass. | + \*---------------------------------------------*/ + std::lock_guard lock(hidpp20_registry_mutex); + hidpp20_claimed_devices.erase(device_id); + continue; + } + + detected.insert(detected.end(), built.begin(), built.end()); + } + + return(detected); +} + +/*-------------------------------------------------------------------------------------------------------------------------------------*\ +| Unified HID++ 2.0: generic detection. These run only for devices with no *enabled* VID/PID-specific detector, disabling a legacy | +| controller in Settings hands its hardware over on the next detection. That is the migration path: no code change, and no risk to a | +| device whose legacy controller stays enabled. | +| | +| The registrations cover every legacy transport signature: | +| any interface, 0xFF00 usage 2 standard HID++ long report (modern keyboards/mice, receivers, G915 family, wired Lightspeed mice). | +| Usage 2 is the collection we write to, on Windows, the only one that accepts our writes. | +| interface 1, 0xFF43 any usage keyboards (G213/G512/G610/G810/G813/G815/G910/G Pro); usage varies by model | +| interface 1, 0xFF00 any usage older mice whose HID++ collection is not the usage-2 one | +| interface 2, 0xFF43 usage 514 G560 speaker | +| interface 3, 0xFF43 usage 514 G933 headset | +| any interface, 0xFFA0 usage 1 Centurion (G522, PRO X 2) | +| | +| Not covered, deliberately: the G600 (page 0xFF80) and the X56 (own VID) are not HID++ 2.0. A matching non-HID++ node costs one failed | +| probe, ProbeIdentity changes nothing on the device. Receivers are recognized at runtime: the device probe fails and the pairing table | +| answers; paired slots follow the same enabled/disabled rule (hidpp20_legacy_wireless_pids). | +| | +| udev metadata for the rules generator (VID/PID-generic registrations carry no ids of their own): | +| DUMMY_DEVICE_DETECTOR("Logitech HID++ 2.0", DetectLogitechHIDPP20, 0x046D, 0xC547 ) | +| DUMMY_DEVICE_DETECTOR("Logitech G560 Lightsync Speaker", DetectLogitechHIDPP20, 0x046D, 0x0A78 ) | +\*-------------------------------------------------------------------------------------------------------------------------------------*/ +REGISTER_HID_DETECTOR_PU_ONLY ("Logitech HID++ 2.0", DetectLogitechHIDPP20, 0xFF00, 2); +REGISTER_HID_DETECTOR_IP_ONLY ("Logitech HID++ 2.0", DetectLogitechHIDPP20, 1, 0xFF43); +REGISTER_HID_DETECTOR_IP_ONLY ("Logitech HID++ 2.0", DetectLogitechHIDPP20, 1, 0xFF00); +REGISTER_HID_DETECTOR_IPU_ONLY("Logitech HID++ 2.0", DetectLogitechHIDPP20, 2, 0xFF43, 514); +REGISTER_HID_DETECTOR_IPU_ONLY("Logitech HID++ 2.0", DetectLogitechHIDPP20, 3, 0xFF43, 514); +REGISTER_HID_DETECTOR_PU_ONLY ("Logitech HID++ 2.0", DetectLogitechHIDPP20, 0xFFA0, 1); /*-------------------------------------------------------------------------------------------------------------------------------------------------*\ | Keyboards | @@ -992,6 +1705,18 @@ REGISTER_HID_DETECTOR_IPU("Logitech G915 Wireless RGB Mechanical Gaming Keyboard REGISTER_HID_DETECTOR_IPU("Logitech G915 Wireless RGB Mechanical Gaming Keyboard (Wired)", DetectLogitechKeyboardG915Wired, LOGITECH_VID, LOGITECH_G915_WIRED_PID, 2, 0xFF00, 2); REGISTER_HID_DETECTOR_IPU("Logitech G915TKL Wireless RGB Mechanical Gaming Keyboard", DetectLogitechKeyboardG915, LOGITECH_VID, LOGITECH_G915TKL_RECEIVER_PID, 2, 0xFF00, 2); REGISTER_HID_DETECTOR_IPU("Logitech G915TKL Wireless RGB Mechanical Gaming Keyboard (Wired)", DetectLogitechKeyboardG915Wired, LOGITECH_VID, LOGITECH_G915TKL_WIRED_PID, 2, 0xFF00, 2); +/*---------------------------------------------------------*\ +| C547 carve-out: the legacy G915 Receiver 2 registration | +| above makes C547 a VID/PID-specific match, which | +| suppresses the generic HID++ 2.0 detector for every C547 | +| node, including receivers with non-G915 devices paired. | +| This specific entry runs after the G915 detector | +| (registration order); when that returns nothing, the | +| unified pairing-table enumeration takes the receiver. | +| Literal PID: the macro token-pastes the object name and | +| LOGITECH_G915_RECEIVER_2_PID is already used above. | +\*---------------------------------------------------------*/ +REGISTER_HID_DETECTOR_IPU("Logitech HID++ 2.0 (C547 receiver)", DetectLogitechHIDPP20, LOGITECH_VID, 0xC547, 2, 0xFF00, 2); /*-------------------------------------------------------------------------------------------------------------------------------------------------*\ | Mice | \*-------------------------------------------------------------------------------------------------------------------------------------------------*/ @@ -1004,10 +1729,6 @@ REGISTER_HID_DETECTOR_IP ("Logitech G600 Gaming Mouse", Dete REGISTER_HID_DETECTOR_IP ("Logitech G Pro Gaming Mouse", DetectLogitechMouseGPRO, LOGITECH_VID, LOGITECH_G_PRO_PID, 1, 0xFF00); REGISTER_HID_DETECTOR_IP ("Logitech G Pro HERO Gaming Mouse", DetectLogitechMouseGPRO, LOGITECH_VID, LOGITECH_G_PRO_HERO_PID, 1, 0xFF00); /*-------------------------------------------------------------------------------------------------------------------------------------------------*\ -| Speakers | -\*-------------------------------------------------------------------------------------------------------------------------------------------------*/ -REGISTER_HID_DETECTOR_IPU("Logitech G560 Lightsync Speaker", DetectLogitechG560, LOGITECH_VID, LOGITECH_G560_PID, 2, 0xFF43, 514); -/*-------------------------------------------------------------------------------------------------------------------------------------------------*\ | Headsets | \*-------------------------------------------------------------------------------------------------------------------------------------------------*/ REGISTER_HID_DETECTOR_IPU("Logitech G933 Lightsync Headset", DetectLogitechG933, LOGITECH_VID, LOGITECH_G933_PID, 3, 0xFF43, 514); @@ -1102,42 +1823,6 @@ DetectedControllers DetectLogitechWired(hid_device_info* info, const std::string \*---------------------------------------------------------------------------------------------------------*/ #if defined(_WIN32) || defined(__APPLE__) -usages BundleLogitechUsages(hid_device_info* info) -{ - /*-----------------------------------------------------------------*\ - | Need a unique ID to group usages for 1 device if multiple exist | - | Grab all usages that you can open. For normal Logitech FAP | - | devices this will be usage 1, 2 and 4 | - \*-----------------------------------------------------------------*/ - usages temp_usages; - - hid_device_info* temp_info = hid_enumerate(info->vendor_id, info->product_id); - while(temp_info) - { - /*-----------------------------------------------------------------*\ - | Only bundle the device that triggered this callback | - \*-----------------------------------------------------------------*/ - if(temp_info->interface_number == 2) - { - LOG_DEBUG("Attempting to open dev path: %s", info->path); - hid_device* dev = hid_open_path(temp_info->path); - - if(dev) - { - LOG_DEBUG("Success! Adding Usage %i for device @ path %s", temp_info->usage, temp_info->path); - temp_usages.emplace((uint8_t)temp_info->usage, dev); - } - else - { - LOG_INFO("FAILED! Can not add Usage %i for device @ path %s", temp_info->usage, temp_info->path); - } - } - temp_info = temp_info->next; - } - - return temp_usages; -} - DetectedControllers DetectLogitechLightspeedReceiver(hid_device_info* info, const std::string& /*name*/) { /*-----------------------------------------------------------------*\ diff --git a/Controllers/LogitechController/LogitechG560Controller/LogitechG560Controller.cpp b/Controllers/LogitechController/LogitechG560Controller/LogitechG560Controller.cpp deleted file mode 100644 index 09f43f50f..000000000 --- a/Controllers/LogitechController/LogitechG560Controller/LogitechG560Controller.cpp +++ /dev/null @@ -1,154 +0,0 @@ -/*---------------------------------------------------------*\ -| LogitechG560Controller.cpp | -| | -| Driver for Logitech G560 | -| | -| Cheerpipe 28 Oct 2020 | -| based on TheRogueZeta 31 Aug 2020 | -| | -| This file is part of the OpenRGB project | -| SPDX-License-Identifier: GPL-2.0-or-later | -\*---------------------------------------------------------*/ - -#include -#include -#include -#include "LogitechG560Controller.h" - -using namespace std::chrono_literals; - -LogitechG560Controller::LogitechG560Controller(hid_device* dev_handle, const char* path, std::string dev_name) -{ - dev = dev_handle; - location = path; - name = dev_name; -} - -LogitechG560Controller::~LogitechG560Controller() -{ - hid_close(dev); -} - -std::string LogitechG560Controller::GetDeviceLocation() -{ - return("HID: " + location); -} - -std::string LogitechG560Controller::GetDeviceName() -{ - return(name); -} - -void LogitechG560Controller::SetDirectMode(uint8_t zone) -{ - unsigned char usb_buf[LOGI_G560_LED_PACKET_SIZE]; - - usb_buf[0x00] = 0x11; - usb_buf[0x01] = 0xFF; - usb_buf[0x02] = 0x04; - usb_buf[0x03] = 0xCA; - usb_buf[0x04] = zone; - - /*-----------------------------------------------------*\ - | Send packet | - \*-----------------------------------------------------*/ - fail_retry_write(dev, usb_buf, LOGI_G560_LED_PACKET_SIZE); -} - -void LogitechG560Controller::SetOffMode(uint8_t zone) -{ - unsigned char usb_buf[LOGI_G560_LED_PACKET_SIZE]; - - usb_buf[0x00] = 0x11; - usb_buf[0x01] = 0xFF; - usb_buf[0x02] = 0x04; - usb_buf[0x03] = 0x3F; - usb_buf[0x04] = zone; - - /*-----------------------------------------------------*\ - | Send packet | - \*-----------------------------------------------------*/ - fail_retry_write(dev, usb_buf, LOGI_G560_LED_PACKET_SIZE); -} - -void LogitechG560Controller::SendSpeakerMode - ( - unsigned char zone, - unsigned char mode, - unsigned char red, - unsigned char green, - unsigned char blue - ) -{ - unsigned char usb_buf[LOGI_G560_LED_PACKET_SIZE]; - - /*-----------------------------------------------------*\ - | Zero out buffer | - \*-----------------------------------------------------*/ - memset(usb_buf, 0x00, sizeof(usb_buf)); - - /*-----------------------------------------------------*\ - | Set up Lighting Control | - \*-----------------------------------------------------*/ - usb_buf[0x00] = 0x11; - usb_buf[0x01] = 0xFF; - usb_buf[0x02] = 0x04; - - /*-----------------------------------------------------*\ - | This packet sets speaker into direct mode. This mode | - | is used by Lightsync Ambilight and Music Visualizer | - | realtime effect. | - \*-----------------------------------------------------*/ - usb_buf[0x03] = 0x3A; - - /*-----------------------------------------------------*\ - | Set up mode and speed | - \*-----------------------------------------------------*/ - usb_buf[0x04] = zone; - usb_buf[0x05] = mode; - - /*-----------------------------------------------------*\ - | And set up the colors | - \*-----------------------------------------------------*/ - usb_buf[0x06] = red; - usb_buf[0x07] = green; - usb_buf[0x08] = blue; - - if(mode == LOGITECH_G560_MODE_DIRECT) //G560 only has Direct Mode. - { - usb_buf[0x09] = 0x02; - } - - /*-----------------------------------------------------*\ - | Send packet | - \*-----------------------------------------------------*/ - fail_retry_write(dev, usb_buf, LOGI_G560_LED_PACKET_SIZE); -} - -void LogitechG560Controller::fail_retry_write(hid_device *device, const unsigned char *data, size_t length) -{ - unsigned char usb_buf_out[LOGI_G560_LED_PACKET_SIZE]; - unsigned int write_max_retry = LOGI_G560_LED_COMMAND_SEND_RETRIES; - do - { - std::this_thread::sleep_for(1ms); - int ret = hid_write(device, data, length); - - /*-------------------------------------------------------------------------------------*\ - | HID write fails if a change led color and set volume command are sent at | - | the same time because RGB controller and volume control shares the same interface. | - \*-------------------------------------------------------------------------------------*/ - if(ret == 20) - { - std::this_thread::sleep_for(1ms); - hid_read_timeout(dev, usb_buf_out, LOGI_G560_LED_PACKET_SIZE, 20); - break; - } - else - { - write_max_retry--; - std::this_thread::sleep_for(10ms); - } - - }while (write_max_retry > 0); -} diff --git a/Controllers/LogitechController/LogitechG560Controller/LogitechG560Controller.h b/Controllers/LogitechController/LogitechG560Controller/LogitechG560Controller.h deleted file mode 100644 index dbc34e32c..000000000 --- a/Controllers/LogitechController/LogitechG560Controller/LogitechG560Controller.h +++ /dev/null @@ -1,59 +0,0 @@ -/*---------------------------------------------------------*\ -| LogitechG560Controller.h | -| | -| Driver for Logitech G560 | -| | -| Cheerpipe 28 Oct 2020 | -| based on TheRogueZeta 31 Aug 2020 | -| | -| This file is part of the OpenRGB project | -| SPDX-License-Identifier: GPL-2.0-or-later | -\*---------------------------------------------------------*/ - -#pragma once - -#include -#include -#include "RGBController.h" - -#define LOGI_G560_LED_PACKET_SIZE 20 -#define LOGI_G560_LED_COMMAND_SEND_RETRIES 3 - -enum -{ - LOGITECH_G560_MODE_OFF = 0x00, - LOGITECH_G560_MODE_DIRECT = 0x01, - LOGITECH_G560_MODE_CYCLE = 0x02, - LOGITECH_G560_MODE_BREATHING = 0x03, -}; - -class LogitechG560Controller -{ -public: - LogitechG560Controller(hid_device* dev_handle, const char* path, std::string dev_name); - ~LogitechG560Controller(); - - std::string GetDeviceLocation(); - std::string GetDeviceName(); - - void SetDirectMode(uint8_t zone); - void SetOffMode(uint8_t zone); - - void SendSpeakerMode - ( - unsigned char zone, - unsigned char mode, - unsigned char red, - unsigned char green, - unsigned char blue - ); - -private: - hid_device* dev; - std::string location; - std::string name; - - void fail_retry_write(hid_device *device, const unsigned char *data, size_t length); -}; - - diff --git a/Controllers/LogitechController/LogitechG560Controller/RGBController_LogitechG560.cpp b/Controllers/LogitechController/LogitechG560Controller/RGBController_LogitechG560.cpp deleted file mode 100644 index 9cb0b5808..000000000 --- a/Controllers/LogitechController/LogitechG560Controller/RGBController_LogitechG560.cpp +++ /dev/null @@ -1,160 +0,0 @@ -/*---------------------------------------------------------*\ -| RGBController_LogitechG560.cpp | -| | -| RGBController for Logitech G560 | -| | -| Cheerpipe 28 Oct 2020 | -| based on TheRogueZeta 31 Aug 2020 | -| | -| This file is part of the OpenRGB project | -| SPDX-License-Identifier: GPL-2.0-or-later | -\*---------------------------------------------------------*/ - -#include "RGBController_LogitechG560.h" - -/**------------------------------------------------------------------*\ - @name Logitech G560 - @category Speaker - @type USB - @save :x: - @direct :white_check_mark: - @effects :x: - @detectors DetectLogitechG560 - @comment -\*-------------------------------------------------------------------*/ - -RGBController_LogitechG560::RGBController_LogitechG560(LogitechG560Controller* controller_ptr) -{ - controller = controller_ptr; - - name = controller->GetDeviceName(); - vendor = "Logitech"; - type = DEVICE_TYPE_SPEAKER; - description = "Logitech G560 Lightsync Speaker"; - location = controller->GetDeviceLocation(); - - mode Off; - Off.name = "Off"; - Off.value = LOGITECH_G560_MODE_OFF; - Off.flags = 0; - Off.color_mode = MODE_COLORS_PER_LED; - modes.push_back(Off); - - mode Direct; - Direct.name = "Direct"; - Direct.value = LOGITECH_G560_MODE_DIRECT; - Direct.flags = MODE_FLAG_HAS_PER_LED_COLOR; - Direct.color_mode = MODE_COLORS_PER_LED; - modes.push_back(Direct); - SetupZones(); -} - -RGBController_LogitechG560::~RGBController_LogitechG560() -{ - Shutdown(); - - delete controller; -} - -void RGBController_LogitechG560::SetupZones() -{ - zone G560_left_front; - G560_left_front.name = "Left Front"; - G560_left_front.type = ZONE_TYPE_SINGLE; - G560_left_front.leds_min = 1; - G560_left_front.leds_max = 1; - G560_left_front.leds_count = 1; - zones.push_back(G560_left_front); - - led G560_left_front_led; - G560_left_front_led.name = "Left Front"; - G560_left_front_led.value = 0x00; - leds.push_back(G560_left_front_led); - - - zone G560_right_front; - G560_right_front.name = "Right Front"; - G560_right_front.type = ZONE_TYPE_SINGLE; - G560_right_front.leds_min = 1; - G560_right_front.leds_max = 1; - G560_right_front.leds_count = 1; - zones.push_back(G560_right_front); - - led G560_right_front_led; - G560_right_front_led.name = "Right Front"; - G560_right_front_led.value = 0x01; - leds.push_back(G560_right_front_led); - - - zone G560_left_rear; - G560_left_rear.name = "Left Rear"; - G560_left_rear.type = ZONE_TYPE_SINGLE; - G560_left_rear.leds_min = 1; - G560_left_rear.leds_max = 1; - G560_left_rear.leds_count = 1; - zones.push_back(G560_left_rear); - - led G560_left_read_led; - G560_left_read_led.name = "Left Rear"; - G560_left_read_led.value = 0x02; - leds.push_back(G560_left_read_led); - - - zone G560_right_rear; - G560_right_rear.name = "Right Rear"; - G560_right_rear.type = ZONE_TYPE_SINGLE; - G560_right_rear.leds_min = 1; - G560_right_rear.leds_max = 1; - G560_right_rear.leds_count = 1; - zones.push_back(G560_right_rear); - - led G560_right_rear_led; - G560_right_rear_led.name = "Right Rear"; - G560_right_rear_led.value = 0x03; - leds.push_back(G560_right_rear_led); - - SetupColors(); -} - -void RGBController_LogitechG560::DeviceUpdateLEDs() -{ - for(std::size_t led_idx = 0; led_idx < leds.size(); led_idx++) - { - unsigned char red = RGBGetRValue(colors[led_idx]); - unsigned char grn = RGBGetGValue(colors[led_idx]); - unsigned char blu = RGBGetBValue(colors[led_idx]); - - controller->SendSpeakerMode((unsigned char)leds[led_idx].value, modes[active_mode].value, red, grn, blu); - } -} - -void RGBController_LogitechG560::DeviceUpdateZoneLEDs(int /*zone*/) -{ - DeviceUpdateLEDs(); -} - -void RGBController_LogitechG560::DeviceUpdateSingleLED(int /*led*/) -{ - DeviceUpdateLEDs(); -} - -void RGBController_LogitechG560::DeviceUpdateMode() -{ - for(std::size_t led_idx = 0; led_idx < leds.size(); led_idx++) - { - if(modes[active_mode].value == LOGITECH_G560_MODE_OFF) - { - controller->SetOffMode(leds[led_idx].value); - } - else - { - /*---------------------------------------------------------*\ - | Required to "reset" RGB controller and start receiving | - | color in direct mode | - \*---------------------------------------------------------*/ - controller->SetDirectMode(leds[led_idx].value); - } - - } - DeviceUpdateLEDs(); -} diff --git a/Controllers/LogitechController/LogitechG560Controller/RGBController_LogitechG560.h b/Controllers/LogitechController/LogitechG560Controller/RGBController_LogitechG560.h deleted file mode 100644 index 558c2f860..000000000 --- a/Controllers/LogitechController/LogitechG560Controller/RGBController_LogitechG560.h +++ /dev/null @@ -1,34 +0,0 @@ -/*---------------------------------------------------------*\ -| RGBController_LogitechG560.h | -| | -| RGBController for Logitech G560 | -| | -| Cheerpipe 28 Oct 2020 | -| based on TheRogueZeta 31 Aug 2020 | -| | -| This file is part of the OpenRGB project | -| SPDX-License-Identifier: GPL-2.0-or-later | -\*---------------------------------------------------------*/ - -#pragma once - -#include "RGBController.h" -#include "LogitechG560Controller.h" - -class RGBController_LogitechG560 : public RGBController -{ -public: - RGBController_LogitechG560(LogitechG560Controller* controller_ptr); - ~RGBController_LogitechG560(); - - void SetupZones(); - - void DeviceUpdateLEDs(); - void DeviceUpdateZoneLEDs(int zone); - void DeviceUpdateSingleLED(int led); - - void DeviceUpdateMode(); - -private: - LogitechG560Controller* controller; -}; diff --git a/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller.cpp b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller.cpp index b7380ca21..16b50e33c 100644 --- a/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller.cpp +++ b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller.cpp @@ -12,7 +12,9 @@ #include #include #include +#include #include "LogitechHIDPP20Controller.h" +#include "LogitechHIDPP20ReceiverWatcher.h" #include "RGBController_LogitechHIDPP20.h" #include "LogManager.h" @@ -21,41 +23,75 @@ #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. | -\*----------------------------------------------------------*/ +/*---------------------------------------------------------*\ +| Hard cap on per-call non-HID++ drains in the read loop. A | +| high-polling-rate mouse can put 50+ input reports in the | +| buffer between our reads; this cap prevents pathological | +| input-flood scenarios from locking up a single read call. | +| 64 is enough headroom for normal congestion at 1 kHz. | +\*---------------------------------------------------------*/ static const int HIDPP20_READ_DRAIN_BUDGET = 64; -/*----------------------------------------------------------*\ -| Per-candidate read timeout (ms) for the Centurion 0x50 | -| device-address probe. USB round-trip is <1ms; 5ms gives | -| 5x margin. Worst case (no device responds) 256 × 5 = | -| ~1.3s; typical G522 at addr 0x23 is ~180ms. Matches | -| Solaar's probe_centurion_device_addr constant. | -\*----------------------------------------------------------*/ +/*---------------------------------------------------------*\ +| 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; -/*----------------------------------------------------------*\ -| 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. | -\*----------------------------------------------------------*/ +/*---------------------------------------------------------*\ +| 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; @@ -67,15 +103,17 @@ static constexpr HIDPP20FeatureVersionSet HIDPP20_FEATURE_OBSERVED_VERSIONS[] = { { 0x0620, { 1 }, 1 }, { 0x1D4B, { 0 }, 1 }, - { 0x4540, { 1 }, 1 }, - { 0x8071, { 4 }, 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. | -\*----------------------------------------------------------*/ +/*---------------------------------------------------------*\ +| 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) @@ -101,7 +139,7 @@ static bool FeatureVersionIsObserved(uint16_t feature_id, uint8_t version) return false; /* tracked feature, unknown version */ } - return true; /* feature not tracked — silent */ + return true; /* feature not tracked, silent */ } LogitechHIDPP20Controller::LogitechHIDPP20Controller @@ -111,17 +149,27 @@ LogitechHIDPP20Controller::LogitechHIDPP20Controller uint8_t device_index, bool wireless, std::shared_ptr mutex_ptr, - uint16_t usage_page + uint16_t usage_page, + hid_device* perkey_vl_dev ) { this->dev = dev; + this->dev_perkey_vl = perkey_vl_dev; this->location = path; this->device_index = device_index; this->wireless = wireless; 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); @@ -134,10 +182,8 @@ LogitechHIDPP20Controller::LogitechHIDPP20Controller this->power_thread_running = false; this->pending_activity = -1; this->pending_connection = 0; - this->pending_path_check = 0; this->device_online = true; this->consecutive_timeouts = 0; - this->watcher_mode = false; this->power_state = HIDPP20_POWER_ACTIVE; this->deep_sleep = false; this->consecutive_frame_end_failures = 0; @@ -148,10 +194,11 @@ LogitechHIDPP20Controller::LogitechHIDPP20Controller caps = {}; - /*---------------------------------------------------------*\ - | Default to standard HID++ transport; DiscoverTransport() | - | may change this during Probe() if Centurion is detected. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -164,15 +211,43 @@ LogitechHIDPP20Controller::LogitechHIDPP20Controller 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); + } } /*---------------------------------------------------------*\ @@ -211,12 +286,12 @@ int LogitechHIDPP20Controller::ReadMessage 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. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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); @@ -263,13 +338,14 @@ int LogitechHIDPP20Controller::ReadFromQueue { 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. | - \*-------------------------------------------------*/ + /*---------------------------------------------*\ + | 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; } @@ -277,6 +353,16 @@ int LogitechHIDPP20Controller::ReadFromQueue { 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(); @@ -301,6 +387,38 @@ int LogitechHIDPP20Controller::ReadFromQueue return msg.result; } +/*---------------------------------------------------------*\ +| 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, @@ -311,11 +429,11 @@ int LogitechHIDPP20Controller::SendAndReceive 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. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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, @@ -334,25 +452,25 @@ int LogitechHIDPP20Controller::SendAcked uint8_t* hidpp20_error_out ) { - /*----------------------------------------------------------*\ - | Universal send-and-ack with policy-driven retry. Mirrors | - | the firmware's own event burst pattern: 7-attempt | - | exponential backoff for reliable one-shot commands, tight | - | 2-attempt for streaming animation frames. | - | | - | Loop semantics per attempt: | - | 1. Sleep backoff_ms[i] (0 on first attempt) | - | 2. Bail if device went offline | - | 3. SendMessage; on wire error, mark and retry | - | 4. Read loop bounded by read_window_ms: | - | - matching response -> success | - | - HID++ error 0xFF for our request: | - | BUSY (0x08) + retry_on_busy -> retry the send | - | other code -> hard fail (-1) | - | - HID++ error for different request -> discard | - | - non-matching, non-error frame -> discard | - | - read timeout (0) -> retry the send | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -368,24 +486,38 @@ int LogitechHIDPP20Controller::SendAcked for(uint8_t attempt = 0; attempt < policy.attempts; attempt++) { - /*-----------------------------------------------------*\ - | Backoff before each attempt (0 on first) | - \*-----------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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) { - std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms)); + InterruptibleBackoff(delay_ms); } - /*-----------------------------------------------------*\ - | Bail early if device went offline mid-retry | - \*-----------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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; + } + int send_result = SendMessage(feat_idx, function, send_data, send_len); if(send_result < 0) @@ -397,19 +529,27 @@ int LogitechHIDPP20Controller::SendAcked 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. | - \*-----------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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) @@ -439,39 +579,41 @@ int LogitechHIDPP20Controller::SendAcked if(rd < 0) { - /* Wire error — propagate, don't retry */ + /* Wire error, propagate, don't retry */ return -2; } if(rd == 0) { - /* Window drained with nothing matching — retry the send */ + /* 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 | - \*-------------------------------------------------*/ + /*---------------------------------------------*\ + | 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. | - \*-----------------------------------------------*/ + /*-----------------------------------------*\ + | 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 & 0xF0) == (function & 0xF0) && + (err_func & 0x0F) == HIDPP20_SW_ID) || (transport.bridge_feat_idx != 0 && err_feat == transport.bridge_feat_idx); @@ -493,6 +635,12 @@ int LogitechHIDPP20Controller::SendAcked "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; @@ -501,15 +649,21 @@ int LogitechHIDPP20Controller::SendAcked return -1; } - /* Error for a different request — stale, discard and keep reading */ + /* Error for a different request, stale, discard and keep reading */ continue; } - /*-------------------------------------------------*\ - | Match our expected response | - \*-------------------------------------------------*/ + /*---------------------------------------------*\ + | 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 & 0xF0) == (function & 0xF0) && + (resp_func & 0x0F) == HIDPP20_SW_ID) { if(recv_data && recv_max > 0) { @@ -543,13 +697,13 @@ int LogitechHIDPP20Controller::SendAcked *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. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -575,14 +729,40 @@ int LogitechHIDPP20Controller::SendAckedIntoFAP 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. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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, @@ -606,6 +786,23 @@ int LogitechHIDPP20Controller::SendAckedIntoFAP | Report IDs 0x10 (7 bytes) / 0x11 (20 bytes) | \*---------------------------------------------------------*/ +/*---------------------------------------------------------*\ +| Outgoing frame as hex, for trace-level wire comparison. | +\*---------------------------------------------------------*/ +static std::string hex_frame(const uint8_t* buf, size_t len) +{ + std::string out; + char byte[4]; + + for(size_t i = 0; i < len; i++) + { + snprintf(byte, sizeof(byte), "%02X ", buf[i]); + out += byte; + } + + return out; +} + int LogitechHIDPP20Controller::SendStandard ( uint8_t feat_idx, @@ -614,26 +811,28 @@ int LogitechHIDPP20Controller::SendStandard 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. | - \*-----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Auto-select short (0x10, 7 bytes) vs long | + | (0x11, 20 bytes) based on data length. | + | Upper layers just provide data; transport | + | picks the smallest frame that fits. | + | | + | Windows exception: HIDClass splits the HID++ short | + | and long message Top-Level Collections into separate | + | virtual HID devices (page 0xFF00 usage 1 vs usage 2). | + | We open the long- message TLC, which rejects 7-byte | + | writes. Force long format on Windows so every | + | outgoing frame matches the collection we opened, | + | Linux hidraw and macOS IOHIDManager expose both TLCs | + | through one handle and keep the size-based heuristic. | + \*-----------------------------------------------------*/ uint8_t buf[LOGITECH_LONG_MESSAGE_LEN]; size_t msg_len; #if defined(_WIN32) const bool prefer_short = false; #else - const bool prefer_short = (len <= 3); + const bool prefer_short = (len <= 3) && !long_only.load(); #endif if(prefer_short) @@ -680,6 +879,108 @@ int LogitechHIDPP20Controller::SendStandard result = hid_write(dev, buf, msg_len); } + if(LogManager::get()->GetLogLevel() >= LL_TRACE) + { + LOG_TRACE("%s TX %s(result=%d)", LOG_TAG, hex_frame(buf, msg_len).c_str(), result); + } + + /*-----------------------------------------------------*\ + | 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(LogManager::get()->GetLogLevel() >= LL_TRACE) + { + LOG_TRACE("%s TX %s(result=%d)", LOG_TAG, hex_frame(buf, LOGITECH_LONG_MESSAGE_LEN).c_str(), result); + } + + 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; } @@ -692,19 +993,20 @@ int LogitechHIDPP20Controller::ReadStandardDirect 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. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -739,21 +1041,48 @@ int LogitechHIDPP20Controller::ReadStandardDirect if(result == 0) { - /* hidapi timeout — window expired with nothing pending. */ + /* hidapi timeout, window expired with nothing pending. */ return 0; } - /*-----------------------------------------------------*\ - | Validate report ID. The hidraw can carry HID input | - | reports (keyboard, mouse, media keys) in addition to | - | HID++. Drop anything that isn't a HID++ short (0x10) | - | or long (0x11) message and keep draining within the | - | remaining window — otherwise the parser would read | - | buf[2]/buf[3] as feat/func and misinterpret | - | keystrokes/motion as HID++ events. | - \*-----------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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) + { + LOG_TRACE("%s ReadStandardDirect: dropping frame for index 0x%02X (ours 0x%02X)", + LOG_TAG, 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_LONG_MESSAGE && + response.buffer[0] != LOGITECH_VERY_LONG_MESSAGE) { if(++drained > HIDPP20_READ_DRAIN_BUDGET) { @@ -815,12 +1144,13 @@ int LogitechHIDPP20Controller::SendCenturion 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...] | - \*-----------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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) @@ -863,9 +1193,9 @@ int LogitechHIDPP20Controller::SendCenturion } else { - /*-----------------------------------------------------*\ - | Direct parent device message (no bridge) | - \*-----------------------------------------------------*/ + /*-------------------------------------------------*\ + | Direct parent device message (no bridge) | + \*-------------------------------------------------*/ if(transport.addressed) { buf[0] = transport.report_id; @@ -922,13 +1252,14 @@ int LogitechHIDPP20Controller::ReadCenturionDirect 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. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -976,9 +1307,9 @@ int LogitechHIDPP20Controller::ReadCenturionDirect } } - /*---------------------------------------------------------*\ - | Parse based on transport variant | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Parse based on transport variant | + \*-----------------------------------------------------*/ int hdr_offset = transport.addressed ? 1 : 0; // skip device address byte uint8_t cpl_len = buf[1 + hdr_offset]; @@ -988,24 +1319,25 @@ int LogitechHIDPP20Controller::ReadCenturionDirect if(transport.bridge_feat_idx != 0 && resp_feat == transport.bridge_feat_idx) { - /*--------------------------------------------------------*\ - | CentPPBridge — distinguish events from command responses | - | | - | Bridge events (e.g. ConnectionStateChangedEvent) have | - | func high nibble = 0x00 (event index 0) and swid = 0. | - | These are NOT wrapped sub-device responses — they are | - | bridge-level notifications. Return as-is so the reader | - | thread can detect them. | - | | - | Command responses follow a two-response pattern: | - | 1. ACK: bridge echoes feat+func with our swid | - | 2. MessageEvent: func=1x, swid=0, wrapped sub-device | - \*--------------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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 | - \*-------------------------------------------------*/ + /*---------------------------------------------*\ + | Bridge event, return feat/func/data as-is | + \*---------------------------------------------*/ if(feat_idx_out) *feat_idx_out = resp_feat; if(function_out) *function_out = resp_func; @@ -1021,12 +1353,13 @@ int LogitechHIDPP20Controller::ReadCenturionDirect 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. | - \*---------------------------------------------------*/ + /*---------------------------------------------*\ + | 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(); @@ -1075,9 +1408,9 @@ int LogitechHIDPP20Controller::ReadCenturionDirect if(resp_feat != transport.bridge_feat_idx) { - /*---------------------------------------------*\ - | Not a bridge response — return as-is | - \*---------------------------------------------*/ + /*-----------------------------------------*\ + | Not a bridge response, return as-is | + \*-----------------------------------------*/ if(feat_idx_out) *feat_idx_out = resp_feat; if(function_out) *function_out = resp_func; @@ -1092,11 +1425,11 @@ int LogitechHIDPP20Controller::ReadCenturionDirect } } - /*-----------------------------------------------------*\ - | MessageEvent — unwrap sub-device response. | - | Bridge params: [devId<<4|lenHi, lenLo, subCPL, | - | subFeatIdx, subFunc|swid, subData...] | - \*-----------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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]; @@ -1113,9 +1446,9 @@ int LogitechHIDPP20Controller::ReadCenturionDirect } else { - /*-----------------------------------------------------*\ - | Direct response | - \*-----------------------------------------------------*/ + /*-------------------------------------------------*\ + | Direct response | + \*-------------------------------------------------*/ if(feat_idx_out) *feat_idx_out = resp_feat; if(function_out) *function_out = resp_func; @@ -1139,10 +1472,10 @@ int LogitechHIDPP20Controller::ReadCenturionDirect 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. | - \*-----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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()) @@ -1150,18 +1483,19 @@ uint8_t LogitechHIDPP20Controller::GetFeatureIndex(uint16_t feature_page, return it->second; } - /*---------------------------------------------------------*\ - | Centurion bulk enumeration is complete — if a feature | - | isn't in the map, it doesn't exist. No wire query needed. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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, cache the result. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -1189,7 +1523,7 @@ uint8_t LogitechHIDPP20Controller::GetFeatureIndex(uint16_t feature_page, if(!FeatureVersionIsObserved(feature_page, version)) { - LOG_INFO("%s Feature 0x%04X V%u not previously observed — " + LOG_INFO("%s Feature 0x%04X V%u not previously observed, " "tripwire for version-gated behavior", LOG_TAG, feature_page, version); } @@ -1202,9 +1536,9 @@ uint8_t LogitechHIDPP20Controller::GetFeatureIndex(uint16_t feature_page, return index; } - /*---------------------------------------------------------*\ - | Cache the miss too so we don't re-query failed lookups | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Cache misses too so failed lookups are not re-queried | + \*-----------------------------------------------------*/ caps.feature_map[feature_page] = 0; LOG_DEBUG("%s Feature 0x%04X not found", LOG_TAG, feature_page); @@ -1230,30 +1564,169 @@ uint8_t LogitechHIDPP20Controller::GetFeatureVersion(uint16_t feature_page) cons return 0; } -void LogitechHIDPP20Controller::DiscoverDeviceName() +/*---------------------------------------------------------*\ +| 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() { - /*---------------------------------------------------------*\ - | Centurion sub-devices use 0x0101 (DeviceName). | - | Standard HID++ uses 0x0005 (DeviceNameType). | - \*---------------------------------------------------------*/ - if(transport.type == HIDPP20_TRANSPORT_CENTURION) + static const struct { - /*------------------------------------------------------*\ - | Centurion sub-device: 0x0101 getName returns firmware | - | data, not a readable name. Fall back to a platform- | - | specific lookup that reads the friendly name from the | - | OS's HID enumeration (sysfs HID_NAME on Linux, | - | hid_device_info::product_string on Windows). | - \*------------------------------------------------------*/ - std::string friendly = GetCenturionSubDeviceName(location); + 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" }, + }; - if(!friendly.empty()) + 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) { - caps.device_name = friendly; + LOG_DEBUG("%s feature 0x%04X %-22s present idx=0x%02X V%u", + LOG_TAG, page, relevant_features[i].name, idx, GetFeatureVersion(page)); } else { - caps.device_name = "Logitech Centurion Device"; + 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()); @@ -1264,53 +1737,113 @@ void LogitechHIDPP20Controller::DiscoverDeviceName() if(feat_idx == 0) { - caps.device_name = "Logitech HID++ Device"; + /*-------------------------------------------------*\ + | 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; } - uint8_t recv[16] = {}; - int result = SendAcked(feat_idx, LOTITECH_CMD_DEVICE_NAME_TYPE_GET_COUNT, - nullptr, 0, recv, sizeof(recv)); - - if(result <= 0) + /*-----------------------------------------------------*\ + | 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++) { - caps.device_name = "Logitech HID++ Device"; - return; - } - - unsigned int name_length = recv[0]; - caps.device_name.clear(); - - for(unsigned int offset = 0; offset < name_length; offset += 16) - { - uint8_t send_data[1] = { (uint8_t)offset }; - result = SendAcked(feat_idx, LOGITECH_CMD_DEVICE_NAME_TYPE_GET_DEVICE_NAME, - send_data, 1, recv, sizeof(recv)); + uint8_t recv[16] = {}; + int result = SendAcked(feat_idx, LOTITECH_CMD_DEVICE_NAME_TYPE_GET_COUNT, + nullptr, 0, recv, sizeof(recv)); if(result <= 0) { - break; + continue; } - unsigned int chunk_len = name_length - offset; - if(chunk_len > 16) + unsigned int name_length = recv[0]; + + if(name_length == 0 || name_length > 64) { - chunk_len = 16; + LOG_DEBUG("%s GetCount returned implausible name length %u (attempt %d)", + LOG_TAG, name_length, attempt); + continue; } - caps.device_name.append((char*)recv, chunk_len); + 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; } - LOG_VERBOSE("%s Device name: %s", LOG_TAG, caps.device_name.c_str()); + /*-----------------------------------------------------*\ + | 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. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -1325,48 +1858,59 @@ void LogitechHIDPP20Controller::DiscoverDeviceType() return; } - uint8_t recv[16] = {}; - int result = SendAcked(feat_idx, LOGITECH_CMD_DEVICE_NAME_TYPE_GET_TYPE, - nullptr, 0, recv, sizeof(recv)); + /*-----------------------------------------------------*\ + | 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); - } - else - { - caps.device_type = LOGITECH_DEVICE_TYPE_MOUSE; + 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) | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -1391,10 +1935,10 @@ void LogitechHIDPP20Controller::DiscoverTransport() } } - /*---------------------------------------------------------*\ - | No 0x51 reply — assume 0x50 (addressed) and find the | - | device address. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | No 0x51 reply: assume 0x50 (addressed) and find the | + | device address. | + \*-----------------------------------------------------*/ transport.report_id = 0x50; transport.addressed = true; transport.device_address = 0x00; @@ -1404,9 +1948,9 @@ void LogitechHIDPP20Controller::DiscoverTransport() | address byte; the device silently drops frames | | addressed to the wrong ID, so we brute-force probe | | every candidate with an IRoot fn1 GetProtocolVersion | - | ping. First response wins — real address lives in | - | resp_buf[1] of the reply. Mirrors Solaar's | - | probe_centurion_device_addr; see | + | 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: | @@ -1472,18 +2016,18 @@ void LogitechHIDPP20Controller::EnumerateFeatures(uint8_t feature_set_idx) caps.feature_map.clear(); caps.feature_map_complete = false; - /*---------------------------------------------------------*\ - | Root (0x0000) is always at index 0 | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Root (0x0000) is always at index 0 | + \*-----------------------------------------------------*/ caps.feature_map[0x0000] = 0; if(transport.type == HIDPP20_TRANSPORT_CENTURION) { - /*-----------------------------------------------------*\ - | Centurion sub-device: CenturionFeatureSet fn1 returns | - | ALL features in a single bulk response. | - | [count, (feat_hi, feat_lo, type, version) × count] | - \*-----------------------------------------------------*/ + /*-------------------------------------------------*\ + | Centurion sub-device: CenturionFeatureSet fn1 | + | returns ALL features in one bulk response. | + | [count, (feat_hi, feat_lo, type, version) x N] | + \*-------------------------------------------------*/ uint8_t send_data[1] = { 0x00 }; uint8_t recv_data[60] = {}; @@ -1512,7 +2056,7 @@ void LogitechHIDPP20Controller::EnumerateFeatures(uint8_t feature_set_idx) if(!FeatureVersionIsObserved(feat_id, feat_version)) { - LOG_INFO("%s Feature 0x%04X V%u not previously observed — " + LOG_INFO("%s Feature 0x%04X V%u not previously observed, " "tripwire for version-gated behavior", LOG_TAG, feat_id, feat_version); } @@ -1523,21 +2067,23 @@ void LogitechHIDPP20Controller::EnumerateFeatures(uint8_t feature_set_idx) } 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. | - \*-----------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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). | - \*--------------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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); @@ -1547,10 +2093,11 @@ void LogitechHIDPP20Controller::DiscoverFirmwareInfo() return; } - /*------------------------------------------------------*\ - | fn1 getFirmwareVersion(entityIndex=0) — main firmware | - | Response: [fwType, additional, version_hi, version_lo] | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | fn1 getFirmwareVersion(entityIndex=0): | + | main firmware Response: [fwType, | + | additional, version_hi, version_lo] | + \*-------------------------------------------------*/ { uint8_t send_data[1] = { 0x00 }; uint8_t recv_data[16] = {}; @@ -1572,10 +2119,10 @@ void LogitechHIDPP20Controller::DiscoverFirmwareInfo() } } - /*-----------------------------------------------------*\ - | fn2 getSerialNumber on 0x0100 (DeviceInfo) | - | Response: [stringLen, serial...] | - \*-----------------------------------------------------*/ + /*-------------------------------------------------*\ + | fn2 getSerialNumber on 0x0100 (DeviceInfo) | + | Response: [stringLen, serial...] | + \*-------------------------------------------------*/ { uint8_t recv_data[16] = {}; @@ -1587,7 +2134,24 @@ void LogitechHIDPP20Controller::DiscoverFirmwareInfo() uint8_t slen = recv_data[0]; if(slen > 15) slen = 15; - caps.serial_number = std::string((char*)&recv_data[1], slen); + 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()); } @@ -1603,10 +2167,11 @@ void LogitechHIDPP20Controller::DiscoverFirmwareInfo() return; } - /*------------------------------------------------------------*\ - | fn0 GetEntityCount — entity count, unitId, transport PIDs | - | Response: [count, unitId(4), transport(2), PID1(2), PID2(2)] | - \*------------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | fn0 GetEntityCount: entity count, unitId, | + | transport PIDs Response: [count, | + | unitId(4), transport(2), PID1(2), PID2(2)] | + \*-----------------------------------------------------*/ uint8_t entity_count = 1; { @@ -1618,10 +2183,10 @@ void LogitechHIDPP20Controller::DiscoverFirmwareInfo() { entity_count = recv_data[0]; - /*-------------------------------------------------*\ - | Extract unitId — stable hardware identity across | - | all connection paths (USB, wireless, dongle). | - \*-------------------------------------------------*/ + /*---------------------------------------------*\ + | 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]); @@ -1629,23 +2194,28 @@ void LogitechHIDPP20Controller::DiscoverFirmwareInfo() 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 device doesn't report one | - \*-------------------------------------------------*/ + /*---------------------------------------------*\ + | 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", - LOG_TAG, caps.unit_id.c_str(), caps.pid_wireless, caps.pid_wired); + 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 from the PID pair. | - \*-------------------------------------------------*/ + /*---------------------------------------------*\ + | 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]); @@ -1653,10 +2223,25 @@ void LogitechHIDPP20Controller::DiscoverFirmwareInfo() { const HIDPP20DeviceQuirkEntry& entry = HIDPP20_DEVICE_QUIRK_TABLE[q]; - if((entry.pid_wireless != 0 && entry.pid_wireless == caps.pid_wireless) || - (entry.pid_wired != 0 && entry.pid_wired == caps.pid_wired)) + for(size_t p = 0; p < 3; p++) { - caps.quirks |= entry.quirks; + 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); } } @@ -1667,11 +2252,12 @@ void LogitechHIDPP20Controller::DiscoverFirmwareInfo() } } - /*----------------------------------------------------------*\ - | 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) | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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 }; @@ -1708,9 +2294,9 @@ void LogitechHIDPP20Controller::DiscoverFirmwareInfo() LOG_DEBUG("%s No main firmware entity found", LOG_TAG); } - /*---------------------------------------------------------*\ - | fn2 GetDeviceSerialNumber — ASCII serial up to 16 bytes | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | fn2 GetDeviceSerialNumber: ASCII, up to 16 bytes | + \*-----------------------------------------------------*/ { uint8_t recv_data[16] = {}; @@ -1723,9 +2309,9 @@ void LogitechHIDPP20Controller::DiscoverFirmwareInfo() memcpy(serial, recv_data, 16); serial[16] = '\0'; - /*-------------------------------------------------*\ - | Trim trailing nulls/spaces | - \*-------------------------------------------------*/ + /*---------------------------------------------*\ + | Trim trailing nulls/spaces | + \*---------------------------------------------*/ for(int i = 15; i >= 0; i--) { if(serial[i] == '\0' || serial[i] == ' ') @@ -1750,9 +2336,9 @@ void LogitechHIDPP20Controller::DiscoverFirmwareInfo() void LogitechHIDPP20Controller::DiscoverRGBEffects() { - /*---------------------------------------------------------*\ - | Try 0x8071 first, then 0x0600 (Centurion), then 0x8070 | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Try 0x8071, then 0x0600 (Centurion), then 0x8070 | + \*-----------------------------------------------------*/ caps.idx_rgb_effects = GetFeatureIndex(HIDPP20_FEAT_RGB_EFFECTS); caps.rgb_feature_page = HIDPP20_FEAT_RGB_EFFECTS; @@ -1774,11 +2360,12 @@ void LogitechHIDPP20Controller::DiscoverRGBEffects() 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. | - \*------------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -1798,11 +2385,11 @@ void LogitechHIDPP20Controller::DiscoverRGBEffects() caps.sw_control_simple = false; } - /*---------------------------------------------------------*\ - | GetInfo: discover cluster count | - | 0x8071: data = [0xFF, 0xFF, 0x00] | - | 0x8070: data = [] (empty) | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -1848,9 +2435,9 @@ void LogitechHIDPP20Controller::DiscoverRGBEffects() GetFeatureVersion(caps.rgb_feature_page), cluster_count); } - /*---------------------------------------------------------*\ - | GetRgbClusterInfo for each cluster | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | GetRgbClusterInfo for each cluster | + \*-----------------------------------------------------*/ for(unsigned int i = 0; i < cluster_count; i++) { HIDPP20ZoneCluster cluster; @@ -1888,9 +2475,9 @@ void LogitechHIDPP20Controller::DiscoverRGBEffects() 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 | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | GetEffectInfo for each effect in this cluster | + \*-------------------------------------------------*/ for(unsigned int j = 0; j < cluster.effect_count; j++) { HIDPP20Effect effect; @@ -1907,12 +2494,25 @@ void LogitechHIDPP20Controller::DiscoverRGBEffects() continue; } - effect.effect_id = (response.data[2] << 8) | response.data[3]; - effect.capabilities = (response.data[4] << 8) | response.data[5]; + /*---------------------------------------------*\ + | 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", - LOG_TAG, j, effect.effect_id, effect.capabilities, effect.default_period); + 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); } @@ -1920,13 +2520,44 @@ void LogitechHIDPP20Controller::DiscoverRGBEffects() 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. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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(); } @@ -1937,7 +2568,7 @@ void LogitechHIDPP20Controller::DiscoverEffectCards() | 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 | + | 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. | @@ -1978,11 +2609,12 @@ void LogitechHIDPP20Controller::DiscoverEffectCards() 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. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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( @@ -2020,7 +2652,7 @@ void LogitechHIDPP20Controller::DiscoverEffectCards() /*---------------------------------------------------------*\ | Feature 0x0620 Headset RGB Hostmode (Centurion G522 / | -| PRO X 2). Separate feature from 0x8071/0x0600/0x8070 — | +| 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. | @@ -2040,12 +2672,37 @@ void LogitechHIDPP20Controller::DiscoverHeadsetRGBHostmode() GetFeatureVersion(HIDPP20_FEAT_HEADSET_RGB_HOSTMODE), caps.idx_headset_rgb_hostmode); - /*---------------------------------------------------------*\ - | fn1 GetRGBZoneInfo — empty request, returns a zone list. | - | Exact packing is not fully pinned down by the protocol | - | doc; log the raw response so a tester's log is enough to | - | refine the decoder. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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, @@ -2066,11 +2723,11 @@ void LogitechHIDPP20Controller::DiscoverHeadsetRGBHostmode() 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. | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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) @@ -2087,20 +2744,36 @@ void LogitechHIDPP20Controller::DiscoverHeadsetRGBHostmode() 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 " + 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. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -2112,12 +2785,13 @@ void LogitechHIDPP20Controller::DiscoverHeadsetRGBHostmode() 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. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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", @@ -2126,17 +2800,16 @@ void LogitechHIDPP20Controller::DiscoverHeadsetRGBHostmode() void LogitechHIDPP20Controller::DiscoverPerKeyZones() { - /*---------------------------------------------------------*\ - | Try 0x8081 first, fall back to 0x8080 | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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); - if(caps.idx_perkey_v2 == 0) - { - caps.idx_perkey_v1 = GetFeatureIndex(HIDPP20_FEAT_PER_KEY_LIGHTING_V1); - } - - uint8_t perkey_idx = (caps.idx_perkey_v2 != 0) ? caps.idx_perkey_v2 : caps.idx_perkey_v1; + uint8_t perkey_idx = caps.idx_perkey_v2; if(perkey_idx == 0) { @@ -2144,34 +2817,35 @@ void LogitechHIDPP20Controller::DiscoverPerKeyZones() 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. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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. | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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, @@ -2182,11 +2856,12 @@ void LogitechHIDPP20Controller::DiscoverPerKeyZones() 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). | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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; @@ -2199,12 +2874,13 @@ void LogitechHIDPP20Controller::DiscoverPerKeyZones() { 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. | - \*----------------------------------------------*/ + /*-----------------------------------------*\ + | 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 " @@ -2222,10 +2898,10 @@ void LogitechHIDPP20Controller::DiscoverPerKeyZones() caps.has_perkey = !caps.perkey_zone_ids.empty(); - /*----------------------------------------------------------*\ - | Detect numpad presence from zone bitmask. | - | Numpad zones are 80-96 in Solaar's KEYCODES numbering. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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) @@ -2244,6 +2920,339 @@ void LogitechHIDPP20Controller::DiscoverPerKeyZones() 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); @@ -2277,17 +3286,18 @@ 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. | - \*-----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -2310,31 +3320,33 @@ bool LogitechHIDPP20Controller::Probe() } } - /*----------------------------------------------------------*\ - | 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. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Test IRoot by looking up a known feature. | + | | + | Standard HID++: look up FeatureSet (0x0001), must | + | exist. Centurion dongle: look up CentPPBridge | + | (0x0003), the dongle doesn't have FeatureSet, but | + | must have the bridge to reach the sub-device. | + | | + | Retry up to 3 times, wireless devices behind | + | a shared receiver can return stale responses. | + \*-----------------------------------------------------*/ uint8_t test_idx = 0; if(transport.type == HIDPP20_TRANSPORT_CENTURION) { - /*------------------------------------------------------*\ - | Centurion: try CentPPBridge (0x0003) first for dongle. | - | If not found, try FeatureSet (0x0001) for wired/direct | - | connection where the device IS the endpoint. | - | | - | This is the "is anyone there?" check — use the fast- | - | fail probe policy so non-Centurion or unreachable | - | devices bail in ~500ms instead of ~6s. Once we have a | - | positive response, subsequent discovery uses reliable. | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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) @@ -2342,30 +3354,31 @@ bool LogitechHIDPP20Controller::Probe() transport.bridge_feat_idx = test_idx; transport.sub_device_id = 0; - LOG_DEBUG("%s CentPPBridge at index %d — routing to sub-device", + 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. | - \*---------------------------------------------------*/ + /*---------------------------------------------*\ + | 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, @@ -2383,14 +3396,15 @@ bool LogitechHIDPP20Controller::Probe() 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. | - \*----------------------------------------*/ + /*-------------------------------------*\ + | 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; @@ -2404,18 +3418,18 @@ bool LogitechHIDPP20Controller::Probe() } else { - LOG_DEBUG("%s No CentPPBridge — Centurion direct connection", LOG_TAG); + LOG_DEBUG("%s No CentPPBridge: Centurion direct connection", LOG_TAG); test_idx = GetFeatureIndex(HIDPP20_FEAT_FEATURE_SET, HIDPP20_POLICY_PROBE); } } else { - /*-------------------------------------------------------*\ - | Standard HID++: probe FeatureSet (0x0001) — fast-fail. | - | The probe policy already includes its own retry; the | - | outer loop is preserved for buffer-flushing behavior | - | between attempts. | - \*-------------------------------------------------------*/ + /*-------------------------------------------------*\ + | Standard HID++: probe FeatureSet (0x0001): | + | fast-fail. The probe policy already includes | + | its own retry; the outer loop is preserved for | + | buffer-flushing behavior between attempts. | + \*-------------------------------------------------*/ for(int attempt = 0; attempt < 3 && test_idx == 0; attempt++) { if(attempt > 0) @@ -2435,15 +3449,15 @@ bool LogitechHIDPP20Controller::Probe() if(test_idx == 0) { - LOG_DEBUG("%s IRoot probe failed at %s — device does not respond", + 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. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | If retries were needed, flush delayed responses from | + | failed attempts before continuing with discovery. | + \*-----------------------------------------------------*/ { uint8_t post_buf[64]; @@ -2452,18 +3466,19 @@ bool LogitechHIDPP20Controller::Probe() } } - /*-----------------------------------------------------------*\ - | 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. | - \*-----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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. | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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 @@ -2471,66 +3486,77 @@ bool LogitechHIDPP20Controller::Probe() 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 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_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. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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 | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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 | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Probe WirelessStatus (0x1D4B) for reconnect | + | detection. Lightspeed devices behind kernel- | + | managed receivers send WirelessStatus events | + | when they reconnect after power cycle. Cache | + | the feature index so the reader thread can | + | detect these events without sending commands. | + \*-----------------------------------------------------*/ caps.idx_wireless_status = GetFeatureIndex(HIDPP20_FEAT_WIRELESS_STATUS); - if(!caps.has_zone_effects && !caps.has_perkey) + /*-----------------------------------------------------*\ + | 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 bridge stay alive to watch for | - | sub-device connection events, even without RGB. | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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; @@ -2546,13 +3572,77 @@ bool LogitechHIDPP20Controller::Probe() 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, HIDPP20_POLICY_PROBE) == 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(). | - \*-----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | No device state changes here, let firmware effects | + | keep running until DeviceUpdateMode claims control | + | with real colors ready via ClaimSWControlIfNeeded(). | + \*-----------------------------------------------------*/ init_generation++; initialized = true; } @@ -2564,38 +3654,47 @@ void LogitechHIDPP20Controller::Shutdown() return; } + /*-----------------------------------------------------*\ + | Sender first: no frame may be mid-flight | + | while SW control is released below. | + \*-----------------------------------------------------*/ + StopSenderThread(); StopPowerManager(); - /*---------------------------------------------------------*\ - | Release SW control | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Release SW control | + \*-----------------------------------------------------*/ if(caps.idx_rgb_effects != 0) { SetSWControl(0, 0); } - /*---------------------------------------------------------*\ - | Restore firmware mode | - \*---------------------------------------------------------*/ - if(caps.idx_profile_management != 0) + /*-----------------------------------------------------*\ + | Restore firmware mode (skip if we never left onboard) | + \*-----------------------------------------------------*/ + if(!(caps.quirks & HIDPP20_QUIRK_KEEP_ONBOARD_MODE)) { - 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); + if(caps.idx_profile_management != 0) + { + uint8_t data[1] = { 0x03 }; + blankFAPmessage response; + SendAckedIntoFAP(caps.idx_profile_management, FN_8101_GET_SET_MODE, + data, 1, response); + } + else if(caps.idx_onboard_profiles != 0) + { + uint8_t data[1] = { 0x01 }; + blankFAPmessage response; + SendAckedIntoFAP(caps.idx_onboard_profiles, FN_8100_SET_ONBOARD_MODE, + data, 1, response); + } } - /*---------------------------------------------------------*\ - | Release 0x0620 Headset RGB hostmode claim. Best-effort; | - | mirrors the SetHostMode() additive branch. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -2648,18 +3747,21 @@ int LogitechHIDPP20Controller::SetSWControl(uint8_t mode, uint8_t flags) if(caps.sw_control_simple) { - /*------------------------------------------------------*\ - | 0x8070: simple [enabled(bool), persist(bool)] | - \*------------------------------------------------------*/ - uint8_t data[2] = { (uint8_t)(mode > 0 ? 0x01 : 0x00), 0x00 }; + /*-------------------------------------------------*\ + | 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] | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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); @@ -2699,51 +3801,12 @@ void LogitechHIDPP20Controller::SetHostMode() } else if(caps.idx_onboard_profiles != 0) { - /*-------------------------------------------------------*\ - | Observed vendor-app wire pattern on G502 X PLUS: | - | unconditional SetOnboardMode(host) immediately | - | followed by a GetOnboardMode verify read. The vendor | - | app never reads first — it writes fn1 with 0x02 then | - | re-queries fn2, ignoring the response value (no retry | - | logic, no branching on it). The verify appears to be a | - | state-settle / sync point rather than a check, but | - | since we don't know its firmware-side effect, we mirror | - | it. | - | | - | An earlier revision of this function added a pre-Set | - | GetOnboardMode guard to skip the write when already | - | in host mode. That deviated from the observed wire | - | behavior, so it has been removed. | - \*-------------------------------------------------------*/ uint8_t set_data[1] = { 0x02 }; blankFAPmessage set_response; SendAckedIntoFAP(caps.idx_onboard_profiles, FN_8100_SET_ONBOARD_MODE, set_data, 1, set_response); - blankFAPmessage verify_response; - SendAckedIntoFAP(caps.idx_onboard_profiles, FN_8100_GET_ONBOARD_MODE, - nullptr, 0, verify_response); - - LOG_DEBUG("%s OnboardProfiles set to host mode, verify=0x%02X", - LOG_TAG, verify_response.data[0]); - } - - /*-------------------------------------------------------*\ - | 0x0620 Headset RGB hostmode claim. Additive — a headset | - | exposing 0x0620 typically won't also have 0x8100/0x8101 | - | but we don't assume mutual exclusion. Sticky claim, not | - | re-issued per write; wake path re-enters SetHostMode | - | after reconnect which reinstates it for free. | - \*-------------------------------------------------------*/ - if(caps.idx_headset_rgb_hostmode != 0) - { - uint8_t on = 0x01; - blankFAPmessage claim_response; - SendAckedIntoFAP(caps.idx_headset_rgb_hostmode, - FN_0620_SET_HOST_MODE_STATE, - &on, 1, claim_response); - - LOG_DEBUG("%s 0x0620 SetHostModeState(1) sent", LOG_TAG); + LOG_DEBUG("%s OnboardProfiles set to host mode", LOG_TAG); } } @@ -2754,81 +3817,158 @@ bool LogitechHIDPP20Controller::ClaimSWControlIfNeeded() 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; } - /*----------------------------------------------------------*\ - | Two-phase claim to avoid the onboard→host transition | - | flash (warm-white ~3000K, ~50ms) visible on G502 X PLUS. | - | | - | The SW Control `flags` bits are not "Zone/Power/Effect" | - | as the overview labels them — derived from a G502 wire | - | capture, bit 0 = effect control, bit 1 = power management, | - | bit 2 = NV config. Setting the effect bit suspends the | - | firmware's autonomous effect engine, and anything the host | - | hasn't explicitly painted since that moment shows as the | - | firmware's default LED buffer — on the G502 X PLUS that | - | default is warm-white. | - | | - | The observed vendor-app behavior paints the G502 with | - | flags=6 (power+NV, NOT effect) the entire time, so the | - | firmware effect engine keeps rendering the onboard | - | profile's output right up to the moment SetEffectByIndex | - | replaces it — no visible gap. | - | | - | We can't just stay on flags=6 forever: the idle/wake | - | state machine in OnUserActivity uses flags=5/3 as its | - | active/idle signals and needs those specific values for | - | the firmware to generate the right onUserActivity events. | - | So we claim at flags=6, let the first per-key frame paint | - | through the transition, then upgrade to flags=5 only | - | after the per-key layer is active — at that point per-key | - | masks any zone output anyway, so the 6→5 transition is | - | invisible. | - | | - | The previous sequence wrote `(3,7)` then `(3,5)` to mimic | - | the vendor app's *keyboard first-contact* behavior. That | - | was right for the initial G515 bring-up but wrong for the | - | mouse — on the G502 X PLUS the effect bit at claim time | - | is the root cause of the startup flash. | - | | - | The vendor-app claim sequence does two WritePowerConfig | - | calls around SetHostMode, writing (a) its profile's sleep | - | value then (b) that value minus the firmware off-ramp. | - | We don't write timers at all: we don't have a profile we | - | want to impose on the device, and our host-side StartSleep | - | trigger already fires SetRgbPowerMode(3) explicitly at the | - | moment we want the fade to begin. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Keyboard-family handshake on feature 0x4522 | + | (DisableKeysByUsage). G815 / G915 / G Pro send | + | this fn3 + fn1 empty-payload pair between | + | SetSWControl and the first mode write. | + | Feature-gated inside, no-op on G502 / G515. | + \*-----------------------------------------------------*/ DoDisableKeysByUsageHandshake(); SetRGBPowerMode(1); WritePowerConfig(idle_timeout_s, sleep_timeout_s); - SetHostMode(); - WritePowerConfig(idle_timeout_s, sleep_timeout_s); + + /*-----------------------------------------------------*\ + | 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)", @@ -2836,18 +3976,77 @@ bool LogitechHIDPP20Controller::ClaimSWControlIfNeeded() 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 and the 6→5 transition no longer | - | exposes the firmware's default LED buffer. Upgrading to | - | flags=5 puts the device into the "active steady state" | - | that OnUserActivity expects for idle detection events. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -2877,20 +4076,22 @@ void LogitechHIDPP20Controller::UpgradeSwControlAfterFirstPaint() 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. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -2913,13 +4114,13 @@ void LogitechHIDPP20Controller::DoDisableKeysByUsageHandshake() | (wired-ish connection via Lightspeed receiver). The two | | frames are: | | | -| Frame 2297 (17.348s, ~262ms after SetOnboardMode(02)): | -| 1101091a ff 02 00 00 00 00 00 00 20 64 00 00 01 … | +| 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 | +| 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 | @@ -2928,27 +4129,24 @@ void LogitechHIDPP20Controller::DoDisableKeysByUsageHandshake() | mirror. | | persist=0x01 | | | -| Frame 2321 (17.443s, ~95ms after frame 2297): | -| 1101091a ff 04 00 00 00 00 00 00 00 00 00 00 01 … | +| 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 | +| effectIdx=0x04: OUT OF RANGE on G502 X PLUS (the | | device only enumerates effects 0..3 via | | GetEffectInfo). Likely a "custom / direct mode" | | slot the firmware accepts but does not advertise | | through the normal enumeration. | -| params=[00 × 10] | +| 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). | | | -| We do NOT attempt to derive these values from the effect | -| param layout tables because we don't understand what | -| they mean. They're observed-working bytes from the wire | -| capture and that's the contract. If this prep sequence | -| later turns out to work on other devices, the gating in | -| DeviceUpdateLEDs can be loosened. | +| 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() @@ -2958,25 +4156,27 @@ void LogitechHIDPP20Controller::DoObservedPerKeyPrep() 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. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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 */ + 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 */ @@ -2985,24 +4185,15 @@ void LogitechHIDPP20Controller::DoObservedPerKeyPrep() SendAckedIntoFAP(caps.idx_rgb_effects, caps.fn_set_effect, prep1, 16, prep1_resp); - /*---------------------------------------------------------*\ - | Prep2: SetEffectByIndex with effectIdx set to the first | - | out-of-range slot above the last enumerated effect, all | - | params zero. | - | | - | On the G502 X PLUS (4 enumerated effects: 0..3) this | - | means effectIdx=4 — matches the value in pcap frame 2321. | - | On other devices, effectIdx is parameterized by effect | - | count so the same "first OOR slot" semantic holds. | - | | - | The RE thread's working theory is that this is a firmware | - | "custom/direct mode" slot the effect engine accepts but | - | doesn't advertise through GetEffectInfo. Without that | - | slot being written, the per-key pipeline doesn't enter | - | cleanly and the firmware exposes its default LED state | - | during the claim→paint window (the 3000K warm-white flash | - | we previously observed on cold starts). | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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()) @@ -3032,31 +4223,33 @@ void LogitechHIDPP20Controller::DoObservedPerKeyPrep() void LogitechHIDPP20Controller::DoKeyboardFamilyPerKeyPrep() { - /*---------------------------------------------------------*\ - | G815 / G915 / G Pro per-key takeover prep, cloned from | - | the InitializeDirect sequence in their legacy OpenRGB | - | controllers. Three steps after the claim-time 0x4522 | - | handshake (which fires from ClaimSWControlIfNeeded): | - | | - | 1. For each enumerated cluster, SetEffectByIndex with | - | effectIdx=0 (Off) and persist=1. This deactivates | - | the firmware effect engine per-cluster — different | - | from the G515 static-black fallback, which leaves | - | the effect engine running with a black static color. | - | | - | 2. Send a primer SetIndividualRgbZones write covering | - | one zone (the first enumerated) at black. G915 uses | - | Escape specifically; we use the first enumerated | - | zone for portability. | - | | - | 3. FrameEnd, so the primer write commits and the | - | per-key layer becomes the visible output. | - | | - | Gate (caller's responsibility): feature 0x4522 present | - | AND per-key V2 feature present. G502 / G515 fail the | - | 0x4522 side; older keyboards without 0x8081 fail the | - | per-key side. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -3114,13 +4307,13 @@ void LogitechHIDPP20Controller::DoKeyboardFamilyPerKeyPrep() | 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: | +| arrives, which matches our two problem cases: | | 1. First frame after a reconnect-transient claim | | (Direct mode, no animation timer). | | 2. Last frame of an animation that then stops. | \*---------------------------------------------------------*/ -void LogitechHIDPP20Controller::ScheduleRetryPaint() +bool LogitechHIDPP20Controller::ScheduleRetryPaint() { size_t max_attempts = sizeof(HIDPP20_REPAINT_RETRY_BACKOFF_MS) / sizeof(uint16_t); @@ -3129,15 +4322,16 @@ void LogitechHIDPP20Controller::ScheduleRetryPaint() 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 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; + return false; } uint16_t delay_ms = HIDPP20_REPAINT_RETRY_BACKOFF_MS[attempt]; @@ -3148,6 +4342,8 @@ void LogitechHIDPP20Controller::ScheduleRetryPaint() LOG_DEBUG("%s retry paint scheduled attempt=%u delay=%ums", LOG_TAG, attempt, delay_ms); + + return true; } void LogitechHIDPP20Controller::CancelRetryPaint() @@ -3158,13 +4354,14 @@ void LogitechHIDPP20Controller::CancelRetryPaint() 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. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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{}) @@ -3177,13 +4374,13 @@ void LogitechHIDPP20Controller::TickRetryPaintIfPending() 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. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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); @@ -3213,11 +4410,12 @@ void LogitechHIDPP20Controller::SetPerKeyColors 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. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -3258,13 +4456,14 @@ void LogitechHIDPP20Controller::SetAllPerKeyColor(RGBColor color) uint8_t g = RGBGetGValue(color); uint8_t b = RGBGetBValue(color); - /*----------------------------------------------------------*\ - | Use SetRangeRgbZones (fn5): [start, end, R, G, B] × 3 | - | per packet. Sets all zones in a contiguous range to one | - | color. Gaps in zone IDs are silently ignored by firmware. | - | For uniform color this is far more efficient than fn6: | - | 1-2 packets vs 8 packets for 94 zones. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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) @@ -3302,254 +4501,801 @@ void LogitechHIDPP20Controller::SendPerKeyData const std::vector& zone_ids ) { - /*-----------------------------------------------------------*\ - | Truly fire-and-forget. Push the packet onto the wire, | - | record the zones it covers in outstanding_writes, and | - | return. PerKeyFrameEnd will drain the response queue at | - | end-of-frame and FIFO-match each ACK back to the | - | corresponding outstanding entry. | - | | - | The retry/backoff machinery is intentionally NOT used | - | here — when a streaming frame fails, we don't want to | - | delay the next frame retrying old data. The carry-over | - | of uncommitted zones via sent_colors[i]=HIDPP20_UNCOMMITTED | - | naturally ensures missed keys land in the next frame. | - \*-----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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) { - LOG_DEBUG("%s SendPerKeyData wire send failed (result=%d) func=0x%02X", + /*-------------------------------------------------*\ + | 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); - /* Still record the outstanding entry — its zones will */ - /* be reported as unacked, which is correct. */ + return; } - OutstandingPerKeyWrite entry; - entry.function = function; - entry.zone_ids = zone_ids; - outstanding_writes.push_back(std::move(entry)); + 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, and log the payload so a | + | real run teaches us what the firmware echoes. | + \*-------------------------------------------------*/ + frame_unmatched_acks[resp_func & 0xF0]++; + + char hex[16 * 3 + 1]; + for(int b = 0; b < 16; b++) + { + snprintf(&hex[b * 3], 4, "%02X ", resp_data[b]); + } + LOG_TRACE("%s per-key ACK echo unmatched func=0x%02X data=[%s]", + LOG_TAG, resp_func, hex); + } + + 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); + + if(pending_frame_valid) + { + pending_frames_skipped++; + } + + pending_frame = frame; + pending_frame_valid = true; + } + + pending_frame_cv.notify_one(); +} + +void LogitechHIDPP20Controller::SenderThreadFunc() +{ + while(sender_running.load()) + { + std::vector frame; + uint32_t skipped = 0; + + { + 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; + skipped = pending_frames_skipped; + pending_frames_skipped = 0; + } + + if(skipped > 0) + { + LOG_TRACE("%s sender skipped %u stale frame(s)", LOG_TAG, skipped); + } + + /*-------------------------------------------------*\ + | 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; - - /*---------------------------------------------------------*\ - | Build attempted_zones from the outstanding writes list | - | up front so the caller can use it for both the success | - | and failure paths. | - \*---------------------------------------------------------*/ - for(size_t w = 0; w < outstanding_writes.size(); w++) - { - const std::vector& zone_ids = outstanding_writes[w].zone_ids; - - for(size_t z = 0; z < zone_ids.size(); z++) - { - result.attempted_zones.push_back(zone_ids[z]); - } - } - - if(!device_online.load()) - { - outstanding_writes.clear(); - return result; - } + 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(perkey_idx == 0) + if(!device_online.load() || perkey_idx == 0) { - outstanding_writes.clear(); + result.acked_zones = std::move(frame_acked_zones); + ResetPerKeyFrameState(); return result; } - /*---------------------------------------------------------*\ - | Send FrameEnd directly. No retry, no backoff: a streaming | - | frame failure means the next frame's delta will pick up | - | the missed keys, and we don't want to delay that frame. | - | | - | Format (matches observed wire capture): LONG message | - | (0x11), 16 bytes of zeros. Firmware expects long-format | - | FrameEnd | - | — short-format hits intermittent BUSY. | - \*---------------------------------------------------------*/ - std::this_thread::sleep_for(std::chrono::milliseconds(30)); + std::chrono::steady_clock::time_point send_done = std::chrono::steady_clock::now(); - uint8_t data[16] = {}; - int send_result = SendMessage(perkey_idx, FN_8081_FRAME_END, data, sizeof(data)); - - if(send_result < 0) + if(!frame_aborted) { - LOG_DEBUG("%s FrameEnd wire send failed (result=%d)", LOG_TAG, send_result); - outstanding_writes.clear(); - return result; - } + /*-------------------------------------------------*\ + | 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); - /*---------------------------------------------------------*\ - | Drain responses in FIFO order until we either see the | - | FrameEnd ACK or run out the wait budget. Each per-key | - | write response is matched (by feature + function high | - | nibble) to the head of outstanding_writes; matched zones | - | go into acked_zones. The FrameEnd response itself is the | - | terminating event. | - | | - | Wait budget: 300ms. Generous enough to absorb the slow | - | batch-ACK behavior we've seen on G515 (~700ms p99) for | - | dense per-key frames, but won't actually consume that | - | much time on healthy devices — the loop exits the moment | - | the FrameEnd response shows up. | - \*---------------------------------------------------------*/ - std::chrono::steady_clock::time_point deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(300); - size_t outstanding_idx = 0; - int busy_retries = 0; - - while(true) - { - std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); - if(now >= deadline) + while(frame_responses_seen < frame_packets.size()) { - LOG_DEBUG("%s FrameEnd timed out waiting for ACK (matched %zu/%zu writes)", - LOG_TAG, outstanding_idx, outstanding_writes.size()); - break; - } + int remaining = (int)std::chrono::duration_cast( + ack_deadline - std::chrono::steady_clock::now()).count(); - int remaining = (int)std::chrono::duration_cast( - deadline - now).count(); - if(remaining <= 0) - { - break; - } - - uint8_t resp_feat = 0; - uint8_t resp_func = 0; - uint8_t resp_data[60] = {}; - - int rd = ReadMessage(&resp_feat, &resp_func, - resp_data, sizeof(resp_data), - remaining); - - if(rd < 0) - { - LOG_DEBUG("%s FrameEnd read error (result=%d)", LOG_TAG, rd); - break; - } - - if(rd == 0) - { - /* timeout */ - LOG_DEBUG("%s FrameEnd timed out waiting for ACK (matched %zu/%zu writes)", - LOG_TAG, outstanding_idx, outstanding_writes.size()); - break; - } - - /*-----------------------------------------------------*\ - | HID++ error frame: feat=0xFF, func=err_feat, | - | data[0]=err_func, data[1]=err_code. | - | | - | The case we care about is BUSY (0x08) for our | - | FrameEnd: the firmware is still draining the per-key | - | write queue and asks us to re-send. Without this we | - | hang on the deadline waiting for an ACK that never | - | comes, since BUSY-rejected commands are not queued. | - | | - | Re-send with a tight budget — 3 retries, 30ms gap. | - | If BUSY persists past that, give up for this frame | - | and let delta carry-over handle it next frame. | - \*-----------------------------------------------------*/ - if(resp_feat == 0xFF) - { - uint8_t err_feat = resp_func; - uint8_t err_func_byte = resp_data[0]; - uint8_t err_code = resp_data[1]; - - bool is_our_frame_end = - (err_feat == perkey_idx) && - ((err_func_byte & 0xF0) == FN_8081_FRAME_END); - - if(is_our_frame_end) + if(remaining <= 0) { - size_t max_busy_retries = - sizeof(HIDPP20_FRAME_END_BUSY_BACKOFF_MS) / sizeof(uint16_t); - - if(err_code == 0x08 && (size_t)busy_retries < max_busy_retries) - { - uint16_t delay_ms = HIDPP20_FRAME_END_BUSY_BACKOFF_MS[busy_retries]; - busy_retries++; - LOG_TRACE("%s FrameEnd BUSY, re-sending (retry %d, delay %ums)", - LOG_TAG, busy_retries, delay_ms); - std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms)); - SendMessage(perkey_idx, FN_8081_FRAME_END, data, sizeof(data)); - continue; - } - - /* Non-BUSY error or out of retries — frame committed=false */ - LOG_DEBUG("%s FrameEnd error 0x%02X (retries=%d)", - LOG_TAG, err_code, busy_retries); break; } - /* Error for an unrelated request — discard and keep reading */ - continue; - } + if(remaining > (int)HIDPP20_PERKEY_ACK_TAIL_MS) + { + remaining = (int)HIDPP20_PERKEY_ACK_TAIL_MS; + } - /*-----------------------------------------------------*\ - | Discard frames that aren't from our perkey feature. | - \*-----------------------------------------------------*/ - if(resp_feat != perkey_idx) - { - continue; + if(ProcessOnePerKeyResponse(remaining, perkey_idx) <= 0) + { + break; + } } - - uint8_t resp_func_hi = resp_func & 0xF0; - - /*-----------------------------------------------------*\ - | FrameEnd response — terminator. | - \*-----------------------------------------------------*/ - if(resp_func_hi == FN_8081_FRAME_END) - { - result.frame_end_acked = true; - break; - } - - /*-----------------------------------------------------*\ - | Per-key write response. Match against the next | - | outstanding entry by function high nibble. If the | - | head doesn't match (a write was dropped on the wire | - | or the firmware is responding out of order), skip | - | unmatched heads — those entries' zones will be left | - | out of acked_zones and treated as uncommitted. | - \*-----------------------------------------------------*/ - while(outstanding_idx < outstanding_writes.size() && - outstanding_writes[outstanding_idx].function != resp_func_hi) - { - outstanding_idx++; - } - - if(outstanding_idx >= outstanding_writes.size()) - { - /* No matching outstanding write — stale or unexpected response */ - continue; - } - - for(uint8_t z : outstanding_writes[outstanding_idx].zone_ids) - { - result.acked_zones.push_back(z); - } - outstanding_idx++; } - /*---------------------------------------------------------*\ - | Deep-sleep detection. If FrameEnd failed (no ACK) while | - | we're in the SLEEPING state, the device may have finished | - | its firmware fade and entered deep sleep. Track | - | consecutive failures; once we hit the threshold, suppress | - | further frame sends until Wake() clears the flag. | - | | - | A successful ACK resets the counter — transient BUSY | - | bursts during the fade don't accumulate. | - \*---------------------------------------------------------*/ + 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; + + if(perkey_window > HIDPP20_PERKEY_WINDOW_MIN) + { + perkey_window /= 2; + + if(perkey_window < HIDPP20_PERKEY_WINDOW_MIN) + { + perkey_window = HIDPP20_PERKEY_WINDOW_MIN; + } + + LOG_DEBUG("%s %zu write ACK(s) lost, write window now %zu", + LOG_TAG, lost, perkey_window); + } + } + 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(); + } + + if(!result.attempted_zones.empty()) + { + std::chrono::steady_clock::time_point frame_done = std::chrono::steady_clock::now(); + + int send_ms = (int)std::chrono::duration_cast( + send_done - frame_first_write).count(); + int ack_ms = (int)std::chrono::duration_cast( + acks_done - send_done).count(); + int fe_ms = (int)std::chrono::duration_cast( + frame_done - acks_done).count(); + + if(complete) + { + LOG_TRACE("%s frame committed: %zu zones, %zu packets (%zu echo-matched) " + "send=%dms ack=%dms fe=%dms pred=%ums busy=%d win=%zu", + LOG_TAG, result.attempted_zones.size(), frame_packets.size(), + frame_exact_acks, send_ms, ack_ms, fe_ms, predicted_ms, busy_polls, + perkey_window); + } + else + { + LOG_DEBUG("%s frame %s: %zu/%zu zones acked (%zu packets, %zu echo-matched), " + "frame_end=%d, send=%dms ack=%dms fe=%dms pred=%ums busy=%d win=%zu", + LOG_TAG, frame_aborted ? "aborted" : "partial", + result.acked_zones.size(), result.attempted_zones.size(), + frame_packets.size(), frame_exact_acks, + (int)result.frame_end_acked, send_ms, ack_ms, fe_ms, predicted_ms, + busy_polls, perkey_window); + } + } + + 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); @@ -3566,10 +5312,111 @@ PerKeyFrameResult LogitechHIDPP20Controller::PerKeyFrameEnd() } } - outstanding_writes.clear(); 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) | \*---------------------------------------------------------*/ @@ -3593,35 +5440,38 @@ void LogitechHIDPP20Controller::SetZoneEffect 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)] | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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 | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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. | - \*-----------------------------------------------------*/ + /*---------------------------------------------*\ + | "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; @@ -3629,13 +5479,13 @@ void LogitechHIDPP20Controller::SetZoneEffect 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 | - \*------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -3655,11 +5505,49 @@ void LogitechHIDPP20Controller::SetZoneEffect 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 - data[3] = (period > 0) ? (uint8_t)(period / 100) : 50; - data[8] = 0x01; // pattern - data[9] = 0x00; // waveform - data[11] = 0x01; // direction + /*---------------------------------------------*\ + | 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 @@ -3670,6 +5558,19 @@ void LogitechHIDPP20Controller::SetZoneEffect 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 | @@ -3721,7 +5622,7 @@ void LogitechHIDPP20Controller::SetZoneEffect data[9] = period & 0xFF; break; - default: // Unknown — best-effort + default: // Unknown: best-effort data[2] = r; data[3] = g; data[4] = b; @@ -3730,11 +5631,11 @@ void LogitechHIDPP20Controller::SetZoneEffect break; } - /*------------------------------------------------------*\ - | 16-byte payload for all pages; persist at byte[12]. | - | 0x8070 and 0x8071/0x0600 share the same byte position | - | per LogitechProtocolCommon setMode convention. | - \*------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; LOG_DEBUG("%s SetEffect cluster=%u idx=%u id=0x%04X " @@ -3752,14 +5653,14 @@ void LogitechHIDPP20Controller::SetZoneEffect } /*---------------------------------------------------------*\ -| Feature 0x0620 Headset RGB Hostmode — static color write. | +| 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. | +| 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 ( @@ -3777,100 +5678,77 @@ void LogitechHIDPP20Controller::SetHeadsetRGBHostmodeColors return; } - /*---------------------------------------------------------*\ - | If fewer colors than zones, fill the tail with the last | - | provided color. If zero colors, nothing to write. | - \*---------------------------------------------------------*/ if(zone_colors.empty()) { return; } - /*---------------------------------------------------------*\ - | Uniformity check: same color across every zone? | - \*---------------------------------------------------------*/ - RGBColor first = zone_colors[0]; - bool all_same = true; - for(size_t i = 1; i < zones.size(); i++) + /*-----------------------------------------------------*\ + | 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(); - if(c != first) - { - all_same = false; - break; - } + groups[c].push_back(zones[i]); } - uint8_t payload[16]; - size_t payload_len = 0; - uint8_t function = 0; blankFAPmessage response; - if(all_same) + for(const std::pair>& group : groups) { - /*------------------------------------------------------*\ - | fn5 SetRgbZonesSingleValue: [R, G, B, count, zones...] | - \*------------------------------------------------------*/ - function = FN_0620_SET_RGB_ZONES_SINGLE_VALUE; - payload[0] = RGBGetRValue(first); - payload[1] = RGBGetGValue(first); - payload[2] = RGBGetBValue(first); - payload[3] = (uint8_t)zones.size(); + RGBColor color = group.first; + const std::vector& ids = group.second; - size_t n = zones.size(); + /*-------------------------------------------------*\ + | 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] = zones[i]; + payload[4 + i] = ids[i]; } - payload_len = 4 + n; - } - else - { - /*------------------------------------------------------*\ - | fn2 SetIndividualRgbZones: [zone, R, G, B] × N | - | Each entry is 4 bytes; 16-byte payload fits 4 entries. | - \*------------------------------------------------------*/ - function = FN_0620_SET_INDIVIDUAL_RGB_ZONES; - size_t n = zones.size(); - if(n > sizeof(payload) / 4) n = sizeof(payload) / 4; - for(size_t i = 0; i < n; i++) - { - RGBColor c = (i < zone_colors.size()) ? zone_colors[i] : zone_colors.back(); - payload[i * 4 + 0] = zones[i]; - payload[i * 4 + 1] = RGBGetRValue(c); - payload[i * 4 + 2] = RGBGetGValue(c); - payload[i * 4 + 3] = RGBGetBValue(c); - } - payload_len = n * 4; + + SendAckedIntoFAP(caps.idx_headset_rgb_hostmode, + FN_0620_SET_RGB_ZONES_SINGLE_VALUE, + payload, 4 + n, response); } - SendAckedIntoFAP(caps.idx_headset_rgb_hostmode, function, - payload, payload_len, response); - - /*---------------------------------------------------------*\ - | fn6 FrameEnd — byte 0 = 0x01 (transient commit). Never | - | 0x00 (silently discarded) and never 0x02 (tested broken | - | on G522 firmware). | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | fn6 FrameEnd: byte 0 = 0x01 (transient | + | commit). Never 0x00 (silently discarded) and | + | never 0x02 (tested broken on G522 firmware). | + \*-----------------------------------------------------*/ uint8_t frame_end[4] = { 0x01, 0x00, 0x00, 0x00 }; SendAckedIntoFAP(caps.idx_headset_rgb_hostmode, FN_0620_FRAME_END, frame_end, sizeof(frame_end), response); - LOG_TRACE("%s 0x0620 wrote %zu zone(s), fn=0x%02X, FrameEnd[0x01]", - LOG_TAG, zones.size(), function); + LOG_TRACE("%s 0x0620 wrote %zu zone(s) in %zu color group(s), FrameEnd[0x01]", + LOG_TAG, zones.size(), groups.size()); } /*---------------------------------------------------------*\ | Power management (idle/dim/sleep/wake) | | | | Matches Solaar's RGBPowerManager state machine: | -| ACTIVE → DIMMING → IDLE → SLEEPING | +| 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) | +| 7 (init) -> 5 (active, monitor idle) -> | +| 3 (idle, monitor active) -> 5 (wake) | \*---------------------------------------------------------*/ void LogitechHIDPP20Controller::SetRepaintCallback(std::function repaint) @@ -3883,9 +5761,25 @@ void LogitechHIDPP20Controller::SetReapplyActiveModeCallback(std::function cb) +void LogitechHIDPP20Controller::SetPairingName(const std::string& name) { - register_controller_fn = cb; + 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 @@ -3915,28 +5809,30 @@ bool LogitechHIDPP20Controller::IsDeepSleep() const void LogitechHIDPP20Controller::ReprobeSubDevice() { - /*----------------------------------------------------------*\ - | Called by power thread when a sub-device connects through | - | the Centurion bridge. The reader thread is running, so all | - | commands go through SendAndRead → ReadFromQueue. | - | | - | We clear the sub-device feature cache and re-discover | - | everything. The bridge_feat_idx and dongle name are kept. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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); - /*----------------------------------------------------------*\ - | Give the sub-device a moment to settle after connection | - | before sending commands through the bridge. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -3962,10 +5858,10 @@ void LogitechHIDPP20Controller::ReprobeSubDevice() 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. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Re-populate feature map. CenturionFeatureSet | + | is always at index 1 on the sub-device. | + \*-----------------------------------------------------*/ EnumerateFeatures(1); if(!caps.feature_map_complete) @@ -3975,30 +5871,31 @@ void LogitechHIDPP20Controller::ReprobeSubDevice() return; } - /*---------------------------------------------------------*\ - | Discover sub-device identity if not already known. | - \*---------------------------------------------------------*/ - if(caps.device_name.empty() || caps.device_name.find("PRO X 2") == std::string::npos) - { - /*------------------------------------------------------*\ - | Dongle may have a sysfs-derived name; get real name | - | from the sub-device now that it's reachable. | - \*------------------------------------------------------*/ - std::string old_name = caps.device_name; - DiscoverDeviceName(); + /*-----------------------------------------------------*\ + | 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(caps.device_name != old_name) - { - log_tag = "[LogitechHID++ " + caps.device_name + "]"; - } + 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 | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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); @@ -4009,9 +5906,10 @@ void LogitechHIDPP20Controller::ReprobeSubDevice() DiscoverHeadsetRGBHostmode(); } DiscoverPerKeyZones(); + DiscoverPerKey8080(); DiscoverKeyboardLayout(); - if(!caps.has_zone_effects && !caps.has_perkey) + if(!caps.has_zone_effects && !caps.has_perkey && !caps.has_perkey_8080) { LOG_DEBUG("%s Sub-device has no RGB features", LOG_TAG); return; @@ -4020,36 +5918,99 @@ void LogitechHIDPP20Controller::ReprobeSubDevice() LOG_INFO("%s Sub-device probed: zones=%zu perkey=%zu", LOG_TAG, caps.zone_clusters.size(), caps.perkey_zone_ids.size()); - /*---------------------------------------------------------*\ - | Create and register RGBController for the sub-device. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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(); +} - if(register_controller_fn) +/*---------------------------------------------------------*\ +| 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) { - RGBController_LogitechHIDPP20* rgb = new RGBController_LogitechHIDPP20(this); - register_controller_fn(rgb); - - LOG_INFO("%s Registered RGB controller for sub-device", LOG_TAG); + 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). | - | | - | Previously this code split work across the two firmware | - | events (reconnect=1/config_needed=1 then config_needed=0) | - | and only pushed once per event. The first push raced the | - | boot, the second only fired after the animation finished, | - | and a failed claim left sw_control_claimed=true so the | - | 10s firmware watchdog dropped us back to onboard mode. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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(); @@ -4069,45 +6030,13 @@ void LogitechHIDPP20Controller::ReconnectDevice() } } - /*----------------------------------------------------------*\ - | Always invalidate any stale claim so the loop below runs | - | the full claim sequence on each attempt until it sticks. | - \*----------------------------------------------------------*/ - sw_control_claimed = false; - sw_control_needs_upgrade_to_5 = false; - retry_paint_deadline_.store(std::chrono::steady_clock::time_point{}); - retry_paint_attempt_.store(0); - - bool claimed = false; - size_t attempt_count = sizeof(HIDPP20_RECLAIM_BACKOFF_MS) / sizeof(uint16_t); - - for(size_t i = 0; i < attempt_count; i++) - { - if(HIDPP20_RECLAIM_BACKOFF_MS[i] > 0) - { - std::this_thread::sleep_for( - std::chrono::milliseconds(HIDPP20_RECLAIM_BACKOFF_MS[i])); - } - - if(!device_online.load()) - { - return; - } - - if(reapply_active_mode_fn && reapply_active_mode_fn()) - { - claimed = true; - LOG_INFO("%s Device reconnected — SW control reclaimed (attempt %zu/%zu)", - LOG_TAG, i + 1, attempt_count); - break; - } - } - - if(!claimed) - { - LOG_WARNING("%s Device reconnected — SW control reclaim failed after %zu attempts", - LOG_TAG, attempt_count); - } + 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) { @@ -4116,28 +6045,29 @@ void LogitechHIDPP20Controller::ReconnectDevice() } } +bool LogitechHIDPP20Controller::DiscoveryInProgress() const +{ + return discovery_in_progress_.load(); +} + void LogitechHIDPP20Controller::RediscoverFeatures() { - /*---------------------------------------------------------*\ - | Clear the cached HID++ feature map and all index/ | - | function-byte derivations, then re-run the standard | - | discovery sequence on the current hid_device handle. | - | | - | Used in two situations: | - | - FullReprobe (a previously-unreachable device just | - | became reachable; reader thread is already running) | - | - SwapHIDHandle (USB/wireless path migration; the new | - | path's HID++ feature map may have completely | - | different feature indices than the old one — observed | - | on the G515 LS TKL where wireless RGBEffects sits at | - | idx 0x09 but the USB path puts it elsewhere, causing | - | INVALID_FEATURE_INDEX (0x07) errors on every cached | - | idx_rgb_effects/fn_set_effect/etc. lookup). | - | | - | Caller is responsible for state that lives outside the | - | feature map (sw_control_claimed, frame counters, online | - | flag, threads). | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -4164,34 +6094,37 @@ void LogitechHIDPP20Controller::RediscoverFeatures() 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 lives outside caps | + | (discovered lazily by QueryExternalPower on first | + | use) so it isn't cleared by the caps reset above. | + | Clear it here too so the next QueryExternalPower | + | call re-probes on the new path, the old path's | + | feature index may not exist, or may map to a | + | different feature entirely, on the new map. | + \*-----------------------------------------------------*/ idx_unified_battery = 0; last_power_raw = 0xFFFF; - /*---------------------------------------------------------*\ - | Force ApplyPowerSavingProfile's dedup to re-emit its | - | "Idle management: ..." line on the next call so a path | - | transition always produces a full state confirmation in | - | the log, symmetric with the QueryExternalPower re-log. | - | Inverting ps_last_logged_external guarantees the boolean | - | comparison trips regardless of the current power state. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Standard HID++ features are looked up on-demand, no | + | bulk enumeration needed. Just re-discover everything. | + \*-----------------------------------------------------*/ DiscoverDeviceName(); log_tag = "[LogitechHID++ " + caps.device_name + "]"; DiscoverDeviceType(); @@ -4208,194 +6141,113 @@ void LogitechHIDPP20Controller::RediscoverFeatures() DiscoverHeadsetRGBHostmode(); } DiscoverPerKeyZones(); + DiscoverPerKey8080(); DiscoverKeyboardLayout(); -} -void LogitechHIDPP20Controller::FullReprobe() -{ - /*----------------------------------------------------------*\ - | Called by power thread when a failed-probe device becomes | - | reachable. Like ReprobeSubDevice but for non-bridge | - | standard HID++ devices. Reader thread is running. | - \*----------------------------------------------------------*/ - LOG_DEBUG("%s Full re-probe of previously unreachable device", LOG_TAG); - - FlushResponseQueue(); - RediscoverFeatures(); - - if(!caps.has_zone_effects && !caps.has_perkey) - { - LOG_DEBUG("%s Device has no RGB features after re-probe", LOG_TAG); - return; - } - - LOG_INFO("%s Re-probe complete: zones=%zu perkey=%zu", - LOG_TAG, caps.zone_clusters.size(), caps.perkey_zone_ids.size()); - - device_online.store(true); - consecutive_timeouts.store(0); - watcher_mode.store(false); - Initialize(); - - if(register_controller_fn) - { - RGBController_LogitechHIDPP20* rgb = new RGBController_LogitechHIDPP20(this); - register_controller_fn(rgb); - LOG_INFO("%s Registered RGB controller after re-probe", LOG_TAG); - } -} - -void LogitechHIDPP20Controller::StartProbeWatcher() -{ - /*---------------------------------------------------------*\ - | Start reader + power threads in watcher mode for a device | - | that failed initial probe. The power thread periodically | - | retries IRoot until the device becomes reachable. | - \*---------------------------------------------------------*/ - if(reader_running) - { - return; - } - - watcher_mode.store(true); - device_online.store(false); - - pending_connection = 0; - reader_running = true; - reader_thread = new std::thread(&LogitechHIDPP20Controller::ReaderThreadFunc, this); - - power_thread_running = true; - power_thread = new std::thread(&LogitechHIDPP20Controller::PowerThreadFunc, this); - - LOG_DEBUG("%s Probe watcher started (retrying every 5s)", LOG_TAG); + /*-----------------------------------------------------*\ + | 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(); } /*---------------------------------------------------------*\ -| ScanForDevice and GetCenturionSubDeviceName live in | -| LogitechHIDPP20Controller_Linux.cpp / | -| LogitechHIDPP20Controller_Windows_MacOS.cpp. Both are | -| the only parts of this controller that touch platform- | -| specific HID enumeration details (sysfs vs hidapi | -| serial_number). | +| Per-link feature-index cache: indices are assigned per | +| transport, so a switch-back restores instead of | +| rediscovering. | \*---------------------------------------------------------*/ - -void LogitechHIDPP20Controller::SwapHIDHandle - ( - hid_device* new_dev, - const std::string& new_path - ) +std::string LogitechHIDPP20Controller::CurrentLinkKey() const { - /*----------------------------------------------------------*\ - | Stop reader/power threads, swap HID handle, restart. | - | Called from the power thread — we can't join ourselves, | - | so we stop the reader, swap, and flag for restart. | - | | - | Actually, we're called from the power thread's scan loop. | - | The reader thread is running. We need to: | - | 1. Stop the reader thread | - | 2. Close old handle, set new one | - | 3. Restart reader thread | - | 4. Reinit device | - | The power thread keeps running throughout. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Key the link: rx#slot over the dongle, usb#idx | + | direct. hidraw paths are reused by the kernel so | + | aren't used. A slot collision across dongles is | + | caught by the reclaim self-heal. | + \*-----------------------------------------------------*/ + return std::string(wireless ? "rx#" : "usb#") + std::to_string((int)device_index); +} - /*----------------------------------------------------------*\ - | Stop reader thread | - \*----------------------------------------------------------*/ - reader_running = false; +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; +} - if(reader_thread && reader_thread->joinable()) +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) { - reader_thread->join(); + return; } - delete reader_thread; - reader_thread = nullptr; - - /*---------------------------------------------------------*\ - | Swap HID handle | - \*---------------------------------------------------------*/ - hid_close(dev); - dev = new_dev; - location = new_path; - log_tag = "[LogitechHID++ " + caps.device_name + "]"; - - /*----------------------------------------------------------*\ - | Re-discover transport (might change between Centurion and | - | standard HID++ if device switches connection types). | - \*----------------------------------------------------------*/ - DiscoverTransport(); - - /*---------------------------------------------------------*\ - | Reset state for new connection | - \*---------------------------------------------------------*/ - FlushResponseQueue(); - device_online.store(true); - consecutive_timeouts.store(0); - sw_control_claimed = false; - sw_control_needs_upgrade_to_5 = false; - retry_paint_deadline_.store(std::chrono::steady_clock::time_point{}); - retry_paint_attempt_.store(0); - frame_counter = 0; - + /*-----------------------------------------------------*\ + | 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) { - std::lock_guard lock(power_mutex); - dim_brightness_pct.store(100); - power_state = HIDPP20_POWER_ACTIVE; + return; } - /*----------------------------------------------------------*\ - | Restart reader thread on new handle | - \*----------------------------------------------------------*/ - reader_running = true; - reader_thread = new std::thread(&LogitechHIDPP20Controller::ReaderThreadFunc, this); + link_index_cache_[CurrentLinkKey()] = SnapshotLinkIndexMap(); +} - /*--------------------------------------------------------------*\ - | Re-discover the HID++ feature map on the new path. The | - | wireless dongle path and the USB-direct path expose | - | DIFFERENT feature index assignments for the same logical | - | features — observed on the G515 LS TKL where wireless | - | RGBEffects sits at idx 0x09 but the USB path puts it | - | elsewhere. Without this rediscovery, the cached idx_* | - | values from the old path point at the wrong features on | - | the new one and every reclaim call returns HID++ error | - | 0x07 INVALID_FEATURE_INDEX. | - | | - | Must run after the reader thread is restarted (the | - | discovery uses queue-backed reads) and before the | - | reapply_active_mode_fn callback (so the SW control claim sees | - | correct indices). | - \*--------------------------------------------------------------*/ +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(); - - /*---------------------------------------------------------*\ - | Reinit device with colors | - \*---------------------------------------------------------*/ - if(reapply_active_mode_fn) - { - reapply_active_mode_fn(); - } - - if(caps.has_power_mgmt) - { - ReadFirmwareTimers(); - ReadNvSleepRampConfig(); - QueryExternalPower(); - ApplyPowerSavingProfile(); - } - - LOG_INFO("%s Device reconnected on new path — colors restored", LOG_TAG); + last_fap_error_ = 0; + ReclaimSWControl(reclaim_reason, false); } void LogitechHIDPP20Controller::StartEventWatcher() { - /*------------------------------------------------------------*\ - | Start reader thread only (no power thread) to watch for | - | connection events on Centurion dongles without sub-devices. | - | When ConnectionStateChangedEvent arrives, pending_connection | - | is set for the power thread — but since there's no power | - | thread, we need a minimal processing loop. | - \*------------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -4405,15 +6257,10 @@ void LogitechHIDPP20Controller::StartEventWatcher() reader_running = true; reader_thread = new std::thread(&LogitechHIDPP20Controller::ReaderThreadFunc, this); - /*----------------------------------------------------------*\ - | Start power thread to process connection events. | - | It won't do dim/sleep (no RGB) but it handles | - | pending_connection for sub-device re-probe. | - \*----------------------------------------------------------*/ power_thread_running = true; power_thread = new std::thread(&LogitechHIDPP20Controller::PowerThreadFunc, this); - LOG_DEBUG("%s Event watcher started (watching for sub-device)", LOG_TAG); + LOG_DEBUG("%s Event watcher started", LOG_TAG); } void LogitechHIDPP20Controller::StartPowerManager() @@ -4428,6 +6275,14 @@ void LogitechHIDPP20Controller::StartPowerManager() 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(); @@ -4436,23 +6291,24 @@ void LogitechHIDPP20Controller::StartPowerManager() 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). | - \*-----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Seed the periodic idle-settings poll clock | + | so the first tick of the power thread's | + | 500ms re-read happens one interval from now, | + | not immediately (we just applied above). | + \*-----------------------------------------------------*/ last_idle_poll = std::chrono::steady_clock::now(); - /*----------------------------------------------------------*\ - | Don't claim SW control here. The device runs its firmware | - | effect (or saved hardware profile) until DeviceUpdateLEDs | - | is called for the first time, at which point claim + push | - | happen atomically. | - | | - | Reader and power threads still start so we can detect | - | migration events (USB plug-in) and process activity events | - | once SW control is eventually claimed. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -4473,9 +6329,10 @@ void LogitechHIDPP20Controller::StopPowerManager() return; } - /*----------------------------------------------------------*\ - | Stop power thread first (it may be waiting on the queue) | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Stop the power thread first (it may be waiting | + | on the queue). | + \*-----------------------------------------------------*/ power_thread_running = false; response_cv.notify_all(); @@ -4487,9 +6344,9 @@ void LogitechHIDPP20Controller::StopPowerManager() delete power_thread; power_thread = nullptr; - /*----------------------------------------------------------*\ - | Then stop reader thread | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Then stop reader thread | + \*-----------------------------------------------------*/ reader_running = false; if(reader_thread && reader_thread->joinable()) @@ -4500,10 +6357,10 @@ void LogitechHIDPP20Controller::StopPowerManager() delete reader_thread; reader_thread = nullptr; - /*----------------------------------------------------------*\ - | Wake if we were dimmed/sleeping so Shutdown() can | - | cleanly release SW control. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Wake if we were dimmed/sleeping so Shutdown() can | + | cleanly release SW control. | + \*-----------------------------------------------------*/ if(power_state != HIDPP20_POWER_ACTIVE) { Wake(); @@ -4514,11 +6371,12 @@ void LogitechHIDPP20Controller::StopPowerManager() 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. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -4527,14 +6385,14 @@ void LogitechHIDPP20Controller::ReaderThreadFunc() if(result < 0) { - /*--------------------------------------------------*\ - | HID read error — device handle is invalid (device | - | physically removed). Mark offline and sleep to | - | avoid spinning. | - \*--------------------------------------------------*/ + /*---------------------------------------------*\ + | 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); + LOG_DEBUG("%s HID read error: device removed", LOG_TAG); device_online.store(false); } @@ -4544,12 +6402,12 @@ void LogitechHIDPP20Controller::ReaderThreadFunc() 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. | - \*--------------------------------------------------*/ + /*---------------------------------------------*\ + | 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 && @@ -4559,39 +6417,12 @@ void LogitechHIDPP20Controller::ReaderThreadFunc() continue; } - /*--------------------------------------------------*\ - | CentPPBridge event 0: ConnectionStateChangedEvent | - | Sub-device connected or disconnected from dongle. | - \*--------------------------------------------------*/ - if(transport.bridge_feat_idx != 0 && - feat == transport.bridge_feat_idx && - (func & 0xF0) == 0x00 && - (func & 0x0F) != HIDPP20_SW_ID) - { - /*--------------------------------------------------*\ - | ConnectionStateChangedEvent payload: | - | data[0] = ? (always 0 in observed packets) | - | data[1] = number of connected sub-devices | - | data[2] = ? | - | Connect: [00 01 00], Disconnect: [00 00 00] | - \*--------------------------------------------------*/ - uint8_t num_devices = data[1]; - LOG_DEBUG("%s Bridge ConnectionStateChanged: %d sub-device(s) (data: %02X %02X %02X)", - LOG_TAG, num_devices, data[0], data[1], data[2]); - /*-------------------------------------------------*\ - | Store +1 for connected, -1 for disconnected. | - | Power thread checks sign to decide action. | - \*-------------------------------------------------*/ - pending_connection.store(num_devices > 0 ? 1 : -1); - continue; - } - - /*---------------------------------------------------*\ - | Feature 0x1D4B event 0: WirelessStatus | - | Device reconnected after power cycle. | - | Use cached map lookup only — reader thread must | - | never send commands (deadlock risk). | - \*---------------------------------------------------*/ + /*---------------------------------------------*\ + | 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; @@ -4606,82 +6437,29 @@ void LogitechHIDPP20Controller::ReaderThreadFunc() 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. | - \*--------------------------------------------------*/ + /*-------------------------------------*\ + | Forward both events to the power | + | thread. Each call into | + | ReconnectDevice runs the fast- | + | backoff reclaim loop, so the | + | second event acts as a belt-and- | + | suspenders re-claim once the | + | firmware boot fully settles. | + \*-------------------------------------*/ pending_connection.store(1); continue; } } - /*---------------------------------------------------*\ - | HID++1.0 Device Connection notifications from the | - | Lightspeed receiver. sub_id 0x40 = Device | - | Disconnection, 0x41 = Device Connection Status. | - | Either one is a signal that the paired device's | - | preferred path just changed — typically because | - | the user plugged in (or unplugged) the USB cable | - | on the device itself. | - | | - | These arrive on the paired device's hidraw with | - | device_index=0x01 (not the receiver's own | - | endpoint) because the kernel dj-receiver driver | - | routes them to the device's virtual hidraw. We | - | see them because our reader is attached to that | - | hidraw. | - | | - | Flag a force path-check for the power thread. It | - | will run ScanForDevice(true) which bypasses the | - | device_online gate so the scan can find a | - | different-PID migration candidate even while the | - | current path hasn't failed yet. If no such | - | candidate exists (same path, false alarm), the | - | scan is a no-op. | - | | - | This is how we catch the wireless→USB transition: | - | the notification fires BEFORE the firmware fully | - | switches its data flow, giving us a window to | - | migrate proactively. The reverse direction | - | (USB→wireless) is already handled via the USB fd | - | becoming invalid on cable unplug. | - \*---------------------------------------------------*/ - if(feat == 0x40 || feat == 0x41) - { - /*--------------------------------------------------*\ - | Set 75 retries × 200ms = ~15 second window. The | - | keyboard's USB HID++ interface (page 0xFF00) can | - | take 10+ seconds to appear after the boot HID | - | interface — the first DJ notification fires when | - | the boot interface comes up, but hid_enumerate | - | won't return the HID++ interface until the kernel | - | finishes setting up all three interfaces. Until | - | the path-check clears, the device is dark/ | - | uncontrolled, so we poll at the same fast cadence | - | as offline recovery (200ms) to minimize how long | - | the user sees default firmware behavior. | - | | - | A subsequent DJ notification before timeout resets | - | the counter to 75, extending the retry window. | - \*--------------------------------------------------*/ - LOG_DEBUG("%s LogitechHID++1.0 connection notification sub_id=0x%02X " - "flags=0x%02X (path change — forcing scan retries)", - LOG_TAG, feat, func); - pending_path_check.store(75); - continue; - } - - /*----------------------------------------------------*\ - | Only queue responses to OUR commands. | - | Our commands use HIDPP20_SW_ID (0x0A) in the low | - | nibble. Firmware-generated messages (battery, | - | sync, etc.) use SW_ID 0 — drop those silently. | - | Error responses (feat=0xFF) are always queued. | - \*----------------------------------------------------*/ + /*---------------------------------------------*\ + | 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; @@ -4702,42 +6480,15 @@ void LogitechHIDPP20Controller::ReaderThreadFunc() void LogitechHIDPP20Controller::PowerThreadFunc() { - /*----------------------------------------------------------*\ - | Handles power state machine and sends commands. | - | Reads responses from the queue (filled by reader thread). | - \*----------------------------------------------------------*/ - std::chrono::steady_clock::time_point last_probe_time = std::chrono::steady_clock::now(); - + /*-----------------------------------------------------*\ + | Handles power state machine and sends commands. Reads | + | responses from the queue (filled by reader thread). | + \*-----------------------------------------------------*/ while(power_thread_running.load()) { - /*------------------------------------------------------*\ - | Watcher mode: device failed initial probe. Retry | - | IRoot every 5 seconds until device becomes reachable. | - \*------------------------------------------------------*/ - if(watcher_mode.load()) - { - std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); - - if(now - last_probe_time >= std::chrono::seconds(5)) - { - last_probe_time = now; - - uint8_t test_idx = GetFeatureIndex(HIDPP20_FEAT_FEATURE_SET); - - if(test_idx != 0) - { - LOG_INFO("%s Device became reachable — initiating full probe", LOG_TAG); - FullReprobe(); - } - } - - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - continue; - } - - /*------------------------------------------------------*\ - | 1. Check for pending firmware events | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | 1. Check for pending firmware events | + \*-------------------------------------------------*/ int activity = pending_activity.exchange(-1); if(activity >= 0) @@ -4746,9 +6497,9 @@ void LogitechHIDPP20Controller::PowerThreadFunc() OnUserActivity((uint8_t)activity); } - /*------------------------------------------------------*\ - | 1b. Check for connection state changes | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | 1b. Check for connection state changes | + \*-------------------------------------------------*/ int connection = pending_connection.exchange(0); if(connection > 0) @@ -4768,61 +6519,9 @@ void LogitechHIDPP20Controller::PowerThreadFunc() device_online.store(false); } - /*-------------------------------------------------------*\ - | 1c. Reactive scan for connection migration. | - | | - | Three modes feed this loop: | - | a) device_online == true, no path-check pending | - | -> 2s idle interval, scan is a no-op | - | b) device_online == false | - | -> 200ms fast interval, scan tries to reclaim | - | c) pending_path_check > 0 (DJ notification fired) | - | -> 200ms fast interval, force-scan bypasses | - | the online gate to find a different-PID | - | migration candidate; counter decrements per | - | failed attempt and clears on success | - | | - | The retry counter exists because the keyboard's USB | - | HID++ interface can take 10+ seconds to enumerate | - | after the boot HID interface comes up — the first DJ | - | notification fires too early to find anything. 75 * | - | 200ms = ~15s window, plenty for the slowest observed | - | enumeration. | - \*-------------------------------------------------------*/ - if(!caps.unit_id.empty()) - { - std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); - int path_retries = pending_path_check.load(); - bool online = device_online.load(); - - std::chrono::milliseconds interval = (online && path_retries == 0) - ? std::chrono::milliseconds(2000) - : std::chrono::milliseconds(200); - - if(now - last_probe_time >= interval) - { - last_probe_time = now; - - bool force = (path_retries > 0) || !online; - bool success = ScanForDevice(force); - - if(path_retries > 0) - { - if(success) - { - pending_path_check.store(0); - } - else - { - pending_path_check.fetch_sub(1); - } - } - } - } - - /*------------------------------------------------------*\ - | 2. Power management timing | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | 2. Power management timing | + \*-------------------------------------------------*/ { std::lock_guard lock(power_mutex); @@ -4840,18 +6539,21 @@ void LogitechHIDPP20Controller::PowerThreadFunc() } 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. | - \*---------------------------------------------------*/ + /*-------------------------------------*\ + | 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) { @@ -4875,14 +6577,15 @@ void LogitechHIDPP20Controller::PowerThreadFunc() } } - /*------------------------------------------------------*\ - | Fast poll of idle settings + external-power flag. | - | QueryExternalPower is a single HID++ 0x1004 GetStatus | - | call — cheap on wire and lets ApplyPowerSavingProfile | - | pick between the on_battery and plugged_in profiles | - | within half a second of a power-source transition. | - | The idle-settings reload itself is purely in-memory. | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | Fast poll of idle settings + external-power | + | flag. QueryExternalPower is a single HID++ | + | 0x1004 GetStatus call, cheap on wire and lets | + | ApplyPowerSavingProfile pick between the | + | on_battery and plugged_in profiles within half a | + | second of a power-source transition. The idle- | + | settings reload itself is purely in-memory. | + \*-------------------------------------------------*/ if(caps.has_power_mgmt) { std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); @@ -4894,11 +6597,12 @@ void LogitechHIDPP20Controller::PowerThreadFunc() } } - /*------------------------------------------------------*\ - | Fire any pending retry-paint whose deadline has come | - | due. The callback runs DeviceUpdateLEDs on this | - | thread's context, not recursively inside another call. | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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)); @@ -4910,65 +6614,31 @@ bool LogitechHIDPP20Controller::IsCurrentlyWireless() const return wireless; } -bool LogitechHIDPP20Controller::QueryWirelessStatus() -{ - if(caps.idx_wireless_status == 0) - { - LOG_DEBUG("%s QueryWirelessStatus: feature not present", LOG_TAG); - return false; - } - - uint8_t send_data[1] = {0}; - uint8_t recv_data[16] = {}; - uint8_t hidpp_err = 0; - - int result = SendAcked(caps.idx_wireless_status, 0, - send_data, 0, recv_data, sizeof(recv_data), - HIDPP20_POLICY_PROBE, &hidpp_err); - - LOG_DEBUG("%s QueryWirelessStatus: result=%d err=0x%02X " - "data=[%02X %02X %02X %02X %02X %02X]", - LOG_TAG, result, hidpp_err, - recv_data[0], recv_data[1], recv_data[2], - recv_data[3], recv_data[4], recv_data[5]); - - if(result <= 0) - { - return false; - } - - return true; -} - bool LogitechHIDPP20Controller::QueryExternalPower() { - /*----------------------------------------------------------*\ - | Query HID++ 2.0 feature 0x1004 (UnifiedBattery) fn1 | - | GetStatus and determine whether the device is drawing | - | external power. | - | | - | Response layout: | - | byte 2: Charging Status | - | 0 = Discharging | - | 1 = Charging (wired) | - | 2 = Charging (slow) | - | 3 = Complete | - | 4 = Error | - | 5 = Wireless Charging | - | byte 3: External Power Status | - | 0 = no external power | - | non-zero = external power present | - | | - | We consider the device externally powered if EITHER byte | - | is non-zero: some devices leave byte 3 at 0 whenever they | - | are actively charging and rely on byte 2 alone to signal | - | the wired state. The pre-refactor QueryOnBattery used the | - | same OR semantic (expressed from the on-battery side) and | - | was known to work across the Logitech lineup. | - | | - | Updates ps_on_external_power and returns the new value. | - | On failure returns the cached value without touching it. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -4981,11 +6651,12 @@ bool LogitechHIDPP20Controller::QueryExternalPower() 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. | - \*-----------------------------------------------------*/ + /*---------------------------------------------*\ + | 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; } @@ -5000,7 +6671,7 @@ bool LogitechHIDPP20Controller::QueryExternalPower() if(result <= 0) { - LOG_TRACE("%s QueryExternalPower: GetStatus failed (result=%d) — using cached", + LOG_TRACE("%s QueryExternalPower: GetStatus failed (result=%d): using cached", LOG_TAG, result); return ps_on_external_power; } @@ -5024,48 +6695,49 @@ bool LogitechHIDPP20Controller::QueryExternalPower() 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. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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. | - \*---------------------------------------------------------*/ + /*-------------------------------------------------*\ + | Unconfigured: no plugin in use. We still hold SW | + | control so firmware will NOT dim or sleep | + | autonomously; it only emits idle events and | + | expects the host to act. Run a basic default | + | profile ourselves: no dim on idle (OpenRGB users | + | generally expect lights to stay on), but still go | + | to sleep at the firmware-configured timeout. | + \*-------------------------------------------------*/ ps_dim_enabled = false; ps_dim_target_pct = DIM_TARGET_PCT; ps_sleep_enabled = true; - /*------------------------------------------------------*\ - | Restore firmware defaults on the device if we | - | previously wrote custom values from a plugin profile. | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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); @@ -5089,12 +6761,13 @@ void LogitechHIDPP20Controller::ApplyPowerSavingProfile() return; } - /*---------------------------------------------------------*\ - | Configured: pick the active profile based on whether the | - | device is currently externally powered. ps_on_external_ | - | power is refreshed by QueryExternalPower() on the same | - | 500 ms power-thread poll that calls us. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Configured: pick the active profile based on | + | whether the device is currently externally | + | powered. ps_on_external_ power is refreshed | + | by QueryExternalPower() on the same 500 ms | + | power-thread poll that calls us. | + \*-----------------------------------------------------*/ const LogitechHIDPP20IdleProfile& profile = ps_on_external_power ? settings->pluggedIn() : settings->onBattery(); @@ -5115,19 +6788,21 @@ void LogitechHIDPP20Controller::ApplyPowerSavingProfile() } else { - /*------------------------------------------------------*\ - | Signal "don't sleep" to the state machine. The IDLE | - | branch of PowerThreadFunc gates on sleep_timeout_s>0. | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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); @@ -5156,8 +6831,33 @@ void LogitechHIDPP20Controller::ApplyPowerSavingProfile() void LogitechHIDPP20Controller::FlushResponseQueue() { - std::lock_guard lock(response_mutex); - response_queue.clear(); + { + 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 @@ -5172,12 +6872,12 @@ void LogitechHIDPP20Controller::DispatchEvent 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). | - \*------------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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) @@ -5188,17 +6888,16 @@ void LogitechHIDPP20Controller::DispatchEvent void LogitechHIDPP20Controller::OnUserActivity(uint8_t activity_type) { - /*----------------------------------------------------------*\ - | power_mutex must already be held by the caller. | - \*----------------------------------------------------------*/ - + /*-----------------------------------------------------*\ + | 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. | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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; @@ -5209,17 +6908,20 @@ void LogitechHIDPP20Controller::OnUserActivity(uint8_t activity_type) return; } - LOG_DEBUG("%s onUserActivity: IDLE — starting dim", LOG_TAG); + LOG_DEBUG("%s onUserActivity: IDLE: starting dim", LOG_TAG); - /*------------------------------------------------------*\ - | Flush stale per-key ACKs before sending commands | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | Flush stale per-key ACKs before sending commands | + \*-------------------------------------------------*/ FlushResponseQueue(); - /*------------------------------------------------------*\ - | Switch to flags=3 (ZONE|POWER): release EFFECT to | - | firmware, monitor for user activity (keypress). | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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) @@ -5231,7 +6933,7 @@ void LogitechHIDPP20Controller::OnUserActivity(uint8_t activity_type) sleep_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(sleep_delay); - LOG_DEBUG("%s Dim disabled — skipping to IDLE (sleep in %us)", + LOG_DEBUG("%s Dim disabled, skipping to IDLE (sleep in %us)", LOG_TAG, sleep_delay); } else @@ -5241,29 +6943,30 @@ void LogitechHIDPP20Controller::OnUserActivity(uint8_t activity_type) } else { - /*------------------------------------------------------*\ - | ACTIVE event — user resumed typing. | - | Only act if we're NOT already active. | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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); + 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. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -5273,11 +6976,11 @@ void LogitechHIDPP20Controller::StartDimRamp() void LogitechHIDPP20Controller::DimRampStep() { - /*----------------------------------------------------------*\ - | power_mutex must already be held by the caller. | - | Adjusts brightness and requests a repaint so | - | DeviceUpdateLEDs pushes the dimmed colors. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -5295,39 +6998,42 @@ void LogitechHIDPP20Controller::DimRampStep() 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. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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 | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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). | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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 @@ -5342,26 +7048,26 @@ void LogitechHIDPP20Controller::DimRampStep() 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_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. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -5381,41 +7087,44 @@ void LogitechHIDPP20Controller::StartSleep() void LogitechHIDPP20Controller::Wake() { - /*----------------------------------------------------------*\ - | Called from OnUserActivity(1) when the firmware tells us | - | the device has seen user input. Works for DIMMING, IDLE, | - | and SLEEPING states uniformly — the only wrinkle is that | - | SLEEPING means we previously sent SetRgbPowerMode(3) to | - | initiate the fade, so we have to explicitly cancel it | - | with SetRgbPowerMode(1) first. | - | | - | Per the 0x8071 protocol lifecycle, a proper wake is: power | - | mode 1 (if we were sleeping), then SetSWControl(3,5) to | - | re-claim | - | rendering from the firmware's idle-monitor mode, then | - | re-push the current lighting state at full brightness. | - | | - | The re-push uses request_repaint_fn (DeviceUpdateLEDs) | - | NOT reapply_active_mode_fn (which re-runs the full claim | - | + per-key prep sequence). Wake is NOT a reconnect — the | - | device handle, feature map, SW control claim, and per-key | - | prep are all still intact. Re-running the claim would | - | briefly reset the zone effect layer and flash the firmware | - | default colors for ~50ms before per-key takes back over. | - | | - | power_mutex must already be held by the caller. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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. | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | 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); } @@ -5427,29 +7136,32 @@ void LogitechHIDPP20Controller::Wake() LOG_DEBUG("%s Woke from state %d", LOG_TAG, prev); - /*----------------------------------------------------------*\ - | Re-push the current lighting state at full brightness. | - | | - | We use request_repaint_fn (lightweight: just calls | - | DeviceUpdateLEDs) NOT reapply_active_mode_fn (heavyweight: | - | re-runs ClaimSWControlIfNeeded → SetOnboardMode → per-key | - | prep sequence → DeviceUpdateMode). On a wake-from-dim/idle | - | the device is still in host mode, SW control is still | - | claimed, and the per-key prep has already been established | - | — all we need is a fresh paint at restored brightness. | - | | - | The brightness was restored to 100% above | - | (dim_brightness_pct.store(100)), so DeviceUpdateLEDs will | - | apply the full-brightness multiplier to the snapshot. | - | Since sent_colors was recorded at the previous (dimmed) | - | brightness, the delta detects a change on every zone and | - | pushes a full frame naturally — no sent_colors.clear() | - | needed. | - | | - | ReapplyActiveMode (the heavyweight path) is reserved for | - | reconnects where the device was fully re-enumerated and | - | needs the complete claim + prep + mode re-establishment. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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) @@ -5470,10 +7182,10 @@ void LogitechHIDPP20Controller::ReadFirmwareTimers() return; } - /*----------------------------------------------------------*\ - | GetRgbPowerModeConfig (fn7, sub-function 0x00 = get) | - | Response: [echo], idle_hi, idle_lo, sleep_hi, sleep_lo | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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] = {}; @@ -5567,18 +7279,18 @@ void LogitechHIDPP20Controller::ReadNvSleepRampConfig() 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. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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; @@ -5609,11 +7321,11 @@ void LogitechHIDPP20Controller::ReadActiveProfileSector() | 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 | + | act on the contents, just log them so we can see what | | the device thinks its persisted state is. | | | | Wire format mirrors observed wire capture (load followed | - | by 7× readBuffer): | + | by 7x readBuffer): | | load: long msg, [partition=0x01, sector=0x01, | | size_hi=0x00, size_lo=0x63, | | padding to 16 bytes] | @@ -5622,8 +7334,7 @@ void LogitechHIDPP20Controller::ReadActiveProfileSector() | | | Sector size 0x63 = 99 bytes is what the vendor app | | requested for the G515 active profile. Other devices may | - | differ — we | - | hardcode it for now since this is diagnostic-only. | + | differ; it is hardcoded because this path is diagnostic. | \*----------------------------------------------------------*/ if(caps.idx_profile_management == 0) { @@ -5633,9 +7344,9 @@ void LogitechHIDPP20Controller::ReadActiveProfileSector() 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 | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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 @@ -5652,9 +7363,9 @@ void LogitechHIDPP20Controller::ReadActiveProfileSector() return; } - /*---------------------------------------------------------*\ - | Step 2: page the sector out 16 bytes at a time | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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) @@ -5683,9 +7394,9 @@ void LogitechHIDPP20Controller::ReadActiveProfileSector() memcpy(sector_buf + offset, page_resp, copy_len); } - /*----------------------------------------------------------*\ - | Step 3: log as a hexdump, one row per 16 bytes | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 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); diff --git a/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller.h b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller.h index f22672211..6f7570036 100644 --- a/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller.h +++ b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller.h @@ -28,9 +28,9 @@ #include "RGBController.h" #include "LogitechProtocolCommon.h" -/*-----------------------------------------------------*\ -| HID++ 2.0 Feature Page IDs | -\*-----------------------------------------------------*/ +/*---------------------------------------------------------*\ +| HID++ 2.0 Feature Page IDs | +\*---------------------------------------------------------*/ #define HIDPP20_FEAT_IROOT 0x0000 #define HIDPP20_FEAT_FEATURE_SET 0x0001 #define HIDPP20_FEAT_DEVICE_NAME_TYPE 0x0005 @@ -51,23 +51,29 @@ #define HIDPP20_FEAT_UNIFIED_BATTERY 0x1004 #define HIDPP20_FEAT_WIRELESS_STATUS 0x1D4B -/*-----------------------------------------------------*\ -| HID++ 2.0 Function IDs (byte 3 high nibble) | -| Function ID is shifted left 4 bits, low nibble = swID | -\*-----------------------------------------------------*/ -/*-----------------------------------------------------*\ -| HID++ Software ID — identifies our responses. | -| Must avoid: 0x00 (firmware), 0x01 (vendor app), | -| 0x02-0x0F (Solaar cycles these). | -| There are only 16 values (4-bit field), and all are | -| claimed. We pick a fixed value and will coordinate | -| with Solaar to exclude it from its cycle. | -\*-----------------------------------------------------*/ +/*---------------------------------------------------------*\ +| Placeholder device names. Centralized so the set | +| sites and the is-placeholder test can't drift. | +\*---------------------------------------------------------*/ +#define HIDPP20_NAME_PLACEHOLDER_STD "Logitech HID++ Device" +#define HIDPP20_NAME_PLACEHOLDER_CENTURION "Logitech Centurion Device" + +/*---------------------------------------------------------*\ +| HID++ 2.0 Function IDs (byte 3 high nibble) | +| Function ID is shifted left 4 bits, low nibble = swID | +| | +| HID++ Software ID: identifies our responses. | +| Must avoid: 0x00 (firmware), 0x01 (vendor app), | +| 0x02-0x0F (Solaar cycles these). | +| There are only 16 values (4-bit field), and all are | +| claimed. We pick a fixed value and will coordinate | +| with Solaar to exclude it from its cycle. | +\*---------------------------------------------------------*/ #define HIDPP20_SW_ID 0x07 -/*-----------------------------------------------------*\ -| Feature 0x8071 functions | -\*-----------------------------------------------------*/ +/*---------------------------------------------------------*\ +| Feature 0x8071 functions | +\*---------------------------------------------------------*/ #define FN_8071_GET_INFO 0x00 #define FN_8071_SET_EFFECT 0x10 #define FN_8071_SET_PATTERN 0x20 @@ -78,9 +84,9 @@ #define FN_8071_PWR_CONFIG 0x70 #define FN_8071_PWR_MODE 0x80 -/*-----------------------------------------------------*\ -| Feature 0x8081 functions | -\*-----------------------------------------------------*/ +/*---------------------------------------------------------*\ +| Feature 0x8081 functions | +\*---------------------------------------------------------*/ #define FN_8081_GET_INFO 0x00 #define FN_8081_SET_INDIVIDUAL 0x10 #define FN_8081_SET_CONSECUTIVE 0x20 @@ -90,9 +96,30 @@ #define FN_8081_SET_SINGLE_VALUE 0x60 #define FN_8081_FRAME_END 0x70 -/*-----------------------------------------------------*\ -| Feature 0x0620 functions (headset RGB hostmode) | -\*-----------------------------------------------------*/ +/*---------------------------------------------------------*\ +| Feature 0x8080 functions. A separate feature from 0x8081 | +| ("V2" is Logitech's feature name, not a version). Keys | +| are (keyType u16 BE, keyId u8 = USB HID usage); fn3 rides | +| the 0x12 very-long report on a second handle, everything | +| else rides 0x11. | +\*---------------------------------------------------------*/ +#define FN_8080_GET_INFO 0x00 +#define FN_8080_GET_KEY_TYPE_INFO 0x10 +#define FN_8080_GET_KEY_COLORS 0x20 +#define FN_8080_SET_KEY_COLORS 0x30 +#define FN_8080_SET_ALL_KEYS 0x40 +#define FN_8080_FLUSH_LEDS 0x50 + +/*---------------------------------------------------------*\ +| 0x8080 carries up to 14 (keyId,R,G,B) tuples in one | +| very-long (report 0x12) frame, and GetKeyColors | +| paginates its readback by the same count. | +\*---------------------------------------------------------*/ +#define HIDPP20_8080_KEYS_PER_FRAME 14 + +/*---------------------------------------------------------*\ +| Feature 0x0620 functions (headset RGB hostmode) | +\*---------------------------------------------------------*/ #define FN_0620_GET_INFO 0x00 #define FN_0620_GET_RGB_ZONE_INFO 0x10 #define FN_0620_SET_INDIVIDUAL_RGB_ZONES 0x20 @@ -103,22 +130,22 @@ #define FN_0620_GET_HOST_MODE_STATE 0x70 #define FN_0620_SET_HOST_MODE_STATE 0x80 -/*-----------------------------------------------------*\ -| Feature 0x8100 functions | -\*-----------------------------------------------------*/ +/*---------------------------------------------------------*\ +| Feature 0x8100 functions | +\*---------------------------------------------------------*/ #define FN_8100_SET_ONBOARD_MODE 0x10 #define FN_8100_GET_ONBOARD_MODE 0x20 -/*-----------------------------------------------------*\ -| Feature 0x8101 functions | -\*-----------------------------------------------------*/ +/*---------------------------------------------------------*\ +| Feature 0x8101 functions | +\*---------------------------------------------------------*/ #define FN_8101_GET_SET_MODE 0x60 #define FN_8101_LOAD 0x80 // load(partition, sector, size) #define FN_8101_READBUFFER 0xC0 // readBuffer(offset) -/*-----------------------------------------------------*\ -| Zone cluster effect entry | -\*-----------------------------------------------------*/ +/*---------------------------------------------------------*\ +| Zone cluster effect entry | +\*---------------------------------------------------------*/ struct HIDPP20Effect { uint8_t index; @@ -127,9 +154,9 @@ struct HIDPP20Effect uint16_t default_period; }; -/*-----------------------------------------------------*\ -| Zone cluster info from 0x8071 GetRgbClusterInfo | -\*-----------------------------------------------------*/ +/*---------------------------------------------------------*\ +| Zone cluster info from 0x8071 GetRgbClusterInfo | +\*---------------------------------------------------------*/ struct HIDPP20ZoneCluster { uint8_t index; @@ -138,14 +165,32 @@ struct HIDPP20ZoneCluster std::vector effects; }; -/*-----------------------------------------------------*\ -| Per-model device quirks — behavioral differences that | -| can't be detected via feature probing. | -\*-----------------------------------------------------*/ +/*---------------------------------------------------------*\ +| One confirmed 0x8080 keyType and its enumerated keyIds. | +| keyTypes are single-bit values, fn0 typeFlags is their | +| bitmask: KEYBOARD 0x01, CONSUMER 0x02, GKEYS 0x04, | +| BUTTONS 0x08, LOGO 0x10, OTHER/indicators 0x40. keyIds | +| are HID usages on KEYBOARD, vendor-local elsewhere. | +| Populated by DiscoverPerKey8080(). | +\*---------------------------------------------------------*/ +struct HIDPP20PerKey8080Type +{ + uint16_t key_type; + uint8_t param[3]; /* fn1 GetKeyTypeInfo bytes 2-4 (after keyCount) */ + std::vector key_ids; /* fn2 GetKeyColors, keyId != 0 */ +}; + +/*---------------------------------------------------------*\ +| Per-model device quirks, behavioral differences that | +| can't be detected via feature probing. | +\*---------------------------------------------------------*/ enum HIDPP20DeviceQuirks : uint32_t { HIDPP20_QUIRK_NONE = 0, HIDPP20_QUIRK_FADE_ACCEPTS_WRITES = (1 << 0), // firmware accepts host frames during sleep fade without waking + HIDPP20_QUIRK_8080_OVERREPORTS_MEDIA = (1 << 2), // 0x8080 typeFlags over-reports media bit 0x02 (G910) + HIDPP20_QUIRK_8080_KEYTYPE40_ID12_ONLY = (1 << 3), // 0x8080 keyType 0x40 accepts only keyId 1 and 2 (G410) + HIDPP20_QUIRK_KEEP_ONBOARD_MODE = (1 << 4), // host mode breaks the onboard-profile-mapped F-row (G915 TKL) }; struct HIDPP20DeviceQuirkEntry @@ -156,8 +201,8 @@ struct HIDPP20DeviceQuirkEntry }; /*---------------------------------------------------------*\ -| Default: suppress frames while SLEEPING. Safe everywhere | -| — it cannot wake a device that treats writes as activity. | +| Default: suppress frames while SLEEPING. Safe everywhere; | +| it cannot wake a device that treats writes as activity. | | Devices listed here opt out of suppression because their | | firmware accepts writes during the fade without | | cancelling sleep. | @@ -165,11 +210,26 @@ struct HIDPP20DeviceQuirkEntry static constexpr HIDPP20DeviceQuirkEntry HIDPP20_DEVICE_QUIRK_TABLE[] = { { 0x40B4, 0xC355, HIDPP20_QUIRK_FADE_ACCEPTS_WRITES }, // G515 LS TKL + { 0xC32B, 0xC32B, HIDPP20_QUIRK_8080_OVERREPORTS_MEDIA }, // G910 Orion Spark + { 0xC330, 0xC330, HIDPP20_QUIRK_8080_KEYTYPE40_ID12_ONLY }, // G410 Atlas Spectrum + { 0x408E, 0xC343, HIDPP20_QUIRK_KEEP_ONBOARD_MODE }, // G915 TKL }; -/*-----------------------------------------------------*\ -| Device capabilities discovered via feature probing | -\*-----------------------------------------------------*/ +/*---------------------------------------------------------*\ +| One write fired during a pipelined claim, kept so its | +| ack can be reconciled and the write re-sent if dropped. | +\*---------------------------------------------------------*/ +struct HIDPP20PendingClaimCmd +{ + uint8_t feat; + uint8_t func; + uint8_t data[20]; + size_t len; +}; + +/*---------------------------------------------------------*\ +| Device capabilities discovered via feature probing | +\*---------------------------------------------------------*/ struct HIDPP20DeviceCapabilities { std::string device_name; @@ -179,34 +239,36 @@ struct HIDPP20DeviceCapabilities std::string unit_id; // stable hardware ID (from FirmwareInfo fn0) uint16_t pid_wireless; // wireless virtual PID (from FirmwareInfo fn0) uint16_t pid_wired; // wired USB PID (from FirmwareInfo fn0) + uint16_t pid_third; // third modelId slot, a USB-only device's PID lands here uint32_t quirks; // resolved from HIDPP20_DEVICE_QUIRK_TABLE after PID discovery - /*--------------------------------------------------*\ - | Complete feature map (feature_id → runtime index) | - | Built once by EnumerateFeatures, used by all | - | subsequent GetFeatureIndex lookups (no wire). | - \*--------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Complete feature map (feature_id -> runtime index) | + | Built once by EnumerateFeatures, used by all | + | subsequent GetFeatureIndex lookups (no wire). | + \*-----------------------------------------------------*/ std::map feature_map; std::map feature_versions; /* feature_id -> version byte */ bool feature_map_complete; // true after bulk enumeration - /*-------------------------------------------------*\ - | Feature indices (0 = not supported) | - \*-------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Feature indices (0 = not supported) | + \*-----------------------------------------------------*/ uint8_t idx_onboard_profiles; uint8_t idx_profile_management; uint8_t idx_rgb_effects; - uint8_t idx_headset_rgb_hostmode; /* 0x0620 — Centurion headset RGB */ + uint8_t idx_headset_rgb_hostmode; /* 0x0620: Centurion headset RGB */ uint8_t idx_perkey_v2; uint8_t idx_perkey_v1; + uint8_t idx_perkey_8080; /* 0x8080: Per Key Lighting (G810/G910 keyboard family) */ uint8_t idx_wireless_status; - uint8_t idx_disable_keys_by_usage; /* 0x4522 — keyboard-family handshake */ + uint8_t idx_disable_keys_by_usage; /* 0x4522: keyboard-family handshake */ uint16_t rgb_feature_page; - /*--------------------------------------------------*\ - | Resolved function IDs for the RGB effects feature | - | Varies between 0x8070, 0x8071, 0x0600 | - \*--------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Resolved function IDs for the RGB effects feature | + | Varies between 0x8070, 0x8071, 0x0600 | + \*-----------------------------------------------------*/ uint8_t fn_set_effect; uint8_t fn_sw_control; uint8_t fn_pwr_config; @@ -214,53 +276,77 @@ struct HIDPP20DeviceCapabilities bool has_power_mgmt; bool sw_control_simple; - /*--------------------------------------------------*\ - | Persistent NV settings read from RGBEffects fn3 | - | (FN_8071_NV_CONFIG). Capability 0x0020 is the | - | sleep ramp / off-ramp transition (enabled + | - | ramp_seconds). | - \*--------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Persistent NV settings read from RGBEffects fn3 | + | (FN_8071_NV_CONFIG). Capability 0x0020 is the | + | sleep ramp / off-ramp transition (enabled + | + | ramp_seconds). | + \*-----------------------------------------------------*/ bool nv_sleep_ramp_known; bool nv_sleep_ramp_enabled; uint8_t nv_sleep_ramp_seconds; - /*---------------------------------------------------*\ - | Device-firmware effect cards (0x8071 fn0 | - | GetEffectSpecificInfo). Populated by | - | DiscoverEffectCards at feature-discovery time. | - | | - | has_effect_cards — probe returned a valid response | - | for firmware card 0 page 1 (no InvalidArgument). | - | effect_card_template[0..1] — device-wide constant | - | bytes read from that response at data[10..11]. | - | Echoed back into prep1 of DoObservedPerKeyPrep | - | at SetEffectByIndex params[6..7]. | - | | - | Gate for the observed per-key prep is now | - | has_effect_cards — replaces the earlier | - | "effects.size() < 5" heuristic, which was a proxy | - | that correlated with "has cards" on the devices we | - | happened to know but had no principled meaning. | - \*---------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Device-firmware effect cards (0x8071 fn0 | + | GetEffectSpecificInfo). Populated by | + | DiscoverEffectCards at feature-discovery time. | + | | + | has_effect_cards, probe returned a valid response | + | for firmware card 0 page 1 (no InvalidArgument). | + | effect_card_template[0..1], device-wide constant | + | bytes read from that response at data[10..11]. | + | Echoed back into prep1 of DoObservedPerKeyPrep | + | at SetEffectByIndex params[6..7]. | + | | + | Gate for the observed per-key prep is now | + | has_effect_cards, replaces the earlier | + | "effects.size() < 5" heuristic, which was a proxy | + | that correlated with "has cards" on the devices we | + | happened to know but had no principled meaning. | + \*-----------------------------------------------------*/ bool has_effect_cards; uint8_t effect_card_template[2]; - /*-------------------------------------------------*\ - | Discovered zone and LED data | - \*-------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Discovered zone and LED data | + \*-----------------------------------------------------*/ std::vector zone_clusters; std::vector perkey_zone_ids; std::vector headset_rgb_hostmode_zone_ids; /* 0x0620 fn1 result */ bool has_perkey; bool has_zone_effects; + + /*-----------------------------------------------------*\ + | Feature 0x8080 (Per Key Lighting) enumeration. | + | Populated by DiscoverPerKey8080() entirely from | + | runtime probing (fn0 GetInfo + fn1/fn2 sweep). | + \*-----------------------------------------------------*/ + bool has_perkey_8080; + uint16_t perkey_8080_type_flags; + uint16_t perkey_8080_type_count; /* fn0 keyTypeCount: completeness oracle */ + uint16_t perkey_8080_max_key_count; + std::vector perkey_8080_types; bool is_headset_rgb_hostmode; /* 0x0620 path selected */ bool has_numpad; uint8_t keyboard_layout_code; }; -/*------------------------------------------------------*\ -| Transport type — determines wire framing | -\*------------------------------------------------------*/ +/*---------------------------------------------------------*\ +| Full snapshot of one link's discovered caps. Feature | +| indices are per transport, and the effect-card template | +| and zone/per-key data are cleared per link too, so we | +| cache all of caps to restore a link without rediscovery. | +\*---------------------------------------------------------*/ +struct HIDPP20LinkIndexMap +{ + HIDPP20DeviceCapabilities caps; + uint8_t idx_unified_battery = 0; /* lives outside caps; also per-link */ + bool valid = false; +}; + +/*---------------------------------------------------------*\ +| Transport type, determines wire framing | +\*---------------------------------------------------------*/ enum HIDPP20TransportType { HIDPP20_TRANSPORT_STANDARD, // 0xFF00/0xFF43: report IDs 0x10/0x11, 7/20 bytes @@ -268,14 +354,14 @@ enum HIDPP20TransportType // with CPL framing and CentPPBridge sub-device routing }; -/*------------------------------------------------------*\ -| Transport layer — abstracts wire format differences | -| | -| Standard HID++ and Centurion both carry the same | -| feature/function/data payload, but with different | -| report framing. This struct holds transport state | -| so SendMessage/ReadMessage can adapt. | -\*------------------------------------------------------*/ +/*---------------------------------------------------------*\ +| Transport layer, abstracts wire format differences | +| | +| Standard HID++ and Centurion both carry the same | +| feature/function/data payload, but with different | +| report framing. This struct holds transport state | +| so SendMessage/ReadMessage can adapt. | +\*---------------------------------------------------------*/ struct HIDPP20Transport { HIDPP20TransportType type; @@ -290,10 +376,10 @@ struct HIDPP20Transport // >0 = sub-device present, payload size in bytes }; -/*-----------------------------------------------------*\ -| Power management state machine | -| Matches Solaar's RGBPowerManager states | -\*-----------------------------------------------------*/ +/*---------------------------------------------------------*\ +| Power management state machine | +| Matches Solaar's RGBPowerManager states | +\*---------------------------------------------------------*/ enum HIDPP20PowerState { HIDPP20_POWER_ACTIVE = 0, @@ -302,9 +388,9 @@ enum HIDPP20PowerState HIDPP20_POWER_SLEEPING = 3, }; -/*-----------------------------------------------------*\ -| Parsed HID++ message for the response queue | -\*-----------------------------------------------------*/ +/*---------------------------------------------------------*\ +| Parsed HID++ message for the response queue | +\*---------------------------------------------------------*/ struct HIDPP20RawMessage { uint8_t feat; @@ -313,26 +399,29 @@ struct HIDPP20RawMessage int result; }; -/*------------------------------------------------------*\ -| Per-key write tracking. SendPerKeyData is fire-and- | -| forget at the wire layer; we track which zones each | -| outstanding write covers so PerKeyFrameEnd can match | -| ACKs back by FIFO order and report which zones the | -| firmware actually committed. | -\*------------------------------------------------------*/ -struct OutstandingPerKeyWrite +/*---------------------------------------------------------*\ +| One pipelined write awaiting its ACK. The ACK names no | +| packet, but its payload echoes the request (fn1: zone | +| IDs positionally, 0xFF = rejected), so the full request | +| is kept for the echo compare. | +\*---------------------------------------------------------*/ +struct PerKeyPacket { - uint8_t function; // FN_8081_* (high nibble carries the type) - std::vector zone_ids; // zones covered by this packet + uint8_t function; // FN_8081_* (high nibble carries the type) + uint8_t payload[16]; // request bytes, zero-padded + size_t len; // meaningful payload length + std::vector zone_ids; // zones covered (fn1: in payload entry order) + bool acked; }; -/*-----------------------------------------------------*\ -| Result of a per-key frame commit. The caller uses | -| these to update its delta-tracking state: | -| - frame_end_acked: did the FrameEnd packet ACK? | -| - acked_zones: zones whose write packet ACKed | -| - attempted_zones: every zone written this frame | -\*-----------------------------------------------------*/ +/*---------------------------------------------------------*\ +| Result of a frame commit. ACKs are attributed to packets | +| by payload echo, falling back to per-function count | +| matching (that group commits all-or-nothing). | +| frame_end_acked: did the FrameEnd packet ACK | +| acked_zones: zones proven committed | +| attempted_zones: every zone written this frame | +\*---------------------------------------------------------*/ struct PerKeyFrameResult { bool frame_end_acked; @@ -340,23 +429,14 @@ struct PerKeyFrameResult std::vector attempted_zones; }; -/*-------------------------------------------------------*\ -| Retry policy for SendAcked. | -| | -| Controls the send/read/retry loop for a single HID++ | -| request. Three canned policies cover all use cases: | -| | -| Reliable: probe/discovery/set/get (~6s worst case) | -| FrameEnd: per-key commit gate (~230ms worst) | -| Streaming: per-key write inside the animation loop | -| (~80ms worst) | -| | -| backoff_ms[i] is the delay applied BEFORE the i-th | -| attempt. backoff_ms[0] is normally 0 (no delay before | -| the first send). The schedule mirrors the firmware's | -| own event burst pattern (63→125→250→500→1000→2000ms, | -| "catch at least one of N"). | -\*-------------------------------------------------------*/ +/*---------------------------------------------------------*\ +| Retry policy for one SendAcked request. Two canned | +| policies: Reliable (probe/discovery/set/get, ~6s worst) | +| and Probe (is-this-HID++ checks, ~500ms). Per-key frames | +| do not come through here. backoff_ms[i] is the delay | +| BEFORE attempt i (first = 0); the schedule mirrors the | +| firmware's own burst pattern (63->2000ms). | +\*---------------------------------------------------------*/ struct HIDPP20RetryPolicy { const uint16_t* backoff_ms; // schedule[i] = delay before attempt i @@ -367,65 +447,86 @@ struct HIDPP20RetryPolicy const char* name; // for logging ("reliable", etc.) }; -/*------------------------------------------------------*\ -| Canned backoff schedules. | -\*------------------------------------------------------*/ +/*---------------------------------------------------------*\ +| Canned backoff schedules. | +\*---------------------------------------------------------*/ static constexpr uint16_t HIDPP20_BACKOFF_RELIABLE[] = { 0, 63, 125, 250, 500, 1000, 2000 }; static constexpr uint16_t HIDPP20_BACKOFF_PROBE[] = { 0, 100 }; -/*------------------------------------------------------*\ -| SW-control reclaim backoff. Used by ReconnectDevice | -| to retry the claim+push sequence after a wireless | -| reconnect, racing the firmware boot animation. The | -| vendor app typically lands control in ~50ms; this | -| schedule fits | -| 6 attempts inside ~620ms so the animation never gets | -| a chance to become visible. | -\*------------------------------------------------------*/ +/*---------------------------------------------------------*\ +| SW-control reclaim backoff. Used by ReconnectDevice | +| to retry the claim+push sequence after a wireless | +| reconnect, racing the firmware boot animation. The | +| vendor app typically lands control in ~50ms; this | +| schedule fits | +| 6 attempts inside ~620ms so the animation never gets | +| a chance to become visible. | +\*---------------------------------------------------------*/ static constexpr uint16_t HIDPP20_RECLAIM_BACKOFF_MS[] = { 0, 20, 40, 80, 160, 320 }; -/*------------------------------------------------------*\ -| FrameEnd BUSY retry backoff. Used by PerKeyFrameEnd | -| when the firmware returns HID++ error 0x08 (BUSY) | -| because it's still draining the per-key write queue. | -| First retry is fast (~2 USB round trips) for the | -| common case where BUSY was transient; subsequent | -| retries give actual drain headroom. Total worst case | -| ~180ms, fits inside the PerKeyFrameEnd 300ms deadline | -| with margin for the eventual ACK to land. | -\*------------------------------------------------------*/ -static constexpr uint16_t HIDPP20_FRAME_END_BUSY_BACKOFF_MS[] = - { 30, 60, 90 }; +/*---------------------------------------------------------*\ +| FrameEnd BUSY (0x08) is backpressure, not failure: the | +| not-ready window scales with keys written. Poll until | +| ready; the wait is the frame cadence, and coalescing | +| means the next frame sent is the newest one. | +| NEAR: after a predicted sleep any BUSY means ready is | +| imminent, poll tight. | +| Probe: every PROBE_INTERVAL frames sleep PROBE_STEP_MS | +| short to test whether the device got faster; a clean | +| accept adopts the shorter window. | +\*---------------------------------------------------------*/ +static constexpr uint16_t HIDPP20_FRAME_END_BUSY_POLL_MS = 10; +static constexpr uint16_t HIDPP20_FRAME_END_BUSY_POLL_NEAR_MS = 3; +static constexpr uint16_t HIDPP20_FE_PROBE_STEP_MS = 5; +static constexpr uint8_t HIDPP20_FE_PROBE_INTERVAL = 32; -/*-----------------------------------------------------*\ -| Deep-sleep detection threshold. After StartSleep() | -| commands the firmware fade, the device eventually | -| enters deep sleep and returns BUSY to every FrameEnd. | -| Once this many consecutive FrameEnd attempts exhaust | -| all BUSY retries while power_state == SLEEPING, we | -| suppress further frame sends until Wake() fires. | -\*-----------------------------------------------------*/ +/*---------------------------------------------------------*\ +| Per-key write flow control: past its limits the firmware | +| silently drops responses (G515 USB: ~20 writes then the | +| tail drops; the radio's ~3ms/packet pacing hides it | +| there). Cap unacked in-flight packets and let the ACK | +| stream pace the sends. | +| Adaptive: an unanswered packet halves the window (floor | +| MIN); loss-free frames grow it back (cap MAX). | +| WAIT: full-window stall this long = link died, abort the | +| frame. TAIL: end-of-frame quiet guard: a dropped ACK is | +| never coming. | +\*---------------------------------------------------------*/ +static constexpr size_t HIDPP20_PERKEY_WINDOW_MAX = 8; +static constexpr size_t HIDPP20_PERKEY_WINDOW_MIN = 2; +static constexpr uint16_t HIDPP20_PERKEY_ACK_WAIT_MS = 60; +static constexpr uint16_t HIDPP20_PERKEY_ACK_TAIL_MS = 25; +static constexpr uint8_t HIDPP20_PERKEY_WINDOW_GROW_AFTER = 16; + +/*---------------------------------------------------------*\ +| Deep-sleep detection threshold. After StartSleep() | +| commands the firmware fade, the device eventually | +| enters deep sleep and returns BUSY to every FrameEnd. | +| Once this many consecutive FrameEnd attempts exhaust | +| all BUSY retries while power_state == SLEEPING, we | +| suppress further frame sends until Wake() fires. | +\*---------------------------------------------------------*/ static constexpr int HIDPP20_DEEP_SLEEP_FAILURE_THRESHOLD = 5; -/*------------------------------------------------------*\ -| Per-key frame retry backoff. Used when a full | -| DeviceUpdateLEDs pass completes with some zones | -| unacked (partial commit). The retry re-runs a whole | -| frame from the power thread, so the backoff is | -| between full frames, not individual packets. | -| | -| First value aligned to the power thread's 50ms poll | -| cadence — anything shorter rounds up anyway, and | -| matching the tick makes latency predictable. | -| | -| Worst case: 5 retries, cumulative ~1550ms. This | -| covers the reconnect-transient window where the G502 | -| firmware silently drops per-key writes for several | -| hundred ms after the wireless link re-establishes. | -\*------------------------------------------------------*/ +/*---------------------------------------------------------*\ +| Per-key frame retry backoff. Used when a full | +| DeviceUpdateLEDs pass completes with some zones | +| unacked (partial commit). The retry re-runs a whole | +| frame from the power thread, so the backoff is | +| between full frames, not individual packets. | +| | +| First value aligned to the power thread's 50ms poll | +| cadence; anything shorter rounds up anyway, and | +| matching the tick makes latency predictable. | +| | +| Worst case: 5 retries, cumulative ~1550ms. This | +| covers the reconnect-transient window where the G502 | +| firmware silently drops per-key writes for several | +| hundred ms after the wireless link re-establishes. | +\*---------------------------------------------------------*/ static constexpr uint16_t HIDPP20_REPAINT_RETRY_BACKOFF_MS[] = { 50, 100, 200, 400, 800 }; @@ -438,14 +539,15 @@ static constexpr HIDPP20RetryPolicy HIDPP20_POLICY_RELIABLE = { "reliable" }; -/*------------------------------------------------------*\ -| Probe policy: tight budget for is-this-HID++ checks | -| during initial discovery. ~500ms worst case for dead | -| devices, vs ~6s for reliable. One retry handles a | -| transient hiccup on the first IRoot call (e.g. on a | -| busy mouse), but we bail fast on truly non-responsive | -| or non-HID++ hidraws so probe latency stays bounded. | -\*------------------------------------------------------*/ +/*---------------------------------------------------------*\ +| Probe policy: tight budget for is-this-HID++ checks | +| during initial discovery. ~500ms worst case for dead | +| devices, vs ~6s for reliable. One retry handles a | +| transient hiccup on the first IRoot call (e.g. on a | +| busy mouse), but we bail fast on truly non-responsive | +| or non-HID++ hidraws so probe latency stays bounded. | +\*---------------------------------------------------------*/ + static constexpr HIDPP20RetryPolicy HIDPP20_POLICY_PROBE = { HIDPP20_BACKOFF_PROBE, sizeof(HIDPP20_BACKOFF_PROBE) / sizeof(uint16_t), @@ -455,40 +557,100 @@ static constexpr HIDPP20RetryPolicy HIDPP20_POLICY_PROBE = { "probe" }; +/*---------------------------------------------------------*\ +| Rolling resync. The per-key stream is a delta and takes | +| an ACK as proof of paint, so a write the device answers | +| but does not apply leaves that key wrong until its colour | +| changes again - which for a key holding a steady colour | +| never happens. Every frame that is not a full update | +| also repaints the least recently sent keys, so any | +| divergence heals within a bounded number of frames | +| whatever caused it. A quiet frame carries more of them; | +| a busy frame still carries one, so a busy side of the | +| board cannot starve an idle one. | +\*---------------------------------------------------------*/ +#define HIDPP20_RESYNC_KEYS_MIN 1 +#define HIDPP20_RESYNC_KEYS_MAX 4 +#define HIDPP20_RESYNC_QUIET_ZONES 8 + class LogitechHIDPP20Controller { public: LogitechHIDPP20Controller(hid_device* dev, const char* path, uint8_t device_index, bool wireless, std::shared_ptr mutex_ptr, - uint16_t usage_page = 0xFF00); + uint16_t usage_page = 0xFF00, + hid_device* perkey_vl_dev = nullptr); ~LogitechHIDPP20Controller(); - /*-------------------------------------------------*\ - | Lifecycle | - \*-------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Lifecycle | + \*-----------------------------------------------------*/ bool Probe(); + + /*-----------------------------------------------------*\ + | Who is this? Transport framing, an IRoot ping, | + | the unit id; nothing else. Changes nothing on | + | the device, so it is safe against hardware | + | another controller is driving. Empty = not | + | HID++ 2.0, or no unit id to identify it by. | + \*-----------------------------------------------------*/ + std::string ProbeIdentity(); + + /*-----------------------------------------------------*\ + | The name the device answers with, or "" when it | + | has none we can trust, a placeholder, or bytes | + | that don't read as a name. Callers persist this, | + | so a guess is worse than nothing. Run after | + | ProbeIdentity, on the same probe object. | + \*-----------------------------------------------------*/ + std::string ProbeName(); + + /*-----------------------------------------------------*\ + | Does this read as a name a device gave us, rather | + | than as whatever was left in the buffer? Public | + | because a receiver's stored pairing names get the | + | same doubt as the ones devices answer with. | + \*-----------------------------------------------------*/ + static bool NameLooksReal(const std::string& name); + void Initialize(); void Shutdown(); - /*-------------------------------------------------*\ - | Accessors | - \*-------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Accessors | + \*-----------------------------------------------------*/ const HIDPP20DeviceCapabilities& GetCapabilities() const; + const HIDPP20Transport& GetTransport() const { return transport; } std::string GetDeviceLocation(); std::string GetSerialString(); uint32_t GetInitGeneration() const; - /*--------------------------------------------------*\ - | Per-key lighting (0x8081) | - | | - | Per-key writes are fire-and-forget at the wire | - | layer. SendPerKeyData enqueues an outstanding | - | write entry; PerKeyFrameEnd drains the response | - | queue, matches ACKs by FIFO, and returns which | - | zones the firmware actually committed plus | - | whether the FrameEnd itself ACKed. | - \*--------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Sender thread: SubmitPerKeyFrame stores a snapshot, | + | replacing any unsent one, and returns; the sender | + | sends at the pace the link sustains. The effect | + | engine never blocks on the wire. | + \*-----------------------------------------------------*/ + void SetPerKeyFrameSender(std::function&)> fn); + void SubmitPerKeyFrame(const std::vector& frame); + bool HasPerKeyFrameSender() const; + + /*-----------------------------------------------------*\ + | True while RediscoverFeatures rebuilds the map. | + | The effect engine must not paint or claim during | + | it: has_perkey and zone/effect data flap false, | + | so a claim here reaches flags=5 with an empty | + | per-key layer. | + \*-----------------------------------------------------*/ + bool DiscoveryInProgress() const; + + /*-----------------------------------------------------*\ + | Per-key lighting (0x8081). Writes pipeline within a | + | frame; PerKeyFrameEnd attributes ACKs by payload echo | + | and reports exactly which zones committed, misses | + | ride the next frame's delta. | + \*-----------------------------------------------------*/ void SetPerKeyColors(const std::vector>& zone_colors); void SetAllPerKeyColor(RGBColor color); void SendPerKeyData(uint8_t perkey_idx, uint8_t function, @@ -496,168 +658,257 @@ public: const std::vector& zone_ids); PerKeyFrameResult PerKeyFrameEnd(); - /*-------------------------------------------------*\ - | Zone effects (0x8071 / 0x8070) | - \*-------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Per-key lighting (0x8080). Addresses (keyType, | + | keyId); fn3 rides the 0x12 report on dev_perkey_vl, | + | fn5 FlushLEDS commits on dev. The key set comes | + | from DiscoverPerKey8080: never hardcoded. | + \*-----------------------------------------------------*/ + void SetPerKey8080(const std::vector>>>& by_type); + void PerKeyCommit8080(); + + /*-----------------------------------------------------*\ + | Zone effects (0x8071 / 0x8070) | + \*-----------------------------------------------------*/ void SetZoneEffect(uint8_t cluster_idx, uint8_t effect_idx, uint16_t effect_id, unsigned char r, unsigned char g, unsigned char b, uint16_t period, unsigned char brightness, unsigned char direction, bool persist); - /*---------------------------------------------------*\ - | Headset RGB hostmode (0x0620). | - | Sticky-claim model: SetHostMode() claims once via | - | fn8, then each write is fn5 (single-value) or fn2 | - | (individual) + fn6 FrameEnd[0x01]. 0x02 persist was | - | tested and does not work on G522 firmware. | - \*---------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Headset RGB hostmode (0x0620). | + | Sticky-claim model: SetHostMode() claims once via | + | fn8, then each write is fn5 (single-value) or fn2 | + | (individual) + fn6 FrameEnd[0x01]. 0x02 persist was | + | tested and does not work on G522 firmware. | + \*-----------------------------------------------------*/ void SetHeadsetRGBHostmodeColors(const std::vector& zone_colors); - /*-------------------------------------------------*\ - | SW control management | - \*-------------------------------------------------*/ + /*-----------------------------------------------------*\ + | SW control management | + \*-----------------------------------------------------*/ int SetSWControl(uint8_t mode, uint8_t flags); void SetRGBPowerMode(uint8_t mode); void SetHostMode(); bool ClaimSWControlIfNeeded(); void UpgradeSwControlAfterFirstPaint(); - /*-------------------------------------------------*\ - | Keyboard-family handshake (0x4522 fn3 + fn1). | - | G815 / G915 / G Pro send this before any mode | - | write. Feature-gated no-op on devices (G502 / | - | G515) that don't enumerate 0x4522. | - \*-------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Keyboard-family handshake (0x4522 fn3 + fn1). | + | G815 / G915 / G Pro send this before any mode | + | write. Feature-gated no-op on devices (G502 / | + | G515) that don't enumerate 0x4522. | + \*-----------------------------------------------------*/ void DoDisableKeysByUsageHandshake(); - /*--------------------------------------------------*\ - | Keyboard-family per-key takeover prep. | - | Per-cluster SetEffectByIndex(effectIdx=0=Off, | - | persist=1) + primer key via SetIndividualRgbZones | - | + FrameEnd. Matches G815 / G915 InitializeDirect. | - | Gated on 0x4522 + per-key V2 presence. | - \*--------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Keyboard-family per-key takeover prep. | + | Per-cluster SetEffectByIndex(effectIdx=0=Off, | + | persist=1) + primer key via SetIndividualRgbZones | + | + FrameEnd. Matches G815 / G915 InitializeDirect. | + | Gated on 0x4522 + per-key V2 presence. | + \*-----------------------------------------------------*/ void DoKeyboardFamilyPerKeyPrep(); - /*--------------------------------------------------*\ - | Wake-repaint flag. Set by Wake() before calling | - | request_repaint_fn so the repaint callback knows | - | to invalidate sent_colors (force a full per-key | - | push) without triggering the claim/prep sequence. | - \*--------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Wake-repaint flag. Set by Wake() before calling | + | request_repaint_fn so the repaint callback knows | + | to invalidate sent_colors (force a full per-key | + | push) without triggering the claim/prep sequence. | + \*-----------------------------------------------------*/ bool ConsumeWakeFullRepaint(); - bool NeedsPrepSequence() const { return sw_control_needs_upgrade_to_5; } + /*-----------------------------------------------------*\ + | The prep is a claim-time operation, not a per-frame | + | one: it wipes the device's per-key buffer so the | + | per-key layer can be laid down fresh. Running it | + | again on a later frame throws away everything | + | painted since. Once per claim, and no more. | + \*-----------------------------------------------------*/ + bool NeedsPrepSequence() const { return sw_control_needs_upgrade_to_5 && !prep_applied; } + void MarkPrepApplied() { prep_applied = true; } - /*--------------------------------------------------*\ - | Per-key retry scheduling. Called by the RGB | - | controller's DeviceUpdateLEDs on partial-commit | - | frames; the power thread polls and fires | - | request_repaint_fn when a retry deadline expires. | - \*--------------------------------------------------*/ - void ScheduleRetryPaint(); + /*-----------------------------------------------------*\ + | True from the claim until the per-key layer | + | takes the screen. This is the first-paint | + | window: the animation is not yet guaranteed to | + | drive another frame, so a partial commit has to | + | be retried rather than left for the next tick. | + \*-----------------------------------------------------*/ + bool AwaitingSWControlUpgrade() const { return sw_control_needs_upgrade_to_5; } + + /*-----------------------------------------------------*\ + | Per-key retry scheduling. Called by the RGB | + | controller's DeviceUpdateLEDs on partial-commit | + | frames; the power thread polls and fires | + | request_repaint_fn when a retry deadline expires. | + \*-----------------------------------------------------*/ + bool ScheduleRetryPaint(); void CancelRetryPaint(); void TickRetryPaintIfPending(); - /*--------------------------------------------------*\ - | Observed per-key prep sequence. Two | - | SetEffectByIndex calls cloned byte-for-byte from a | - | wire capture on a G502 X PLUS. | - | Used in place of the Static-pass-through prep when | - | the device's RGBEffects enumeration matches the | - | G502 shape — see DeviceUpdateLEDs for gating. | - \*--------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Observed per-key prep sequence. Two | + | SetEffectByIndex calls cloned byte-for-byte from a | + | wire capture on a G502 X PLUS. | + | Used in place of the Static-pass-through prep when | + | the device's RGBEffects enumeration matches the | + | G502 shape, see DeviceUpdateLEDs for gating. | + \*-----------------------------------------------------*/ void DoObservedPerKeyPrep(); - /*--------------------------------------------------*\ - | Power management (idle/dim/sleep/wake) | - \*--------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Power management (idle/dim/sleep/wake) | + \*-----------------------------------------------------*/ void StartPowerManager(); void StopPowerManager(); void StartEventWatcher(); - void StartProbeWatcher(); bool HasBridge() const; + + /*-----------------------------------------------------*\ + | The receiver watcher's connection nudge, the same | + | flag the reader raises, so the power thread's | + | handling and guards apply. Safe from any thread. | + \*-----------------------------------------------------*/ + void NudgeConnection(int direction) { pending_connection.store(direction); } void SetRepaintCallback(std::function repaint); void SetReapplyActiveModeCallback(std::function cb); - void SetRegisterCallback(std::function cb); + void SetPairingName(const std::string& name); /* receiver-stored codename; watcher label + name-read fallback */ + + /*-----------------------------------------------------*\ + | One wire transaction at a time. Responses land in one | + | queue and SendAcked drains it, discarding | + | unrecognized frames, only safe while nothing else has | + | a transaction in flight. A per-key frame is a long | + | transaction (prep, writes, FrameEnd matching the ACKs | + | back); the power thread sends on its own schedule and | + | would otherwise eat the frame's ACKs mid-flight, | + | reading landed writes as uncommitted. Held across | + | SendAcked, and by the RGB controller across a whole | + | frame; recursive because a frame sends. | + \*-----------------------------------------------------*/ + std::recursive_mutex& TransactionMutex() { return transaction_mutex; } + HIDPP20PowerState GetPowerState() const; int GetDimBrightness() const; bool IsOnline() const; bool IsDeepSleep() const; void SetWireless(bool w) { wireless = w; } - bool QueryWirelessStatus(); bool QueryExternalPower(); void FlushResponseQueue(); private: hid_device* dev; + hid_device* dev_perkey_vl; /* 0x8080: very-long (0x12) write handle (usage 0x0604); nullptr if absent */ std::string location; uint8_t device_index; + + /*-----------------------------------------------------*\ + | Set when the controller is going away. Waits, | + | retries and backoff sleeps check it and give up | + | rather than run their course. | + \*-----------------------------------------------------*/ + std::atomic teardown_pending; + + std::recursive_mutex transaction_mutex; /* one wire transaction at a time, see TransactionMutex() */ + bool wireless; std::shared_ptr mutex; + std::atomic long_only; /* collection rejected the 0x10 short report */ + std::atomic discovery_in_progress_; /* RediscoverFeatures is rebuilding caps */ HIDPP20DeviceCapabilities caps; HIDPP20Transport transport; + + /*-----------------------------------------------------*\ + | Per-link feature cache (this controller is one | + | device), keyed by command link. last_fap_error_ | + | is the last HID++ error, for the restore | + | self-heal (0x06/0x07). | + \*-----------------------------------------------------*/ + std::map link_index_cache_; + uint8_t last_fap_error_; + + /*-----------------------------------------------------*\ + | While pipelining_claim_ is set, SendAckedIntoFAP | + | fires the write and records it here instead of | + | blocking. VerifyClaimPipeline reconciles the | + | batch afterward. | + \*-----------------------------------------------------*/ + bool pipelining_claim_; + std::vector claim_pipeline_; + bool initialized; bool sw_control_claimed; bool sw_control_needs_upgrade_to_5; + bool prep_applied; /* prep has run for the current claim */ uint32_t frame_counter; - /*--------------------------------------------------*\ - | Retry-paint state (partial-commit recovery). | - | retry_paint_deadline_ zero = no retry pending. | - | retry_paint_attempt_ indexes into | - | HIDPP20_REPAINT_RETRY_BACKOFF_MS; once it reaches | - | the array length, we give up for this sequence. | - | Atomic so both the paint thread (RGB controller) | - | and the power thread can access without locks. | - \*--------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Retry-paint state (partial-commit recovery). | + | retry_paint_deadline_ zero = no retry pending. | + | retry_paint_attempt_ indexes into | + | HIDPP20_REPAINT_RETRY_BACKOFF_MS; once it reaches | + | the array length, we give up for this sequence. | + | Atomic so both the paint thread (RGB controller) | + | and the power thread can access without locks. | + \*-----------------------------------------------------*/ std::atomic retry_paint_deadline_; std::atomic retry_paint_attempt_; std::atomic wake_full_repaint_pending_; uint32_t init_generation; std::string log_tag; - /*---------------------------------------------------*\ - | Transport-layer I/O | - | | - | SendMessage/ReadMessage handle wire framing based | - | on transport.type. Upper layers pass feature index, | - | function ID, and payload — the transport layer | - | wraps them in the correct report format. | - \*---------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Transport-layer I/O | + | | + | SendMessage/ReadMessage handle wire framing based | + | on transport.type. Upper layers pass feature index, | + | function ID, and payload, the transport layer | + | wraps them in the correct report format. | + \*-----------------------------------------------------*/ int SendMessage(uint8_t feat_idx, uint8_t function, const uint8_t* data, size_t len); int ReadMessage(uint8_t* feat_idx_out, uint8_t* function_out, uint8_t* data_out, size_t data_max, int timeout_ms = LOGITECH_PROTOCOL_TIMEOUT); - /*--------------------------------------------------*\ - | Standard HID++ transport (0xFF00/0xFF43) | - \*--------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Standard HID++ transport (0xFF00/0xFF43) | + \*-----------------------------------------------------*/ int SendStandard(uint8_t feat_idx, uint8_t function, const uint8_t* data, size_t len); int ReadStandardDirect(uint8_t* feat_idx_out, uint8_t* function_out, uint8_t* data_out, size_t data_max, int timeout_ms); - /*--------------------------------------------------*\ - | Centurion transport (0xFFA0) | - | Wraps messages in CPL framing, routes through | - | CentPPBridge for sub-device access. | - \*--------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Centurion transport (0xFFA0) | + | Wraps messages in CPL framing, routes through | + | CentPPBridge for sub-device access. | + \*-----------------------------------------------------*/ int SendCenturion(uint8_t feat_idx, uint8_t function, const uint8_t* data, size_t len); int ReadCenturionDirect(uint8_t* feat_idx_out, uint8_t* function_out, uint8_t* data_out, size_t data_max, int timeout_ms); - /*--------------------------------------------------*\ - | Reader thread dispatch layer | - | ReadHIDDirect: raw HID read (used by reader thread | - | and during Probe before reader starts). | - | ReadFromQueue: waits on response queue filled by | - | the reader thread. | - \*--------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Feature 0x8080 very-long (0x12) frame transport. | + | Writes a 64-byte report to dev_perkey_vl; the ACK | + | lands on dev and is drained by the reader thread. | + | Fire-and-forget, do NOT route through SendStandard | + | (which caps at the 0x11 long report). | + \*-----------------------------------------------------*/ + int SendVeryLongFrame(uint8_t feat_idx, uint8_t function, + const uint8_t* data, size_t len); + + /*-----------------------------------------------------*\ + | Reader thread dispatch layer | + | ReadHIDDirect: raw HID read (used by reader thread | + | and during Probe before reader starts). | + | ReadFromQueue: waits on response queue filled by | + | the reader thread. | + \*-----------------------------------------------------*/ int ReadHIDDirect(uint8_t* feat_idx_out, uint8_t* function_out, uint8_t* data_out, size_t data_max, int timeout_ms); @@ -665,51 +916,53 @@ private: uint8_t* data_out, size_t data_max, int timeout_ms); - /*---------------------------------------------------*\ - | High-level helpers | - | SendAndReceive is retained as a thin wrapper | - | around SendAcked with the reliable policy, for | - | call-site stability. | - \*---------------------------------------------------*/ + /*-----------------------------------------------------*\ + | High-level helpers | + | SendAndReceive is retained as a thin wrapper | + | around SendAcked with the reliable policy, for | + | call-site stability. | + \*-----------------------------------------------------*/ int SendAndReceive(uint8_t feat_idx, uint8_t function, const uint8_t* send_data, size_t send_len, uint8_t* recv_data, size_t recv_max); - /*---------------------------------------------------*\ - | Unified send-and-ack primitive with retry policy. | - | All command paths converge here. Returns: | - | >0 : bytes copied into recv_data | - | 0 : timeout / BUSY exhaustion | - | -1 : non-BUSY HID++ error | - | -2 : wire error (SendMessage failed) | - | If hidpp20_error_out is non-null and return is -1, | - | the HID++ error code is stored there. | - \*---------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Unified send-and-ack primitive with retry policy. | + | All command paths converge here. Returns: | + | >0 : bytes copied into recv_data | + | 0 : timeout / BUSY exhaustion | + | -1 : non-BUSY HID++ error | + | -2 : wire error (SendMessage failed) | + | If hidpp20_error_out is non-null and return is -1, | + | the HID++ error code is stored there. | + \*-----------------------------------------------------*/ int SendAcked(uint8_t feat_idx, uint8_t function, const uint8_t* send_data, size_t send_len, uint8_t* recv_data, size_t recv_max, const HIDPP20RetryPolicy& policy = HIDPP20_POLICY_RELIABLE, uint8_t* hidpp20_error_out = nullptr); - /*--------------------------------------------------*\ - | Compatibility shim: same as SendAcked but writes | - | the response into a blankFAPmessage. Used by | - | callers that inherited the SendLong+ReadResponse | - | interface and inspect response.data[] downstream. | - \*--------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Compatibility shim: same as SendAcked but writes | + | the response into a blankFAPmessage. Used by | + | callers that inherited the SendLong+ReadResponse | + | interface and inspect response.data[] downstream. | + \*-----------------------------------------------------*/ int SendAckedIntoFAP(uint8_t feat_idx, uint8_t function, const uint8_t* send_data, size_t send_len, blankFAPmessage& response, const HIDPP20RetryPolicy& policy = HIDPP20_POLICY_RELIABLE); - /*-------------------------------------------------*\ - | Feature discovery | - \*-------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Feature discovery | + \*-----------------------------------------------------*/ uint8_t GetFeatureIndex(uint16_t feature_page, const HIDPP20RetryPolicy& policy = HIDPP20_POLICY_RELIABLE); uint8_t GetFeatureVersion(uint16_t feature_page) const; + void LogFeatureSummary(); void DiscoverTransport(); void DiscoverDeviceName(); + bool ReadCenturionDeviceName0101(std::string& out); void DiscoverDeviceType(); void EnumerateFeatures(uint8_t feature_set_idx); void DiscoverFirmwareInfo(); @@ -717,11 +970,16 @@ private: void DiscoverEffectCards(); void DiscoverHeadsetRGBHostmode(); void DiscoverPerKeyZones(); + void DiscoverPerKey8080(); + size_t ReadPerKey8080Colors(uint8_t feat_idx, uint16_t key_type, + uint16_t expected_key_count, + std::vector& out_ids, + bool log_colors); void DiscoverKeyboardLayout(); - /*-------------------------------------------------*\ - | Power management internals | - \*-------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Power management internals | + \*-----------------------------------------------------*/ void ReaderThreadFunc(); void PowerThreadFunc(); void DispatchEvent(uint8_t feat, uint8_t func, const uint8_t* data); @@ -736,65 +994,68 @@ private: void ReadActiveProfileSector(); void ReprobeSubDevice(); void ReconnectDevice(); - void FullReprobe(); - void RediscoverFeatures(); - /*----------------------------------------------------*\ - | Platform-specific. ScanForDevice walks the OS-level | - | HID enumeration to find the same physical device on | - | a new path (USB<->wireless transitions). Linux uses | - | sysfs; Windows and macOS use hidapi + serial_number | - | matching. Bodies live in | - | LogitechHIDPP20Controller_Linux.cpp and | - | LogitechHIDPP20Controller_Windows_MacOS.cpp. | - \*----------------------------------------------------*/ - bool ScanForDevice(bool force = false); - /*----------------------------------------------------*\ - | Platform-specific. Returns the friendly name for a | - | Centurion sub-device at the given hidapi path, or "" | - | if no name is available. Linux reads HID_NAME from | - | sysfs; Windows uses hid_device_info::product_string. | - \*----------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Take software control and keep trying until it | + | sticks. A device that has just come back is booting | + | its onboard profile and drops a claim that arrives | + | too early, silently. | + \*-----------------------------------------------------*/ + bool ReclaimSWControl(const char* reason, bool force); + void RediscoverFeatures(); + + /*-----------------------------------------------------*\ + | Per-link feature-index cache helpers | + \*-----------------------------------------------------*/ + std::string CurrentLinkKey() const; /* usb#idx or rx#slot for the current command link */ + HIDPP20LinkIndexMap SnapshotLinkIndexMap() const; /* pull the link-dependent indices out of caps */ + void RestoreLinkIndexMap(const HIDPP20LinkIndexMap& m); /* push them back */ + void CacheCurrentLinkIndexMap(); /* snapshot the current link if discovery is complete */ + void HealStaleMapIfNeeded(const char* reclaim_reason); /* rediscover if a reclaim hit 0x06/0x07 */ + void VerifyClaimPipeline(); /* reconcile a pipelined claim's ACKs, blocking-retry any miss */ + + /*-----------------------------------------------------*\ + | Platform-specific. Returns the friendly name for a | + | Centurion sub-device at the given hidapi path, or "" | + | if no name is available. Linux reads HID_NAME from | + | sysfs; Windows uses hid_device_info::product_string. | + \*-----------------------------------------------------*/ std::string GetCenturionSubDeviceName(const std::string& path); - void SwapHIDHandle(hid_device* new_dev, const std::string& new_path); - - /*-------------------------------------------------*\ - | Reader thread + response queue | - \*-------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Reader thread + response queue | + \*-----------------------------------------------------*/ std::thread* reader_thread; std::atomic reader_running; std::mutex response_mutex; std::condition_variable response_cv; std::deque response_queue; - /*-------------------------------------------------*\ - | Power thread (state machine + command sender) | - \*-------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Power thread (state machine + command sender) | + \*-----------------------------------------------------*/ std::thread* power_thread; std::atomic power_thread_running; std::atomic pending_activity; // -1=none, 0=idle, 1+=active std::atomic pending_connection; // 0=none, +1=connected, -1=disconnected - std::atomic pending_path_check; // HID++1.0 DJ notification → force-scan retries remaining (0=idle) std::atomic device_online; // false when device is unreachable std::atomic consecutive_timeouts; // reset on successful response - std::atomic watcher_mode; // true when retrying failed probe - /*-------------------------------------------------*\ - | Power management state | - \*-------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Power management state | + \*-----------------------------------------------------*/ HIDPP20PowerState power_state; std::mutex power_mutex; std::atomic deep_sleep; // true once device stops responding after StartSleep() std::atomic consecutive_frame_end_failures; // FrameEnd BUSY exhaustions while SLEEPING - /*-------------------------------------------------*\ - | Dim ramp state | - | dim_brightness_pct is applied by DeviceUpdateLEDs | - | to scale colors before pushing to device. | - | This is our own host-side animation, independent | - | of any firmware dim/sleep timers. | - \*-------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Dim ramp state | + | dim_brightness_pct is applied by DeviceUpdateLEDs | + | to scale colors before pushing to device. | + | This is our own host-side animation, independent | + | of any firmware dim/sleep timers. | + \*-----------------------------------------------------*/ #define DIM_STEPS 25 #define DIM_INTERVAL_MS 200 #define DIM_TARGET_PCT 50 @@ -802,47 +1063,47 @@ private: int dim_step; std::chrono::steady_clock::time_point next_dim_time; - /*-------------------------------------------------*\ - | Sleep timer | - \*-------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Sleep timer | + \*-----------------------------------------------------*/ std::chrono::steady_clock::time_point sleep_deadline; - /*--------------------------------------------------*\ - | Last idle-settings re-read timestamp. Drives the | - | 500ms poll in PowerThreadFunc that re-reads the | - | LogitechHIDPP20IdleSettings JSON key so updates | - | from the plugin (or manual edits) apply within | - | about half a second without any callback plumbing. | - \*--------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Last idle-settings re-read timestamp. Drives the | + | 500ms poll in PowerThreadFunc that re-reads the | + | LogitechHIDPP20IdleSettings JSON key so updates | + | from the plugin (or manual edits) apply within | + | about half a second without any callback plumbing. | + \*-----------------------------------------------------*/ std::chrono::steady_clock::time_point last_idle_poll; - /*-------------------------------------------------*\ - | Effective idle/sleep timers used by the state | - | machine. Populated from the firmware snapshot by | - | default, then possibly overridden by profile | - | values in ApplyPowerSavingProfile. | - \*-------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Effective idle/sleep timers used by the state | + | machine. Populated from the firmware snapshot by | + | default, then possibly overridden by profile | + | values in ApplyPowerSavingProfile. | + \*-----------------------------------------------------*/ uint16_t idle_timeout_s; uint16_t sleep_timeout_s; - /*--------------------------------------------------*\ - | Firmware-configured timer snapshot. Read at init | - | (and on reconnect) by ReadFirmwareTimers and | - | never overwritten by profile application, so | - | that the unconfigured fallback path and transition | - | back from a user profile both have a clean set | - | of defaults to return to. | - \*--------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Firmware-configured timer snapshot. Read at init | + | (and on reconnect) by ReadFirmwareTimers and | + | never overwritten by profile application, so | + | that the unconfigured fallback path and transition | + | back from a user profile both have a clean set | + | of defaults to return to. | + \*-----------------------------------------------------*/ uint16_t fw_idle_timeout_s = 60; uint16_t fw_sleep_timeout_s = 300; uint16_t written_idle_s = 0; // last value written to device RAM (0 = not written yet) uint16_t written_sleep_s = 0; - /*--------------------------------------------------*\ - | Host-side idle/dim/sleep state. | - | Populated from LogitechHIDPP20IdleSettings on each | - | ApplyPowerSavingProfile() invocation. | - \*--------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Host-side idle/dim/sleep state. | + | Populated from LogitechHIDPP20IdleSettings on each | + | ApplyPowerSavingProfile() invocation. | + \*-----------------------------------------------------*/ bool ps_dim_enabled = false; int ps_dim_target_pct = DIM_TARGET_PCT; bool ps_sleep_enabled = false; @@ -857,18 +1118,105 @@ private: void ApplyPowerSavingProfile(); bool IsCurrentlyWireless() const; - /*-------------------------------------------------*\ - | Per-key write tracking (per active frame) | - | Populated by SendPerKeyData, drained by | - | PerKeyFrameEnd. Single-threaded — only the RGB | - | controller thread touches per-key state. | - \*-------------------------------------------------*/ - std::vector outstanding_writes; + /*-----------------------------------------------------*\ + | Per-key frame accumulators. Populated by | + | SendPerKeyData as packets go out and ACKs are | + | attributed, settled by PerKeyFrameEnd. Guarded by | + | transaction_mutex, which every frame path holds. | + \*-----------------------------------------------------*/ + std::vector frame_attempted_zones; + std::vector frame_packets; + std::vector frame_acked_zones; + std::map frame_unmatched_acks; + size_t frame_responses_seen = 0; + size_t frame_exact_acks = 0; + bool frame_aborted = false; + std::chrono::steady_clock::time_point frame_first_write; - /*-------------------------------------------------*\ - | Callbacks | - \*-------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Learned FrameEnd busy window, us per zone: the | + | commit's not-ready time scales with update size, so | + | predict it, sleep the prediction before the first | + | FrameEnd attempt (link stays free for input), keep | + | the BUSY poll as corrector. BUSY -> pull toward the | + | measurement; clean first-try accept -> decay slowly | + | to re-probe the floor. Starts at 0 (first frames | + | measure); converges within seconds, per device. | + \*-----------------------------------------------------*/ + float fe_busy_us_per_zone = 0.0f; + uint8_t fe_frames_since_probe = 0; + + /*-----------------------------------------------------*\ + | Zones staged on the device since the last | + | successful FrameEnd. This: not the current | + | frame's zone count, is what the commit has to | + | chew through: an aborted frame skips its FrameEnd | + | and its landed writes latch with the next one. | + \*-----------------------------------------------------*/ + size_t fe_staged_zones = 0; + + /*-----------------------------------------------------*\ + | Adaptive write window (AIMD on ACK loss). | + \*-----------------------------------------------------*/ + size_t perkey_window = HIDPP20_PERKEY_WINDOW_MAX; + uint8_t perkey_clean_frames = 0; + + void ResetPerKeyFrameState(); + + /*-----------------------------------------------------*\ + | Read and process one per-key response within | + | timeout_ms: attribute a write ACK, count a write | + | error, ignore anything else. Returns 1 when a | + | message was consumed, 0 on timeout, -1 on wire | + | error. | + \*-----------------------------------------------------*/ + int ProcessOnePerKeyResponse(int timeout_ms, uint8_t perkey_idx); + + /*-----------------------------------------------------*\ + | Read until the wire goes quiet. Called after an | + | incomplete frame: late ACKs may still be in | + | flight and must be consumed before the next | + | frame's matching can trust what it reads. | + \*-----------------------------------------------------*/ + void DrainStaleResponses(); + + /*-----------------------------------------------------*\ + | Attribute one write ACK to an outstanding packet | + | by payload echo. Appends the zones it proves to | + | acked_zones_out; returns the packet index or -1. | + \*-----------------------------------------------------*/ + int MatchPerKeyAck(uint8_t resp_func_hi, + const uint8_t* resp_data, + std::vector& acked_zones_out); + + /*-----------------------------------------------------*\ + | Per-key frame sender thread state. pending_frame | + | holds the newest submitted snapshot; submitting | + | over an unsent one replaces it (the skip counter | + | records how many). | + \*-----------------------------------------------------*/ + void SenderThreadFunc(); + void StopSenderThread(); + + /*-----------------------------------------------------*\ + | Sliced sleep that wakes early on a pending link | + | change or offline; false when interrupted. | + \*-----------------------------------------------------*/ + bool InterruptibleBackoff(uint16_t delay_ms); + + std::function&)> perkey_frame_fn; + std::thread* sender_thread = nullptr; + std::atomic sender_running{false}; + std::mutex pending_frame_mutex; + std::condition_variable pending_frame_cv; + std::vector pending_frame; + bool pending_frame_valid = false; + uint32_t pending_frames_skipped = 0; + + /*-----------------------------------------------------*\ + | Callbacks | + \*-----------------------------------------------------*/ std::function request_repaint_fn; std::function reapply_active_mode_fn; - std::function register_controller_fn; + std::string pairing_name; /* receiver-stored codename, "" if unknown */ }; diff --git a/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller_Linux.cpp b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller_Linux.cpp index d166e0612..c94a043b2 100644 --- a/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller_Linux.cpp +++ b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller_Linux.cpp @@ -28,27 +28,45 @@ std::string LogitechHIDPP20Controller::GetCenturionSubDeviceName(const std::string& path) { - /*--------------------------------------------------------*\ - | Centurion sub-device friendly name comes from sysfs | - | HID_NAME=, same field Solaar reads. The `path` arg is a | - | hidraw dev path like /dev/hidraw5; extract the hidrawN | - | basename and read /sys/class/hidraw//device/ | - | uevent. | - \*--------------------------------------------------------*/ - std::string sysfs_name; + /*-----------------------------------------------------*\ + | Prefer the USB product string (Solaar's source) | + | over HID_NAME's "Logitech " prefix: read | + | /sys/class/hidraw//device/../../product, with | + | HID_NAME from the hidraw uevent as the fallback. | + \*-----------------------------------------------------*/ + std::string name; size_t pos = path.rfind("hidraw"); if(pos == std::string::npos) { - return sysfs_name; + return name; } - std::string uevent_path = "/sys/class/hidraw/" + path.substr(pos) + "/device/uevent"; + std::string hidraw = path.substr(pos); + + std::ifstream pf("/sys/class/hidraw/" + hidraw + "/device/../../product"); + + if(pf) + { + std::getline(pf, name); + + while(!name.empty() && (name.back() == '\n' || name.back() == '\r')) + { + name.pop_back(); + } + } + + if(!name.empty()) + { + return name; + } + + std::string uevent_path = "/sys/class/hidraw/" + hidraw + "/device/uevent"; FILE* f = fopen(uevent_path.c_str(), "r"); if(!f) { - return sysfs_name; + return name; } char line[256]; @@ -57,11 +75,11 @@ std::string LogitechHIDPP20Controller::GetCenturionSubDeviceName(const std::stri { if(strncmp(line, "HID_NAME=", 9) == 0) { - sysfs_name = line + 9; + name = line + 9; - while(!sysfs_name.empty() && (sysfs_name.back() == '\n' || sysfs_name.back() == '\r')) + while(!name.empty() && (name.back() == '\n' || name.back() == '\r')) { - sysfs_name.pop_back(); + name.pop_back(); } break; @@ -69,365 +87,5 @@ std::string LogitechHIDPP20Controller::GetCenturionSubDeviceName(const std::stri } fclose(f); - return sysfs_name; -} - -bool LogitechHIDPP20Controller::ScanForDevice(bool force) -{ - /*---------------------------------------------------------*\ - | Scan sysfs for a hidraw with matching unitId. Called by | - | the power thread either periodically (normal reactive | - | mode — only when device_online==false, USB/wireless | - | transition or physical unplug) or on demand from the | - | reader thread after a HID++1.0 Device Connection | - | notification arrives (force=true, bypasses the online | - | gate). Finds the device on its new connection path. | - | | - | Reactive-only: we never migrate while the current path | - | still works. On devices like the G502 X PLUS that expose | - | both a USB-direct hidraw AND a wireless-via-dongle hidraw | - | simultaneously (when paired to the receiver and plugged | - | in at the same time), eager migration would bounce us off | - | a working path onto one the firmware has actively muted, | - | breaking control. The device signals which path is | - | active by returning errors/going silent on the inactive | - | path — our reader thread picks that up as a hid_read | - | failure and flips device_online=false, at which point the | - | power thread calls this function to pick the new path. | - | | - | Linux implementation: walks /sys/class/hidraw and matches | - | on HID_UNIQ. The Windows counterpart (same class method, | - | different .cpp file) uses hid_enumerate + serial_number. | - | | - | Match criteria: | - | - device_online == false (caller should already ensure) | - | - HID_UNIQ matches our unitId | - | - Logitech VID (046D) | - | - HID++ interface (usage_page 0xFF00, usage 2) | - | - Different from our current path (device moved) | - \*---------------------------------------------------------*/ - if(caps.unit_id.empty() || caps.unit_id == "00000000") - { - return false; - } - - /*---------------------------------------------------------*\ - | Online guard: never migrate off a working path unless | - | the caller explicitly forces a re-check. The normal | - | periodic scan path stays gated on device_online==false | - | so it's a no-op when everything is healthy. | - | | - | The reader thread bypasses this guard (force=true) after | - | seeing a HID++1.0 Device Connection Status notification | - | from the Lightspeed receiver, which fires BEFORE the | - | firmware fully switches its data flow from wireless to | - | USB. At that moment device_online is still true (the | - | current path hasn't failed yet), but we want the scan to | - | run so we can migrate to the new path proactively. | - \*---------------------------------------------------------*/ - if(!force && device_online.load()) - { - return false; - } - - /*---------------------------------------------------------*\ - | Normalize our unitId to lowercase hex without dashes for | - | comparison. Sysfs HID_UNIQ varies between drivers: | - | - Lightspeed virtual: "0d-12-5d-47" (dashes) | - | - USB direct: "0D125D47" (no dashes) | - | We strip dashes and lowercase both sides for matching. | - \*---------------------------------------------------------*/ - std::string target_norm; - - for(char c : caps.unit_id) - { - if(c != '-') - { - target_norm += (char)tolower(c); - } - } - - /*---------------------------------------------------------*\ - | Read our current PID from sysfs. Only migrate to paths | - | with a DIFFERENT PID — same PID means same receiver, | - | just a different pairing slot (e.g., stale pairing). | - \*---------------------------------------------------------*/ - unsigned int current_pid = 0; - { - std::string cur_hidraw = location.substr(location.rfind('/') + 1); - std::string cur_uevent = "/sys/class/hidraw/" + cur_hidraw + "/device/uevent"; - std::ifstream cur_file(cur_uevent); - std::string line; - - while(std::getline(cur_file, line)) - { - if(line.compare(0, 7, "HID_ID=") == 0) - { - size_t lc = line.rfind(':'); - - if(lc != std::string::npos) - { - sscanf(line.c_str() + lc + 1, "%x", ¤t_pid); - } - - break; - } - } - } - - /*----------------------------------------------------------*\ - | Collect ALL sysfs hidraws with matching unit_id + Logitech | - | VID + different PID. With multiple Lightspeed dongles in | - | the system, several hidraws can share the same HID_UNIQ: | - | one is the live virt-slot on the connected dongle, others | - | are stale virt-slots on dongles where our device is not | - | actually present. We have to probe each one to find out | - | which slot is real — sysfs alone can't tell them apart. | - \*----------------------------------------------------------*/ - struct Candidate - { - std::string dev_path; - unsigned int pid; - }; - - std::vector candidates; - - DIR* dir = opendir("/sys/class/hidraw"); - - if(!dir) - { - return false; - } - - struct dirent* entry; - - while((entry = readdir(dir)) != nullptr) - { - if(strncmp(entry->d_name, "hidraw", 6) != 0) - { - continue; - } - - std::string uevent_path = "/sys/class/hidraw/" + - std::string(entry->d_name) + "/device/uevent"; - std::ifstream uevent(uevent_path); - - if(!uevent.is_open()) - { - continue; - } - - std::string line; - std::string hid_uniq; - std::string hid_id; - - while(std::getline(uevent, line)) - { - if(line.compare(0, 9, "HID_UNIQ=") == 0) - { - hid_uniq = line.substr(9); - } - else if(line.compare(0, 7, "HID_ID=") == 0) - { - hid_id = line.substr(7); - } - } - - std::string uniq_norm; - - for(char c : hid_uniq) - { - if(c != '-') - { - uniq_norm += (char)tolower(c); - } - } - - if(uniq_norm != target_norm) - { - continue; - } - - if(hid_id.find("0000046D") == std::string::npos && - hid_id.find("0000046d") == std::string::npos) - { - continue; - } - - std::string dev_path = "/dev/" + std::string(entry->d_name); - - if(dev_path == location) - { - continue; - } - - size_t last_colon = hid_id.rfind(':'); - - if(last_colon == std::string::npos) - { - continue; - } - - unsigned int pid = 0; - sscanf(hid_id.c_str() + last_colon + 1, "%x", &pid); - - if(pid == current_pid) - { - continue; - } - - candidates.push_back({dev_path, pid}); - } - - closedir(dir); - - if(candidates.empty()) - { - return false; - } - - /*----------------------------------------------------------*\ - | Probe each candidate before committing. Two-dongle systems | - | can expose multiple sysfs hidraws with the same HID_UNIQ: | - | one is the live slot, others are stale pairings that | - | respond with short-format UNKNOWN_DEVICE errors. Issue a | - | cheap IRoot GetFeature (feat 0x0001) to distinguish them. | - | A live slot returns a long-form response with feat_idx=0 | - | and feat_byte matching the sub-index we asked for. A stale | - | slot returns r[10 xx 8F 00 00 08 ...] (short error, code | - | 0x08 UNKNOWN_DEVICE). We accept the first candidate that | - | passes; the overall pending_path_check retry loop will | - | naturally re-probe all candidates on subsequent scans if | - | none pass on the current pass (e.g., mid-transition). | - \*----------------------------------------------------------*/ - std::string found_path; - hid_device* found_dev = nullptr; - - for(size_t c = 0; c < candidates.size(); c++) - { - const Candidate& cand = candidates[c]; - - LOG_DEBUG("%s Scan: migration candidate at %s (pid=0x%04X, current=%s pid=0x%04X)", - LOG_TAG, cand.dev_path.c_str(), cand.pid, - location.c_str(), current_pid); - - /*-----------------------------------------------------*\ - | Multi-dongle caveat: hid_enumerate returns entries | - | for ALL dongles with this PID (046D:4099 Lightspeed | - | can appear several times in a single machine). Match | - | strictly on the sysfs candidate's dev_path so each | - | candidate resolves to its OWN dongle's HID++ | - | interface — not the first match we stumble across. | - \*-----------------------------------------------------*/ - hid_device_info* devs = hid_enumerate(0x046D, (uint16_t)cand.pid); - std::string hidpp20_path; - - for(hid_device_info* d = devs; d != nullptr; d = d->next) - { - LOG_TRACE("%s Scan: enumerate PID=0x%04X path=%s page=0x%04X usage=%d", - LOG_TAG, cand.pid, d->path, d->usage_page, d->usage); - - if(std::string(d->path) == cand.dev_path && - d->usage_page == 0xFF00 && d->usage == 2 && - std::string(d->path) != location) - { - hidpp20_path = d->path; - break; - } - } - - hid_free_enumeration(devs); - - if(hidpp20_path.empty()) - { - LOG_DEBUG("%s Scan: %s no matching LogitechHID++ interface in enum", - LOG_TAG, cand.dev_path.c_str()); - continue; - } - - hid_device* test_dev = hid_open_path(hidpp20_path.c_str()); - - if(!test_dev) - { - LOG_DEBUG("%s Scan: %s failed to open", LOG_TAG, hidpp20_path.c_str()); - continue; - } - - /*------------------------------------------------------*\ - | Probe: HID++2.0 IRoot GetFeature for feat 0x0001 | - | (IFeatureSet). Wire: w[10 FF 0000 000100] | - | - report_id 0x10 (short) | - | - device_index 0xFF | - | - feat_idx 0x00 (IRoot) | - | - address 0x00 (func 0 GetFeature, sw_id 0) | - | - payload 00 01 00 (feat_id 0x0001) | - | | - | A live device returns r[11 xx 00 0X ...] — long form, | - | feat_idx 0x00, the address byte we sent back, and the | - | feature index in the payload. | - | A stale slot returns r[10 xx 8F 00 00 08 ...] — short | - | error form. Reject anything that starts with 0x10. | - \*------------------------------------------------------*/ - uint8_t probe[7] = {0x10, 0xFF, 0x00, 0x00, 0x00, 0x01, 0x00}; - uint8_t reply[20] = {}; - int write_rc = hid_write(test_dev, probe, sizeof(probe)); - bool probe_ok = false; - - if(write_rc >= 0) - { - /*-----------------------------------------------------*\ - | Drain up to ~100ms, looking for a long-form response | - | matching our probe. Skip stray reports from unrelated | - | firmware events that might be queued on the hidraw. | - \*-----------------------------------------------------*/ - std::chrono::steady_clock::time_point deadline = std::chrono::steady_clock::now() + - std::chrono::milliseconds(100); - - while(std::chrono::steady_clock::now() < deadline) - { - int read_rc = hid_read_timeout(test_dev, reply, sizeof(reply), 50); - - if(read_rc <= 0) - { - continue; - } - - if(reply[0] == 0x11 && reply[2] == 0x00 && reply[3] == 0x00) - { - probe_ok = true; - break; - } - - if(reply[0] == 0x10 && reply[2] == 0x8F) - { - LOG_DEBUG("%s Scan: %s probe rejected — err=0x%02X (stale slot)", - LOG_TAG, hidpp20_path.c_str(), reply[5]); - break; - } - } - } - - if(!probe_ok) - { - hid_close(test_dev); - continue; - } - - LOG_DEBUG("%s Scan: %s probe accepted (feat_idx=0x%02X)", - LOG_TAG, hidpp20_path.c_str(), reply[4]); - - found_path = hidpp20_path; - found_dev = test_dev; - break; - } - - if(!found_dev) - { - return false; - } - - LOG_INFO("%s Device migrated: %s -> %s", LOG_TAG, location.c_str(), found_path.c_str()); - - SwapHIDHandle(found_dev, found_path); - return true; + return name; } diff --git a/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller_Windows_MacOS.cpp b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller_Windows_MacOS.cpp index 6c01d99b6..dedebe5a3 100644 --- a/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller_Windows_MacOS.cpp +++ b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20Controller_Windows_MacOS.cpp @@ -25,18 +25,20 @@ std::string LogitechHIDPP20Controller::GetCenturionSubDeviceName(const std::string& path) { - /*---------------------------------------------------------*\ - | On Windows, hidapi's hid_device_info carries a | - | product_string field (wchar_t*). Enumerate all Logitech | - | devices and find the one whose path matches `path`. | - | | - | Caveat: Windows hidapi typically returns the parent | - | product string on every (interface, usage_page, usage) | - | entry that maps to the same USB device, so Centurion | - | sub-devices may share a name with the parent dongle. | - | That's a less specific name than Linux's HID_NAME, but | - | still better than "Logitech Centurion Device". | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | On Windows, hidapi's hid_device_info | + | carries a product_string field (wchar_t*). | + | Enumerate all Logitech devices and find | + | the one whose path matches `path`. | + | | + | Caveat: Windows hidapi typically returns the | + | parent product string on every (interface, | + | usage_page, usage) entry that maps to the same | + | USB device, so Centurion sub-devices may share a | + | name with the parent dongle. That's a less | + | specific name than Linux's HID_NAME, but still | + | better than "Logitech Centurion Device". | + \*-----------------------------------------------------*/ std::string friendly; hid_device_info* devs = hid_enumerate(0x046D, 0x0000); @@ -60,243 +62,3 @@ std::string LogitechHIDPP20Controller::GetCenturionSubDeviceName(const std::stri hid_free_enumeration(devs); return friendly; } - -bool LogitechHIDPP20Controller::ScanForDevice(bool force) -{ - /*---------------------------------------------------------*\ - | Scan hidapi for a Logitech device with matching unitId. | - | Called by the power thread either periodically (normal | - | reactive mode — only when device_online==false, USB/ | - | wireless transition or physical unplug) or on demand | - | from the reader thread after a HID++1.0 Device | - | Connection notification arrives (force=true, bypasses | - | the online gate). Finds the device on its new | - | connection path. | - | | - | Reactive-only: we never migrate while the current path | - | still works (same rationale as Linux — see the Linux | - | companion file for the full explanation). | - | | - | Windows implementation: walks hid_enumerate(046D, *) and | - | matches on hid_device_info::serial_number, which for | - | Logitech devices generally corresponds to the same | - | stable identity as the HID++-reported unit_id. If | - | serial_number matching yields nothing (hidapi may not | - | populate it for some Logitech devices on Windows), we | - | fall through to IRoot probing every candidate on the | - | matching usage_page/usage so we can still find the | - | device albeit more slowly. | - \*---------------------------------------------------------*/ - if(caps.unit_id.empty() || caps.unit_id == "00000000") - { - return false; - } - - if(!force && device_online.load()) - { - return false; - } - - std::string target_norm = StringUtils::normalize_hex_id(caps.unit_id); - - /*---------------------------------------------------------*\ - | Candidate = {path, pid, has_serial_match}. Serial matches | - | sort first so we try the cheapest/most-likely candidate. | - \*---------------------------------------------------------*/ - struct Candidate - { - std::string dev_path; - unsigned int pid; - bool serial_match; - }; - - std::vector candidates; - unsigned int current_pid = 0; - - hid_device_info* devs = hid_enumerate(0x046D, 0x0000); - - /*---------------------------------------------------------*\ - | First pass: find the entry matching our current path and | - | record its product_id so we can skip same-PID candidates. | - \*---------------------------------------------------------*/ - for(hid_device_info* d = devs; d != nullptr; d = d->next) - { - if(d->path != nullptr && std::string(d->path) == location) - { - current_pid = d->product_id; - break; - } - } - - /*---------------------------------------------------------*\ - | Second pass: collect candidates that (a) aren't our | - | current path, (b) speak the HID++ interface, and (c) have | - | a plausible identity match — either the serial_number | - | matches our unit_id, or at minimum their PID differs from | - | ours so they can't be another slot on the same dongle. | - \*---------------------------------------------------------*/ - for(hid_device_info* d = devs; d != nullptr; d = d->next) - { - if(d->path == nullptr) - { - continue; - } - - if(std::string(d->path) == location) - { - continue; - } - - /*-----------------------------------------------------*\ - | Only HID++ interface (usage_page 0xFF00, usage 2). | - | Note: on Windows hidapi may or may not populate | - | usage / usage_page consistently. If either is zero, | - | fall through — we'll probe unconditionally in that | - | case rather than dropping a potential candidate. | - \*-----------------------------------------------------*/ - bool usage_known = (d->usage_page != 0 || d->usage != 0); - - if(usage_known && (d->usage_page != 0xFF00 || d->usage != 2)) - { - continue; - } - - /*-----------------------------------------------------*\ - | Compare serial_number (wchar_t*) against our unit_id. | - | Normalize both and do an exact-match comparison. | - | Empty/missing serial is allowed — falls through as a | - | serial_match=false candidate so the probe path can | - | still find it via a different-PID filter. | - \*-----------------------------------------------------*/ - bool serial_match = false; - - if(d->serial_number != nullptr && d->serial_number[0] != L'\0') - { - std::string sn = StringUtils::wchar_to_string(d->serial_number); - std::string sn_norm = StringUtils::normalize_hex_id(sn); - - if(!sn_norm.empty() && sn_norm == target_norm) - { - serial_match = true; - } - } - - /*-----------------------------------------------------*\ - | If we have no serial match AND this path shares the | - | current PID, skip. Same PID with no identity evidence | - | is probably a sibling slot on the same dongle, not a | - | valid migration target. | - \*-----------------------------------------------------*/ - if(!serial_match && d->product_id == current_pid && current_pid != 0) - { - continue; - } - - Candidate c; - c.dev_path = d->path; - c.pid = d->product_id; - c.serial_match = serial_match; - candidates.push_back(c); - } - - hid_free_enumeration(devs); - - if(candidates.empty()) - { - return false; - } - - /*---------------------------------------------------------*\ - | Sort so serial-matched candidates get probed first. | - \*---------------------------------------------------------*/ - std::stable_sort(candidates.begin(), candidates.end(), - [](const Candidate& a, const Candidate& b) - { - return a.serial_match && !b.serial_match; - }); - - /*---------------------------------------------------------*\ - | Probe each candidate with IRoot GetFeature feat 0x0001. | - | A live slot returns a long-form (0x11) response; a stale | - | slot returns a short-form (0x10) error. See the Linux | - | companion file for the wire-level explanation. | - \*---------------------------------------------------------*/ - std::string found_path; - hid_device* found_dev = nullptr; - - for(size_t c = 0; c < candidates.size(); c++) - { - const Candidate& cand = candidates[c]; - - LOG_DEBUG("%s Scan: migration candidate at %s (pid=0x%04X, serial_match=%d)", - LOG_TAG, cand.dev_path.c_str(), cand.pid, - cand.serial_match ? 1 : 0); - - hid_device* test_dev = hid_open_path(cand.dev_path.c_str()); - - if(!test_dev) - { - LOG_DEBUG("%s Scan: %s failed to open", - LOG_TAG, cand.dev_path.c_str()); - continue; - } - - uint8_t probe[7] = {0x10, 0xFF, 0x00, 0x00, 0x00, 0x01, 0x00}; - uint8_t reply[20] = {}; - int write_rc = hid_write(test_dev, probe, sizeof(probe)); - bool probe_ok = false; - - if(write_rc >= 0) - { - std::chrono::steady_clock::time_point deadline = std::chrono::steady_clock::now() + - std::chrono::milliseconds(100); - - while(std::chrono::steady_clock::now() < deadline) - { - int read_rc = hid_read_timeout(test_dev, reply, sizeof(reply), 50); - - if(read_rc <= 0) - { - continue; - } - - if(reply[0] == 0x11 && reply[2] == 0x00 && reply[3] == 0x00) - { - probe_ok = true; - break; - } - - if(reply[0] == 0x10 && reply[2] == 0x8F) - { - LOG_DEBUG("%s Scan: %s probe rejected — err=0x%02X (stale slot)", - LOG_TAG, cand.dev_path.c_str(), reply[5]); - break; - } - } - } - - if(!probe_ok) - { - hid_close(test_dev); - continue; - } - - LOG_DEBUG("%s Scan: %s probe accepted (feat_idx=0x%02X)", - LOG_TAG, cand.dev_path.c_str(), reply[4]); - - found_path = cand.dev_path; - found_dev = test_dev; - break; - } - - if(!found_dev) - { - return false; - } - - LOG_INFO("%s Device migrated: %s -> %s", - LOG_TAG, location.c_str(), found_path.c_str()); - - SwapHIDHandle(found_dev, found_path); - return true; -} diff --git a/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20IdleSettings.cpp b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20IdleSettings.cpp index 3d1b0e02b..8073f644c 100644 --- a/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20IdleSettings.cpp +++ b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20IdleSettings.cpp @@ -85,21 +85,29 @@ void LogitechHIDPP20IdleSettings::load() { json settings = ResourceManager::get()->GetSettingsManager()->GetSettings(SETTINGS_KEY); - /*---------------------------------------------------------*\ - | Empty / missing key means the plugin is not in use. | - | Reset both profiles to defaults with configured=false so | - | the controller defers to firmware. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Empty / missing key means the plugin is not in use. | + | Reset both profiles to defaults with configured=false | + | so the controller defers to firmware. | + \*-----------------------------------------------------*/ if(!settings.is_object() || settings.empty()) { - configured = false; - on_battery = LogitechHIDPP20IdleProfile{}; - plugged_in = LogitechHIDPP20IdleProfile{}; + configured = false; + force_host_mode = false; + show_unmapped = false; + on_battery = LogitechHIDPP20IdleProfile{}; + plugged_in = LogitechHIDPP20IdleProfile{}; return; } configured = true; + force_host_mode = settings.contains("force_host_mode") + ? (bool)settings["force_host_mode"] : false; + + show_unmapped = settings.contains("show_unmapped_leds") + ? (bool)settings["show_unmapped_leds"] : false; + if(settings.contains("on_battery")) { on_battery = ProfileFromJson(settings["on_battery"]); @@ -123,8 +131,10 @@ void LogitechHIDPP20IdleSettings::save() { json settings; - settings["on_battery"] = ProfileToJson(on_battery); - settings["plugged_in"] = ProfileToJson(plugged_in); + settings["on_battery"] = ProfileToJson(on_battery); + settings["plugged_in"] = ProfileToJson(plugged_in); + settings["force_host_mode"] = force_host_mode; + settings["show_unmapped_leds"] = show_unmapped; SettingsManager* mgr = ResourceManager::get()->GetSettingsManager(); mgr->SetSettings(SETTINGS_KEY, settings); @@ -133,6 +143,18 @@ void LogitechHIDPP20IdleSettings::save() configured = true; } +void LogitechHIDPP20IdleSettings::setForceHostMode(bool v) +{ + force_host_mode = v; + configured = true; +} + +void LogitechHIDPP20IdleSettings::setShowUnmapped(bool v) +{ + show_unmapped = v; + configured = true; +} + void LogitechHIDPP20IdleSettings::setOnBattery(const LogitechHIDPP20IdleProfile& p) { on_battery = p; diff --git a/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20IdleSettings.h b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20IdleSettings.h index f3cacbcad..52e6c6a48 100644 --- a/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20IdleSettings.h +++ b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20IdleSettings.h @@ -5,7 +5,7 @@ | HID++ 2.0 devices. Two profiles (on_battery, plugged_in)| | selected at runtime based on the device's external- | | power flag. `configured == false` means the JSON key | -| is absent entirely — the controller defers to firmware. | +| is absent entirely, the controller defers to firmware. | | Qt-free so the controller can consume it directly. | | | | This file is part of the OpenRGB project | @@ -35,6 +35,23 @@ public: const LogitechHIDPP20IdleProfile& onBattery() const { return on_battery; } const LogitechHIDPP20IdleProfile& pluggedIn() const { return plugged_in; } + /*-----------------------------------------------------*\ + | Ignore HIDPP20_QUIRK_KEEP_ONBOARD_MODE (Solaar or the | + | official app handles G-keys). Applied at device init. | + \*-----------------------------------------------------*/ + bool forceHostMode() const { return force_host_mode; } + void setForceHostMode(bool v); + + /*-----------------------------------------------------*\ + | Surface every unclaimed zone the classification would | + | suppress (defined-but-unstuffed positions, per-board | + | phantoms) in the Unmapped zone. For boards that | + | actually stuff a position no KLM layout places (F13, | + | Keypad =). Applied at device rescan. | + \*-----------------------------------------------------*/ + bool showUnmapped() const { return show_unmapped; } + void setShowUnmapped(bool v); + void setOnBattery(const LogitechHIDPP20IdleProfile& p); void setPluggedIn(const LogitechHIDPP20IdleProfile& p); @@ -42,6 +59,8 @@ private: LogitechHIDPP20IdleSettings() = default; bool configured = false; + bool force_host_mode = false; + bool show_unmapped = false; LogitechHIDPP20IdleProfile on_battery; LogitechHIDPP20IdleProfile plugged_in; }; diff --git a/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20ReceiverWatcher.cpp b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20ReceiverWatcher.cpp new file mode 100644 index 000000000..3246c671a --- /dev/null +++ b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20ReceiverWatcher.cpp @@ -0,0 +1,391 @@ +/*---------------------------------------------------------*\ +| LogitechHIDPP20ReceiverWatcher.cpp | +| | +| Persistent per-receiver-node listener | +| | +| This file is part of the OpenRGB project | +| SPDX-License-Identifier: GPL-2.0-or-later | +\*---------------------------------------------------------*/ + +#include "LogitechHIDPP20ReceiverWatcher.h" +#include "LogitechHIDPP20Controller.h" +#include "LogManager.h" + +#define LOG_TAG log_tag.c_str() + +/*---------------------------------------------------------*\ +| Give up on the node after this many consecutive read | +| errors; the receiver was unplugged. | +\*---------------------------------------------------------*/ +static const int WATCHER_READ_ERROR_LIMIT = 10; + +/*---------------------------------------------------------*\ +| Built sub-device registry. Keyed on node and index so one | +| watcher can poke any of the up-to-six devices paired to | +| its receiver. | +\*---------------------------------------------------------*/ +static std::map, LogitechHIDPP20Controller*> subdevice_registry; +static std::mutex subdevice_registry_mutex; + +/*---------------------------------------------------------*\ +| How to build a device, set by the detector. | +\*---------------------------------------------------------*/ +static LogitechHIDPP20ReceiverWatcher::BuilderFunction subdevice_builder; + +void LogitechHIDPP20ReceiverWatcher::SetBuilder(BuilderFunction builder) +{ + std::lock_guard lock(subdevice_registry_mutex); + subdevice_builder = builder; +} + +void LogitechHIDPP20ReceiverWatcher::RegisterSubDevice(const std::string& node_path, uint8_t index, LogitechHIDPP20Controller* controller) +{ + std::lock_guard lock(subdevice_registry_mutex); + subdevice_registry[std::make_pair(node_path, index)] = controller; +} + +void LogitechHIDPP20ReceiverWatcher::UnregisterSubDevice(LogitechHIDPP20Controller* controller) +{ + std::lock_guard lock(subdevice_registry_mutex); + + for(std::map, LogitechHIDPP20Controller*>::iterator it = subdevice_registry.begin(); + it != subdevice_registry.end();) + { + if(it->second == controller) + { + it = subdevice_registry.erase(it); + } + else + { + ++it; + } + } +} + +bool LogitechHIDPP20ReceiverWatcher::PokeSubDevice(const std::string& node_path, uint8_t index, int direction) +{ + std::lock_guard lock(subdevice_registry_mutex); + + std::map, LogitechHIDPP20Controller*>::iterator it = + subdevice_registry.find(std::make_pair(node_path, index)); + + if(it == subdevice_registry.end()) + { + return false; + } + + it->second->NudgeConnection(direction); + + return true; +} + +/*---------------------------------------------------------*\ +| A bridge event carries the connected sub-device count, | +| not an index, so every controller on the node gets the | +| direction. | +\*---------------------------------------------------------*/ +void LogitechHIDPP20ReceiverWatcher::PokeNode(const std::string& node_path, int direction) +{ + std::lock_guard lock(subdevice_registry_mutex); + + for(std::map, LogitechHIDPP20Controller*>::iterator it = subdevice_registry.begin(); + it != subdevice_registry.end(); ++it) + { + if(it->first.first == node_path) + { + it->second->NudgeConnection(direction); + } + } +} + +bool LogitechHIDPP20ReceiverWatcher::HasSubDevice(const std::string& node_path, uint8_t index) +{ + std::lock_guard lock(subdevice_registry_mutex); + + return subdevice_registry.count(std::make_pair(node_path, index)) != 0; +} + +LogitechHIDPP20ReceiverWatcher::LogitechHIDPP20ReceiverWatcher(const std::string& node_path, uint8_t bridge_feat_idx, + uint8_t bridge_report_id, bool bridge_addressed) +{ + this->node_path = node_path; + this->bridge_feat_idx = bridge_feat_idx; + this->bridge_report_id = bridge_report_id; + this->bridge_addressed = bridge_addressed; + this->log_tag = "[Logitech receiver watcher @ " + node_path + "]"; + this->dev = nullptr; + this->reader_thread = nullptr; + this->worker_thread = nullptr; + this->running = false; + this->alive = false; +} + +LogitechHIDPP20ReceiverWatcher::~LogitechHIDPP20ReceiverWatcher() +{ + running.store(false); + queue_cv.notify_all(); + + if(reader_thread) + { + reader_thread->join(); + delete reader_thread; + } + + if(worker_thread) + { + worker_thread->join(); + delete worker_thread; + } + + if(dev) + { + hid_close(dev); + } +} + +bool LogitechHIDPP20ReceiverWatcher::Start() +{ + dev = hid_open_path(node_path.c_str()); + + if(dev == nullptr) + { + return false; + } + + running.store(true); + alive.store(true); + + reader_thread = new std::thread(&LogitechHIDPP20ReceiverWatcher::ReaderThreadFunc, this); + worker_thread = new std::thread(&LogitechHIDPP20ReceiverWatcher::WorkerThreadFunc, this); + + LOG_INFO("%s Watching for connection events", LOG_TAG); + + return true; +} + +bool LogitechHIDPP20ReceiverWatcher::IsAlive() +{ + return alive.load(); +} + +/*---------------------------------------------------------*\ +| Hand a slot to the worker, once. Repeated connection | +| events for a slot that cannot answer must not queue | +| more than one build. | +\*---------------------------------------------------------*/ +void LogitechHIDPP20ReceiverWatcher::QueueBuild(uint8_t index) +{ + { + std::lock_guard lock(queue_mutex); + + for(std::deque::iterator it = build_queue.begin(); it != build_queue.end(); ++it) + { + if(*it == index) + { + return; + } + } + + build_queue.push_back(index); + } + + queue_cv.notify_all(); +} + +/*---------------------------------------------------------*\ +| A slot changed state. A built controller gets the nudge | +| and its own threads handle the wake; a slot with no | +| controller was unreachable when the pass ran, so build | +| it now. | +\*---------------------------------------------------------*/ +void LogitechHIDPP20ReceiverWatcher::OnConnection(uint8_t index, int direction) +{ + if(PokeSubDevice(node_path, index, direction)) + { + LOG_DEBUG("%s index=0x%02X nudged %+d", LOG_TAG, index, direction); + return; + } + + if(direction < 0) + { + return; + } + + /*-----------------------------------------------------*\ + | Whether this slot is one we know is the builder's | + | answer to give: it holds what every enumerated slot | + | needs to be built, and it outlives the pass. | + \*-----------------------------------------------------*/ + QueueBuild(index); +} + +/*---------------------------------------------------------*\ +| Builds happen here, off the read loop, one at a time. | +\*---------------------------------------------------------*/ +void LogitechHIDPP20ReceiverWatcher::WorkerThreadFunc() +{ + while(running.load()) + { + uint8_t index = 0; + bool have = false; + + { + std::unique_lock lock(queue_mutex); + + queue_cv.wait(lock, [this]{ return !build_queue.empty() || !running.load(); }); + + if(!running.load()) + { + return; + } + + if(!build_queue.empty()) + { + index = build_queue.front(); + build_queue.pop_front(); + have = true; + } + } + + if(!have) + { + continue; + } + + BuilderFunction builder; + + { + std::lock_guard lock(subdevice_registry_mutex); + builder = subdevice_builder; + } + + if(!builder) + { + continue; + } + + LOG_INFO("%s index=0x%02X connected, building it", LOG_TAG, index); + + builder(node_path, index); + } +} + +/*---------------------------------------------------------*\ +| Only reader of the watcher's handle. Never sends: | +| decode, flag, continue. | +\*---------------------------------------------------------*/ +void LogitechHIDPP20ReceiverWatcher::ReaderThreadFunc() +{ + int errors = 0; + + while(running.load()) + { + uint8_t buf[64] = {}; + int result = hid_read_timeout(dev, buf, sizeof(buf), 500); + + if(result < 0) + { + if(++errors >= WATCHER_READ_ERROR_LIMIT) + { + LOG_DEBUG("%s Node is gone, going dormant", LOG_TAG); + break; + } + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + continue; + } + + errors = 0; + + if(result == 0) + { + continue; + } + + if(bridge_feat_idx != 0) + { + /*---------------------------------------------*\ + | CentPPBridge event 0: | + | ConnectionStateChangedEvent data[1] = number | + | of connected sub-devices for the whole | + | bridge, so every slot gets the direction. | + \*---------------------------------------------*/ + if(buf[0] != bridge_report_id) + { + continue; + } + + int hdr = bridge_addressed ? 1 : 0; + uint8_t feat = buf[3 + hdr]; + uint8_t func = buf[4 + hdr]; + + if(feat != bridge_feat_idx || (func & 0xF0) != 0x00 || (func & 0x0F) == HIDPP20_SW_ID) + { + continue; + } + + uint8_t num_devices = buf[6 + hdr]; + int direction = (num_devices > 0) ? 1 : -1; + + LOG_DEBUG("%s Bridge ConnectionStateChanged: %d sub-device(s)", LOG_TAG, num_devices); + + PokeNode(node_path, direction); + + if(direction > 0) + { + /*-----------------------------------------*\ + | A bridge event carries no slot index, so | + | a sub-device with no controller is the | + | dongle's own default index. | + \*-----------------------------------------*/ + if(!HasSubDevice(node_path, LOGITECH_DEFAULT_DEVICE_INDEX)) + { + QueueBuild(LOGITECH_DEFAULT_DEVICE_INDEX); + } + } + + continue; + } + + /*-------------------------------------------------*\ + | HID++ 1.0 connection notifications from the | + | receiver: 0x41 device connection (flags bit | + | 0x40 = paired but not linked), 0x40 device | + | disconnection. Disconnects are logged only, | + | built controllers detect it themselves. | + \*-------------------------------------------------*/ + if(buf[0] != LOGITECH_SHORT_MESSAGE && buf[0] != LOGITECH_LONG_MESSAGE) + { + continue; + } + + uint8_t index = buf[1]; + uint8_t sub_id = buf[2]; + uint8_t flags = buf[4]; + + if(sub_id == 0x41) + { + bool link_established = !(flags & 0x40); + + LOG_DEBUG("%s index=0x%02X connection notification flags=0x%02X link=%d", + LOG_TAG, index, flags, link_established); + + if(link_established) + { + OnConnection(index, 1); + } + } + else if(sub_id == 0x40) + { + LOG_DEBUG("%s index=0x%02X disconnection notification", LOG_TAG, index); + } + else + { + continue; + } + } + + alive.store(false); + queue_cv.notify_all(); +} + diff --git a/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20ReceiverWatcher.h b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20ReceiverWatcher.h new file mode 100644 index 000000000..68086d307 --- /dev/null +++ b/Controllers/LogitechController/LogitechHIDPP20Controller/LogitechHIDPP20ReceiverWatcher.h @@ -0,0 +1,90 @@ +/*---------------------------------------------------------*\ +| LogitechHIDPP20ReceiverWatcher.h | +| | +| Persistent per-receiver-node listener. Decodes the | +| dongle's connection notifications and either nudges the | +| built sub-device controller awake, or builds one for a | +| slot that has none yet. Never sends. | +| | +| This file is part of the OpenRGB project | +| SPDX-License-Identifier: GPL-2.0-or-later | +\*---------------------------------------------------------*/ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +class LogitechHIDPP20Controller; + +class LogitechHIDPP20ReceiverWatcher +{ +public: + /*-----------------------------------------------------*\ + | bridge_feat_idx == 0 reads HID++ 1.0 connection | + | notifications (standard receiver). Nonzero reads | + | CentPPBridge ConnectionStateChangedEvent in the | + | Centurion framing given by report_id/addressed. | + \*-----------------------------------------------------*/ + LogitechHIDPP20ReceiverWatcher(const std::string& node_path, uint8_t bridge_feat_idx, + uint8_t bridge_report_id, bool bridge_addressed); + ~LogitechHIDPP20ReceiverWatcher(); + + bool Start(); + bool IsAlive(); + + /*-----------------------------------------------------*\ + | How the watcher builds a device reported as | + | connected. Set once by the detector, which owns the | + | build; the watcher only decides when. | + \*-----------------------------------------------------*/ + typedef std::function BuilderFunction; + static void SetBuilder(BuilderFunction builder); + + static bool HasSubDevice(const std::string& node_path, uint8_t index); + + /*-----------------------------------------------------*\ + | Built sub-device registry, shared by all watchers. | + | A poke reaches a built controller; a controller | + | deregisters at the top of its destructor under the | + | same lock, so a poke can never touch a dying one. | + \*-----------------------------------------------------*/ + static void RegisterSubDevice(const std::string& node_path, uint8_t index, LogitechHIDPP20Controller* controller); + static void UnregisterSubDevice(LogitechHIDPP20Controller* controller); + +private: + void ReaderThreadFunc(); + void WorkerThreadFunc(); + void OnConnection(uint8_t index, int direction); + void QueueBuild(uint8_t index); + static bool PokeSubDevice(const std::string& node_path, uint8_t index, int direction); + static void PokeNode(const std::string& node_path, int direction); + + std::string node_path; + uint8_t bridge_feat_idx; + uint8_t bridge_report_id; + bool bridge_addressed; + std::string log_tag; + + hid_device* dev; + + std::thread* reader_thread; + std::thread* worker_thread; + std::atomic running; + std::atomic alive; + + /*-----------------------------------------------------*\ + | Slots reported connected with no controller yet, | + | waiting for the worker. A build is feature discovery | + | and a claim, far too long to run on the read loop. | + \*-----------------------------------------------------*/ + std::deque build_queue; + std::mutex queue_mutex; + std::condition_variable queue_cv; +}; diff --git a/Controllers/LogitechController/LogitechHIDPP20Controller/RGBController_LogitechHIDPP20.cpp b/Controllers/LogitechController/LogitechHIDPP20Controller/RGBController_LogitechHIDPP20.cpp index 8a67631fd..88a47190c 100644 --- a/Controllers/LogitechController/LogitechHIDPP20Controller/RGBController_LogitechHIDPP20.cpp +++ b/Controllers/LogitechController/LogitechHIDPP20Controller/RGBController_LogitechHIDPP20.cpp @@ -14,17 +14,18 @@ #include "RGBController_LogitechHIDPP20.h" #include "RGBControllerKeyNames.h" #include "KeyboardLayoutManager.h" +#include "LogitechHIDPP20IdleSettings.h" #include "LogManager.h" -/*----------------------------------------------------------*\ -| Sentinel for "this LED's last write didn't ACK". | -| Stored in sent_colors[i] to force the next frame to | -| re-push the LED regardless of color delta. The high byte | -| 0xFF is unreachable from any ToRGBColor(r,g,b) value | -| (those have high byte 0), so the sentinel never collides | -| with a real color including black (0x00000000) or | -| white (0x00FFFFFF). | -\*----------------------------------------------------------*/ +/*---------------------------------------------------------*\ +| Sentinel for "this LED's last write didn't ACK". | +| Stored in sent_colors[i] to force the next frame to | +| re-push the LED regardless of color delta. The high byte | +| 0xFF is unreachable from any ToRGBColor(r,g,b) value | +| (those have high byte 0), so the sentinel never collides | +| with a real color including black (0x00000000) or | +| white (0x00FFFFFF). | +\*---------------------------------------------------------*/ static constexpr RGBColor HIDPP20_UNCOMMITTED = 0xFF000000; /*---------------------------------------------------------*\ @@ -41,7 +42,7 @@ static const uint16_t HIDPP20_PERIOD_MAX_MS = 20000; /*---------------------------------------------------------*\ | Ripple has its own narrower, much faster period range. | | Values taken from G915's LOGITECH_G915_SPEED_RIPPLE_* | -| constants: 2ms..200ms. A ripple feels right when quick — | +| constants: 2ms..200ms. A ripple feels right when quick; | | using the breathing range (1..20s) makes it invisible. | \*---------------------------------------------------------*/ static const uint16_t HIDPP20_RIPPLE_PERIOD_MIN_MS = 2; @@ -116,19 +117,17 @@ static const char* zone_location_name(uint16_t location) { switch(location) { - case 0x0001: return "All"; - case 0x0002: return "Primary"; - case 0x0003: return "Combined"; - case 0x0004: return "Logo"; - case 0x0005: return "Left"; - case 0x0006: return "Right"; - case 0x0007: return "Group 1"; - case 0x0008: return "Group 2"; - case 0x0009: return "Group 3"; - case 0x000A: return "Group 4"; - case 0x000B: return "Group 5"; - case 0x2000: return "Top"; - case 0x4000: return "Bottom"; + case 0x0001: return "Primary"; + case 0x0002: return "Logo"; + case 0x0003: return "Left Side"; + case 0x0004: return "Right Side"; + case 0x0005: return "Combined"; + case 0x0006: return "Primary 1"; + case 0x0007: return "Primary 2"; + case 0x0008: return "Primary 3"; + case 0x0009: return "Primary 4"; + case 0x000A: return "Primary 5"; + case 0x000B: return "Primary 6"; default: { static char buf[16]; @@ -138,6 +137,22 @@ static const char* zone_location_name(uint16_t location) } } +/*---------------------------------------------------------*\ +| Speakers report the generic location codes but the lamps | +| are front and rear per satellite. | +\*---------------------------------------------------------*/ +static const char* speaker_zone_location_name(uint16_t location) +{ + switch(location) + { + case 0x0001: return "Left Front"; + case 0x0002: return "Right Front"; + case 0x0003: return "Left Rear"; + case 0x0004: return "Right Rear"; + default: return zone_location_name(location); + } +} + /*---------------------------------------------------------*\ | HID++ per-key zone ID to OpenRGB key name mapping | | Zone IDs follow Solaar's KEYCODES (special_keys.py) | @@ -191,7 +206,9 @@ static const std::map hidpp20_key_name_to_zone = { KEY_EN_EQUALS, 43 }, { KEY_EN_LEFT_BRACKET, 44 }, { KEY_EN_RIGHT_BRACKET, 45 }, - { KEY_EN_ANSI_BACK_SLASH, 46 }, + { KEY_EN_ANSI_BACK_SLASH, 46 }, /* ANSI only; ISO/JIS use 47 + 97 */ + { KEY_EN_POUND, 47 }, /* ISO/JIS POUND (#/ row 3 col 12) */ + { KEY_EN_ISO_BACK_SLASH, 97 }, /* ISO/JIS extra key (row 4 col 1) */ { KEY_EN_SEMICOLON, 48 }, { KEY_EN_QUOTE, 49 }, { KEY_EN_BACK_TICK, 50 }, @@ -227,10 +244,23 @@ static const std::map hidpp20_key_name_to_zone = { KEY_EN_RIGHT_FUNCTION, 111 }, { KEY_EN_MENU, 98 }, - /*------------------------------------------------------*\ - | Numpad zones (Solaar KEYCODES 80-96). | - | Required for any full-size HID++ keyboard. | - \*------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Positions 99-103, decoded from the enumeration's | + | correspondence with USB HID usages (zone = usage - 3; | + | the legacy G915 table carries the raw usages, Solaar | + | names 99 POWER). No current board stuffs them, so | + | they classify as known-but-unstuffed, not unknown. | + \*-----------------------------------------------------*/ + { KEY_EN_POWER, 99 }, + { KEY_EN_NUMPAD_EQUAL, 100 }, + { KEY_EN_F13, 101 }, + { KEY_EN_F14, 102 }, + { KEY_EN_F15, 103 }, + + /*-----------------------------------------------------*\ + | Numpad zones (Solaar KEYCODES 80-96). | + | Required for any full-size HID++ keyboard. | + \*-----------------------------------------------------*/ { KEY_EN_NUMPAD_LOCK, 80 }, { KEY_EN_NUMPAD_DIVIDE, 81 }, { KEY_EN_NUMPAD_TIMES, 82 }, @@ -258,12 +288,12 @@ static const std::map hidpp20_key_name_to_zone = { KEY_EN_RIGHT_ALT, 110 }, { KEY_EN_RIGHT_WINDOWS, 111 }, - /*------------------------------------------------------*\ - | G915 (and similar) out-of-KLM LEDs. | - | Zone IDs from Solaar KEYCODES. Names match the legacy | - | G915 controller so existing users don't see their LED | - | labels change when they move onto the unified driver. | - \*------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | G915 (and similar) out-of-KLM LEDs. | + | Zone IDs from Solaar KEYCODES. Names match the legacy | + | G915 controller so existing users don't see their LED | + | labels change when they move onto the unified driver. | + \*-----------------------------------------------------*/ { "Key: Brightness", 153 }, { KEY_EN_MEDIA_PLAY_PAUSE, 155 }, { KEY_EN_MEDIA_MUTE, 156 }, @@ -277,6 +307,304 @@ static const std::map hidpp20_key_name_to_zone = { "Logo", 210 }, }; +/*---------------------------------------------------------*\ +| Named extras: real positions on G-boards that no KLM | +| layout places (media strip, G-keys, logo). Zone-keyed, | +| but the numbering is board-specific, the PRO X RAPID | +| renumbered brightness/play/next to 150/152/154, so the | +| same label may sit at a different id on another board. | +\*---------------------------------------------------------*/ +static const std::map hidpp20_extras_names = +{ + { 150, "Brightness" }, /* PRO X RAPID renumbering */ + { 152, "Media Play/Pause" }, /* PRO X RAPID renumbering */ + { 153, "Brightness" }, + { 154, "Media Next" }, /* PRO X RAPID renumbering */ + { 155, "Media Play/Pause" }, + { 156, "Media Mute" }, + { 157, "Media Next" }, + { 158, "Media Previous" }, + { 180, "G1" }, + { 181, "G2" }, + { 182, "G3" }, + { 183, "G4" }, + { 184, "G5" }, + { 210, "Logo" }, +}; + +/*---------------------------------------------------------*\ +| What key does the enumeration define at this position? | +| The firmware reports a superset and the board stuffs a | +| subset; a defined-but-unclaimed position is unstuffed | +| (ISO keys on ANSI, numpad on TKL, F13-F15/Power), not | +| unknown. "" = not defined at all. | +\*---------------------------------------------------------*/ +static std::string HIDPP20ZonePositionName(uint16_t zid) +{ + for(const std::pair& kv : hidpp20_key_name_to_zone) + { + if(kv.second == zid) + { + return(kv.first); + } + } + + return(""); +} + +/*---------------------------------------------------------*\ +| Per-board zone policy for studied boards: | +| excluded zones this board misreports | +| suppress_unclaimed every unclaimed zone is a phantom | +| (the real special keys are all | +| placed by the layout edit) | +| Boards without an entry surface what they report: named | +| extras stay named, true unknowns get an Unmapped zone. | +\*---------------------------------------------------------*/ +struct KbZonePolicy +{ + uint16_t pid; + std::set excluded; + bool suppress_unclaimed; +}; + +static const KbZonePolicy known_kb_zone_policies[] = +{ + /*-----------------------------------------------------*\ + | PRO X RAPID: the probed top strip is the complete | + | special-key set; the firmware also enumerates the | + | canonical media/G-key ids, which aren't wired. | + \*-----------------------------------------------------*/ + { 0xC35B, {}, true }, +}; + +static const KbZonePolicy* FindKbZonePolicy(uint16_t pid_wired, uint16_t pid_wireless) +{ + for(const KbZonePolicy& p : known_kb_zone_policies) + { + if(p.pid == pid_wired || p.pid == pid_wireless) + { + return(&p); + } + } + + return(nullptr); +} + +/*---------------------------------------------------------*\ +| Keyboard layout edits: KLM key_set overlays for boards | +| that differ from stock (e.g. a media row above the | +| F-row). keyboard_led.value carries the HID++ zone id, | +| read back via GetKeyValueAt, so renumbered boards | +| address the right LED with no special case in the | +| commit path. Keyed by PID (wired or wireless). F-row | +| columns: ESC=0, F1..F12=2..13, PrtSc group 14-16. | +\*---------------------------------------------------------*/ +struct KbLayoutEdit +{ + uint16_t pid; + const keyboard_led* keys; + size_t key_count; +}; + +/*---------------------------------------------------------*\ +| PRO X RAPID top strip: INSERT_ROW adds row 0 above | +| the F-row, the rest INSERT_SHIFT_RIGHT into it. | +| Zone ids hardware-probed on this board. Game Mode | +| (F3) is a non-RGB toggle; this model has no | +| separately addressable logo LED, so none is listed. | +\*---------------------------------------------------------*/ +static const keyboard_led proxrapid_top_strip[] = +{ + { 0, 0, 5, 150, "Brightness", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_ROW }, /* above F4 */ + { 0, 0, 10, 155, "Media Previous", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, /* above F9 */ + { 0, 0, 11, 152, "Media Play/Pause", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, /* above F10 */ + { 0, 0, 12, 154, "Media Next", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, /* above F11 */ + { 0, 0, 13, 153, "Media Mute", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, /* above F12 */ +}; + +static const KbLayoutEdit known_kb_layout_edits[] = +{ + { 0xC35B, proxrapid_top_strip, 5 }, /* PRO X RAPID */ +}; + +static key_set FindKbLayoutEdit(uint16_t pid_wired, uint16_t pid_wireless) +{ + for(const KbLayoutEdit& e : known_kb_layout_edits) + { + if(e.pid == pid_wired || e.pid == pid_wireless) + { + return key_set(e.keys, e.keys + e.key_count); + } + } + + return key_set(); +} + +/*---------------------------------------------------------*\ +| OpenRGB key name -> USB HID Keyboard/Keypad usage (page | +| 0x07), used by the 0x8080 path whose keyIds ARE these | +| usages. Sibling to hidpp20_key_name_to_zone (the 0x8081 | +| sequential zones). This is the HID spec, not a device | +| table. | +\*---------------------------------------------------------*/ +static const std::map hidpp20_key_name_to_usage = +{ + { KEY_EN_A, 0x04 }, + { KEY_EN_B, 0x05 }, + { KEY_EN_C, 0x06 }, + { KEY_EN_D, 0x07 }, + { KEY_EN_E, 0x08 }, + { KEY_EN_F, 0x09 }, + { KEY_EN_G, 0x0A }, + { KEY_EN_H, 0x0B }, + { KEY_EN_I, 0x0C }, + { KEY_EN_J, 0x0D }, + { KEY_EN_K, 0x0E }, + { KEY_EN_L, 0x0F }, + { KEY_EN_M, 0x10 }, + { KEY_EN_N, 0x11 }, + { KEY_EN_O, 0x12 }, + { KEY_EN_P, 0x13 }, + { KEY_EN_Q, 0x14 }, + { KEY_EN_R, 0x15 }, + { KEY_EN_S, 0x16 }, + { KEY_EN_T, 0x17 }, + { KEY_EN_U, 0x18 }, + { KEY_EN_V, 0x19 }, + { KEY_EN_W, 0x1A }, + { KEY_EN_X, 0x1B }, + { KEY_EN_Y, 0x1C }, + { KEY_EN_Z, 0x1D }, + { KEY_EN_1, 0x1E }, + { KEY_EN_2, 0x1F }, + { KEY_EN_3, 0x20 }, + { KEY_EN_4, 0x21 }, + { KEY_EN_5, 0x22 }, + { KEY_EN_6, 0x23 }, + { KEY_EN_7, 0x24 }, + { KEY_EN_8, 0x25 }, + { KEY_EN_9, 0x26 }, + { KEY_EN_0, 0x27 }, + { KEY_EN_ANSI_ENTER, 0x28 }, + { KEY_EN_ESCAPE, 0x29 }, + { KEY_EN_BACKSPACE, 0x2A }, + { KEY_EN_TAB, 0x2B }, + { KEY_EN_SPACE, 0x2C }, + { KEY_EN_MINUS, 0x2D }, + { KEY_EN_EQUALS, 0x2E }, + { KEY_EN_LEFT_BRACKET, 0x2F }, + { KEY_EN_RIGHT_BRACKET, 0x30 }, + { KEY_EN_ANSI_BACK_SLASH, 0x31 }, /* ANSI */ + { KEY_EN_POUND, 0x32 }, /* ISO */ + { KEY_EN_SEMICOLON, 0x33 }, + { KEY_EN_QUOTE, 0x34 }, + { KEY_EN_BACK_TICK, 0x35 }, + { KEY_EN_COMMA, 0x36 }, + { KEY_EN_PERIOD, 0x37 }, + { KEY_EN_FORWARD_SLASH, 0x38 }, + { KEY_EN_CAPS_LOCK, 0x39 }, + { KEY_EN_F1, 0x3A }, + { KEY_EN_F2, 0x3B }, + { KEY_EN_F3, 0x3C }, + { KEY_EN_F4, 0x3D }, + { KEY_EN_F5, 0x3E }, + { KEY_EN_F6, 0x3F }, + { KEY_EN_F7, 0x40 }, + { KEY_EN_F8, 0x41 }, + { KEY_EN_F9, 0x42 }, + { KEY_EN_F10, 0x43 }, + { KEY_EN_F11, 0x44 }, + { KEY_EN_F12, 0x45 }, + { KEY_EN_PRINT_SCREEN, 0x46 }, + { KEY_EN_SCROLL_LOCK, 0x47 }, + { KEY_EN_PAUSE_BREAK, 0x48 }, + { KEY_EN_INSERT, 0x49 }, + { KEY_EN_HOME, 0x4A }, + { KEY_EN_PAGE_UP, 0x4B }, + { KEY_EN_DELETE, 0x4C }, + { KEY_EN_END, 0x4D }, + { KEY_EN_PAGE_DOWN, 0x4E }, + { KEY_EN_RIGHT_ARROW, 0x4F }, + { KEY_EN_LEFT_ARROW, 0x50 }, + { KEY_EN_DOWN_ARROW, 0x51 }, + { KEY_EN_UP_ARROW, 0x52 }, + { KEY_EN_NUMPAD_LOCK, 0x53 }, + { KEY_EN_NUMPAD_DIVIDE, 0x54 }, + { KEY_EN_NUMPAD_TIMES, 0x55 }, + { KEY_EN_NUMPAD_MINUS, 0x56 }, + { KEY_EN_NUMPAD_PLUS, 0x57 }, + { KEY_EN_NUMPAD_ENTER, 0x58 }, + { KEY_EN_NUMPAD_1, 0x59 }, + { KEY_EN_NUMPAD_2, 0x5A }, + { KEY_EN_NUMPAD_3, 0x5B }, + { KEY_EN_NUMPAD_4, 0x5C }, + { KEY_EN_NUMPAD_5, 0x5D }, + { KEY_EN_NUMPAD_6, 0x5E }, + { KEY_EN_NUMPAD_7, 0x5F }, + { KEY_EN_NUMPAD_8, 0x60 }, + { KEY_EN_NUMPAD_9, 0x61 }, + { KEY_EN_NUMPAD_0, 0x62 }, + { KEY_EN_NUMPAD_PERIOD, 0x63 }, + { KEY_EN_ISO_BACK_SLASH, 0x64 }, /* ISO */ + { KEY_JP_RO, 0x87 }, /* International1: JIS Ro, ABNT2 slash */ + { "Keypad Comma", 0x85 }, /* ABNT2 / JIS numpad comma */ + { KEY_EN_MENU, 0x65 }, + { KEY_EN_LEFT_CONTROL, 0xE0 }, + { KEY_EN_LEFT_SHIFT, 0xE1 }, + { KEY_EN_LEFT_ALT, 0xE2 }, + { KEY_EN_LEFT_WINDOWS, 0xE3 }, + { KEY_EN_RIGHT_CONTROL, 0xE4 }, + { KEY_EN_RIGHT_SHIFT, 0xE5 }, + { KEY_EN_RIGHT_ALT, 0xE6 }, + { KEY_EN_RIGHT_WINDOWS, 0xE7 }, + { KEY_EN_RIGHT_FUNCTION, 0xE7 }, /* boards that place Fn where Right GUI sits (G Pro) */ +}; + +/*---------------------------------------------------------*\ +| Reverse lookup over hidpp20_key_name_to_usage: HID usage | +| code -> key name, or nullptr when the code is not a | +| standard keyboard key (a vendor-local logo / G-key / | +| indicator id). | +\*---------------------------------------------------------*/ +static const char* hidpp20_key_name_for_usage(unsigned int usage) +{ + for(const std::pair& kv : hidpp20_key_name_to_usage) + { + if(kv.second == usage) + { + return kv.first.c_str(); + } + } + + return nullptr; +} + +/*---------------------------------------------------------*\ +| ABNT2 wire overrides for the 0x8080 keyboard build. KLM's | +| ABNT2 overlay inserts the bottom-row ; key, which sits at | +| the ANSI / position (usage 0x38), and leaves / next to | +| right shift, whose lighting id is NOT its input usage: | +| the key types International1 (0x87) but lights on 0x88 | +| (G512 ABNT2, hardware-verified: 0x88 is what fn2 | +| enumerates and what SetKeyColors paints; 0x87 controls | +| nothing). Values carry (keyType << 8) | keyId and land | +| in the GetKeyValueAt branch like the strip keys. | +\*---------------------------------------------------------*/ +static const layout_values hidpp20_kb_8080_values = +{ + {}, + { + { + KEYBOARD_LAYOUT_ABNT2, + { + { 0, 4, 11, 0x0138, KEY_EN_SEMICOLON, KEY_EN_UNUSED, KEYBOARD_OPCODE_SWAP_ONLY }, + { 0, 4, 12, 0x0188, KEY_EN_FORWARD_SLASH, KEY_EN_UNUSED, KEYBOARD_OPCODE_SWAP_ONLY }, + }, + }, + }, +}; + /*---------------------------------------------------------*\ | Mouse LED layout table | | Each entry defines a matrix layout for a known mouse. | @@ -315,10 +643,10 @@ static const char* g502x_led_names[] = static const MouseLayout known_mouse_layouts[] = { { "G502 X", 3, 7, 8, g502x_map, g502x_led_names }, - /*-------------------------------------------------------*\ - | Add new mice here: | - | { "G PRO X", rows, cols, count, map_ptr, names_ptr }, | - \*-------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Add new mice here: | + | { "G PRO X", rows, cols, count, map_ptr, names_ptr }, | + \*-----------------------------------------------------*/ { nullptr, 0, 0, 0, nullptr, nullptr } }; @@ -335,6 +663,198 @@ static const MouseLayout* FindMouseLayout(const std::string& device_name) return nullptr; } +/*---------------------------------------------------------*\ +| 0x0620 headset physical layout: firmware reports zone IDs | +| but no geometry, so this places known devices' IDs onto | +| earcup matrices (mirrors Solaar). Keyed by firmware PID. | +| Centurion device names are unreliable. Unknown headsets | +| fall back to one LED per zone. | +\*---------------------------------------------------------*/ +struct HeadsetEarcup +{ + const char* name; /* OpenRGB zone name */ + unsigned int rows; + unsigned int cols; + unsigned int led_count; + const unsigned int* map; /* zone-relative LED indices (rows*cols) */ + const unsigned char* zone_ids; /* firmware zone ID per LED, in LED order*/ +}; + +struct HeadsetLayout +{ + uint16_t pid; /* matches pid_wireless or pid_wired */ + unsigned int earcup_count; + const HeadsetEarcup* earcups; +}; + +/*---------------------------------------------------------*\ +| G522: 8 LEDs, two 2x2 earcup grids (outer view): | +| Left 8 7 / 4 3 Right 6 5 / 2 1 | +| LEDs are listed in grid reading order (TL, TR, BL, BR) so | +| each earcup's matrix map is the identity {0,1,2,3}. | +\*---------------------------------------------------------*/ +static const unsigned int g522_earcup_map[2 * 2] = { 0, 1, 2, 3 }; +static const unsigned char g522_left_zone_ids[4] = { 8, 7, 4, 3 }; +static const unsigned char g522_right_zone_ids[4] = { 6, 5, 2, 1 }; + +static const HeadsetEarcup g522_earcups[] = +{ + { "Left Earcup", 2, 2, 4, g522_earcup_map, g522_left_zone_ids }, + { "Right Earcup", 2, 2, 4, g522_earcup_map, g522_right_zone_ids }, +}; + +static const HeadsetLayout known_headset_layouts[] = +{ + { 0x0B18, 2, g522_earcups }, /* G522 LIGHTSPEED */ + { 0x0000, 0, nullptr } +}; + +static const HeadsetLayout* FindHeadsetLayout(uint16_t pid_wireless, uint16_t pid_wired) +{ + for(const HeadsetLayout* hl = known_headset_layouts; hl->earcups != nullptr; hl++) + { + if(hl->pid == pid_wireless || hl->pid == pid_wired) + { + return hl; + } + } + + return nullptr; +} + +/*---------------------------------------------------------*\ +| 0x8080 special-key strips: the model-specific keys a | +| board carries beyond the standard main block, placed | +| over the KLM-generated matrix (which supplies the main | +| block per the 0x4540 layout). keyboard_led.value carries | +| the wire address (keyType << 8) | keyId, read back via | +| GetKeyValueAt. The main block resolves by key name to | +| its HID usage, so regional frames need no per-model data. | +| full_size supplies the frame when fn2 enumeration is | +| unavailable (has_numpad needs it). | +\*---------------------------------------------------------*/ +struct Kb8080Strip +{ + uint16_t pid; + bool full_size; + const keyboard_led* keys; + size_t key_count; + + /*-----------------------------------------------------*\ + | Studied board: the firmware enumerates a canonical | + | id superset and everything physically present is | + | claimed by the matrix (KLM adapts per 0x4540) or the | + | strip, so unclaimed ids are phantoms. Suppressed | + | unless the plugin's Show Unmapped override is on. | + \*-----------------------------------------------------*/ + bool suppress_unclaimed; +}; + +/*---------------------------------------------------------*\ +| G810/G610 shared strip: logo, indicator row and Mute | +| above the F-row, media block right of Pause. Positions | +| follow the legacy matrix. | +\*---------------------------------------------------------*/ +static const keyboard_led g810_top_strip[] = +{ + { 0, 0, 0, 0x1001, "Logo", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_ROW }, + { 0, 0, 12, 0x4005, "Num Lock Indicator", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, + { 0, 0, 13, 0x4003, "Caps Lock Indicator", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, + { 0, 0, 14, 0x4004, "Scroll Lock Indicator", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, + { 0, 0, 16, 0x4002, "Game Mode", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, + { 0, 0, 18, 0x4001, "Lighting", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, + { 0, 0, 19, 0x02E2, KEY_EN_MEDIA_MUTE, KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, + { 0, 1, 17, 0x02CD, KEY_EN_MEDIA_PLAY_PAUSE, KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, + { 0, 1, 18, 0x02B7, KEY_EN_MEDIA_STOP, KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, + { 0, 1, 19, 0x02B6, KEY_EN_MEDIA_PREVIOUS, KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, + { 0, 1, 20, 0x02B5, KEY_EN_MEDIA_NEXT, KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, +}; + +/*---------------------------------------------------------*\ +| G Pro (wired TKL): logo and indicator row above the | +| F-row, no media keys or Num Lock indicator. | +\*---------------------------------------------------------*/ +static const keyboard_led gpro_top_strip[] = +{ + { 0, 0, 0, 0x1001, "Logo", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_ROW }, + { 0, 0, 12, 0x4003, "Caps Lock Indicator", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, + { 0, 0, 13, 0x4004, "Scroll Lock Indicator", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, + { 0, 0, 15, 0x4002, "Game Mode", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, + { 0, 0, 16, 0x4001, "Lighting", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, +}; + +/*---------------------------------------------------------*\ +| G512: two indicator LEDs in the F-row line above the | +| numpad, Caps Lock above Num Lock and Game Mode above | +| numpad /. Ids match the G810 family; positions and ids | +| hardware-reported (G512 ABNT2 tester). No logo, media | +| or lock-state row on this board. | +\*---------------------------------------------------------*/ +static const keyboard_led g512_top_strip[] = +{ + { 0, 0, 17, 0x4003, "Caps Lock Indicator", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, + { 0, 0, 18, 0x4002, "Game Mode Indicator", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, +}; + +/*---------------------------------------------------------*\ +| G910: G6-G9 in a row directly above F1-F4 (photo- | +| verified), Logo left of Esc in the F row, G1-G5 down the | +| left edge, Nameplate on the front edge. Wire ids from | +| the legacy G910 map. Deliberately absent, as in the | +| legacy map: the media cluster above the numpad (on/off | +| backlight, not RGB; the firmware still advertises media | +| keyType 0x02, see the C32B quirk) and the four small | +| mode keys left of G6, which nothing has identified yet. | +| If firmware enumerates any of these they surface in | +| Extras (suppression is off for this board). | +\*---------------------------------------------------------*/ +static const keyboard_led g910_side_strip[] = +{ + { 0, 0, 3, 0x0406, "Key: G6", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_ROW }, + { 0, 0, 4, 0x0407, "Key: G7", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, + { 0, 0, 5, 0x0408, "Key: G8", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, + { 0, 0, 6, 0x0409, "Key: G9", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, + { 0, 1, 0, 0x1001, "Logo", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, + { 0, 2, 0, 0x0401, "Key: G1", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, + { 0, 3, 0, 0x0402, "Key: G2", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, + { 0, 4, 0, 0x0403, "Key: G3", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, + { 0, 5, 0, 0x0404, "Key: G4", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, + { 0, 6, 0, 0x0405, "Key: G5", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, + { 0, 7, 3, 0x1002, "Nameplate", KEY_EN_UNUSED, KEYBOARD_OPCODE_INSERT_SHIFT_RIGHT }, +}; + +/*---------------------------------------------------------*\ +| An entry with no keys still marks a known 0x8080 board: | +| it keeps per-key usable and the main block claimable | +| when fn2 enumeration is unavailable, and supplies the | +| frame size. | +\*---------------------------------------------------------*/ +static const Kb8080Strip known_kb_8080_strips[] = +{ + { 0xC331, true, g810_top_strip, 11, false }, /* G810 */ + { 0xC337, true, g810_top_strip, 11, false }, /* G810 */ + { 0xC333, true, g810_top_strip, 11, false }, /* G610 */ + { 0xC338, true, g810_top_strip, 11, false }, /* G610 */ + { 0xC342, true, g512_top_strip, 2, true }, /* G512 */ + { 0xC33C, true, g512_top_strip, 2, true }, /* G512 RGB */ + { 0xC32B, true, g910_side_strip, 11, false }, /* G910 Orion Spark */ + { 0xC335, true, g910_side_strip, 11, false }, /* G910 */ + { 0xC339, false, gpro_top_strip, 5, false }, /* G Pro */ +}; + +static const Kb8080Strip* FindKb8080Strip(uint16_t pid_wired, uint16_t pid_wireless) +{ + for(const Kb8080Strip& s : known_kb_8080_strips) + { + if(s.pid == pid_wired || s.pid == pid_wireless) + { + return(&s); + } + } + + return(nullptr); +} + RGBController_LogitechHIDPP20::RGBController_LogitechHIDPP20(LogitechHIDPP20Controller* controller_ptr) { controller = controller_ptr; @@ -353,6 +873,9 @@ RGBController_LogitechHIDPP20::RGBController_LogitechHIDPP20(LogitechHIDPP20Cont case LOGITECH_DEVICE_TYPE_KEYBOARD: type = DEVICE_TYPE_KEYBOARD; break; + case LOGITECH_DEVICE_TYPE_NUMPAD: + type = DEVICE_TYPE_KEYPAD; + break; case LOGITECH_DEVICE_TYPE_MOUSE: case LOGITECH_DEVICE_TYPE_TRACKBALL: type = DEVICE_TYPE_MOUSE; @@ -363,18 +886,31 @@ RGBController_LogitechHIDPP20::RGBController_LogitechHIDPP20(LogitechHIDPP20Cont case LOGITECH_DEVICE_TYPE_MOUSEPAD: type = DEVICE_TYPE_MOUSEMAT; break; + case LOGITECH_DEVICE_TYPE_JOYSTICK: + case LOGITECH_DEVICE_TYPE_GAMEPAD: + type = DEVICE_TYPE_GAMEPAD; + break; + case LOGITECH_DEVICE_TYPE_SPEAKER: + type = DEVICE_TYPE_SPEAKER; + break; + case LOGITECH_DEVICE_TYPE_MICROPHONE: + type = DEVICE_TYPE_MICROPHONE; + break; + case LOGITECH_DEVICE_TYPE_LIGHT: + type = DEVICE_TYPE_LIGHT; + break; default: type = DEVICE_TYPE_UNKNOWN; break; } - /*----------------------------------------------------------*\ - | Build mode list from discovered capabilities | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Build mode list from discovered capabilities | + \*-----------------------------------------------------*/ - /*----------------------------------------------------------*\ - | Direct mode: per-key control via 0x8081 | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Direct mode: per-key control via 0x8081 | + \*-----------------------------------------------------*/ if(caps.has_perkey) { mode Direct; @@ -385,9 +921,39 @@ RGBController_LogitechHIDPP20::RGBController_LogitechHIDPP20(LogitechHIDPP20Cont modes.push_back(Direct); } - /*----------------------------------------------------------*\ - | Off mode: always available | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Direct mode: per-key control via 0x8080, a | + | separate feature. | + \*-----------------------------------------------------*/ + if(PerKey8080Capable()) + { + mode Direct; + Direct.name = "Direct"; + Direct.value = 0; + Direct.flags = MODE_FLAG_HAS_PER_LED_COLOR; + Direct.color_mode = MODE_COLORS_PER_LED; + modes.push_back(Direct); + } + + /*-----------------------------------------------------*\ + | Direct mode: zone-only devices (G560, G810 | + | family). One LED per cluster, colors ride | + | per-zone Static writes. | + \*-----------------------------------------------------*/ + if(caps.has_zone_effects && !caps.has_perkey && !PerKey8080Capable() + && !caps.is_headset_rgb_hostmode) + { + mode Direct; + Direct.name = "Direct"; + Direct.value = 0; + Direct.flags = MODE_FLAG_HAS_PER_LED_COLOR; + Direct.color_mode = MODE_COLORS_PER_LED; + modes.push_back(Direct); + } + + /*-----------------------------------------------------*\ + | Off mode: always available | + \*-----------------------------------------------------*/ { mode Off; Off.name = "Off"; @@ -397,12 +963,12 @@ RGBController_LogitechHIDPP20::RGBController_LogitechHIDPP20(LogitechHIDPP20Cont modes.push_back(Off); } - /*----------------------------------------------------------*\ - | 0x0620 Headset RGB Hostmode has no effect cards. Provide | - | a single Direct mode that maps every LED to the frame | - | buffer; SetHeadsetRGBHostmodeColors writes them straight | - | to the earcup zones. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 0x0620 Headset RGB Hostmode has no effect cards. | + | Provide a single Direct mode that maps every LED to | + | the frame buffer; SetHeadsetRGBHostmodeColors | + | writes them straight to the earcup zones. | + \*-----------------------------------------------------*/ if(caps.is_headset_rgb_hostmode) { mode Direct; @@ -413,11 +979,11 @@ RGBController_LogitechHIDPP20::RGBController_LogitechHIDPP20(LogitechHIDPP20Cont modes.push_back(Direct); } - /*----------------------------------------------------------*\ - | Effect modes from zone cluster discovery | - | Scan effects from the first cluster (effects are usually | - | the same across clusters) | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Effect modes from zone cluster discovery | + | Scan effects from the first cluster (effects | + | are usually the same across clusters) | + \*-----------------------------------------------------*/ if(caps.has_zone_effects && !caps.zone_clusters.empty() && !caps.is_headset_rgb_hostmode) { const HIDPP20ZoneCluster& cluster = caps.zone_clusters[0]; @@ -434,13 +1000,15 @@ RGBController_LogitechHIDPP20::RGBController_LogitechHIDPP20(LogitechHIDPP20Cont Static.name = "Static"; Static.value = fx.index; - /*-----------------------------------------------------*\ - | Multi-cluster devices (mice with logo/scroll/DPI) | - | get per-LED colors so each zone can be painted | - | independently in Static. Single-cluster devices | - | (keyboards, single-zone mice) keep the single-color | - | MODE_COLORS_MODE_SPECIFIC UX. | - \*-----------------------------------------------------*/ + /*-------------------------------------*\ + | Multi-cluster devices (mice with | + | logo/scroll/DPI) get per-LED colors | + | so each zone can be painted | + | independently in Static. Single- | + | cluster devices (keyboards, single- | + | zone mice) keep the single-color | + | MODE_COLORS_MODE_SPECIFIC UX. | + \*-------------------------------------*/ if(caps.zone_clusters.size() > 1) { Static.flags = MODE_FLAG_HAS_PER_LED_COLOR; @@ -488,9 +1056,10 @@ RGBController_LogitechHIDPP20::RGBController_LogitechHIDPP20(LogitechHIDPP20Cont Breathing.brightness_max = 100; Breathing.brightness = 100; - /*-----------------------------------------------------*\ - | See Static above — multi-cluster gets per-LED colors. | - \*-----------------------------------------------------*/ + /*-------------------------------------*\ + | See Static above, multi- | + | cluster gets per-LED colors. | + \*-------------------------------------*/ if(caps.zone_clusters.size() > 1) { Breathing.flags = MODE_FLAG_HAS_PER_LED_COLOR @@ -512,6 +1081,40 @@ RGBController_LogitechHIDPP20::RGBController_LogitechHIDPP20(LogitechHIDPP20Cont break; } + case 0x0007: // Audio visualizer (G560 factory default) + { + /*-------------------------------------*\ + | Pulses brightness with audio. A | + | painted zone pulses its own | + | color; a black zone self-cycles | + | like the factory default. | + \*-------------------------------------*/ + mode Visualizer; + Visualizer.name = "Audio Visualizer"; + Visualizer.value = fx.index; + Visualizer.speed_min = HIDPP20_SPEED_SLIDER_MIN; + Visualizer.speed_max = HIDPP20_SPEED_SLIDER_MAX; + Visualizer.speed = 80; /* ~4.9s, near the 5s firmware default */ + + if(caps.zone_clusters.size() > 1) + { + Visualizer.flags = MODE_FLAG_HAS_PER_LED_COLOR + | MODE_FLAG_HAS_SPEED; + Visualizer.color_mode = MODE_COLORS_PER_LED; + } + else + { + Visualizer.flags = MODE_FLAG_HAS_MODE_SPECIFIC_COLOR + | MODE_FLAG_HAS_SPEED; + Visualizer.colors_min = 1; + Visualizer.colors_max = 1; + Visualizer.color_mode = MODE_COLORS_MODE_SPECIFIC; + Visualizer.colors.resize(1); + } + modes.push_back(Visualizer); + break; + } + case 0x0004: // Color Wave { mode Wave; @@ -554,11 +1157,12 @@ RGBController_LogitechHIDPP20::RGBController_LogitechHIDPP20(LogitechHIDPP20Cont case 0x0015: // Cycle (saturation variant) { - /*-----------------------------------------------*\ - | Saturation-bearing variant of 0x0003. Same UI | - | (speed = period, brightness = intensity); the | - | saturation byte is hardcoded full on the wire. | - \*-----------------------------------------------*/ + /*-------------------------------------*\ + | Saturation-bearing variant of 0x0003. | + | Same UI (speed = period, brightness = | + | intensity); the saturation byte is | + | hardcoded full on the wire. | + \*-------------------------------------*/ mode Cycle; Cycle.name = "Spectrum Cycle"; Cycle.value = fx.index; @@ -577,13 +1181,14 @@ RGBController_LogitechHIDPP20::RGBController_LogitechHIDPP20(LogitechHIDPP20Cont case 0x0016: // Wave (saturation variant) { - /*-----------------------------------------------*\ - | Saturation-bearing variant of 0x0004. Period | - | is a BE16 ms value on the standard 1..20s range | - | (Solaar's LEDEffects table has no period range | - | override for Wave); saturation is hardcoded on | - | the wire. | - \*-----------------------------------------------*/ + /*-------------------------------------*\ + | Saturation-bearing variant of 0x0004. | + | Period is a BE16 ms value on the | + | standard 1..20s range (Solaar's | + | LEDEffects table has no period range | + | override for Wave); saturation is | + | hardcoded on the wire. | + \*-------------------------------------*/ mode Wave; Wave.name = "Color Wave"; Wave.value = fx.index; @@ -606,12 +1211,13 @@ RGBController_LogitechHIDPP20::RGBController_LogitechHIDPP20(LogitechHIDPP20Cont case 0x0017: // Ripple (saturation variant) { - /*-----------------------------------------------*\ - | Saturation-bearing variant of 0x000B. Carries | - | color + period only — no intensity param, so | - | no brightness slider. Saturation is hardcoded | - | full on the wire. | - \*-----------------------------------------------*/ + /*-------------------------------------*\ + | Saturation-bearing variant of | + | 0x000B. Carries color + period | + | only, no intensity param, so no | + | brightness slider. Saturation | + | is hardcoded full on the wire. | + \*-------------------------------------*/ mode Ripple; Ripple.name = "Ripple"; Ripple.value = fx.index; @@ -628,22 +1234,51 @@ RGBController_LogitechHIDPP20::RGBController_LogitechHIDPP20(LogitechHIDPP20Cont break; } + case 0x000E: // Decomposition + case 0x000F: // Signature1 + case 0x0010: // Signature2 + { + /*-------------------------------------*\ + | Animated, no user color (period + | + | intensity only), per Solaar's | + | LEDEffects table. Wire param | + | offsets handled in SetZoneEffect. | + \*-------------------------------------*/ + mode Anim; + Anim.name = (fx.effect_id == 0x000E) ? "Decomposition" + : (fx.effect_id == 0x000F) ? "Signature 1" + : "Signature 2"; + Anim.value = fx.index; + Anim.flags = MODE_FLAG_HAS_SPEED + | MODE_FLAG_HAS_BRIGHTNESS; + Anim.speed_min = HIDPP20_SPEED_SLIDER_MIN; + Anim.speed_max = HIDPP20_SPEED_SLIDER_MAX; + Anim.speed = 80; + Anim.brightness_min = 1; + Anim.brightness_max = 100; + Anim.brightness = 100; + Anim.color_mode = MODE_COLORS_NONE; + modes.push_back(Anim); + break; + } + default: break; } } } - /*---------------------------------------------------------*\ - | On 0x8070 devices every effect write is ephemeral by | - | default (see DeviceUpdateMode persist branch below). Add | - | a Save button on firmware-effect modes so users can | - | explicitly commit the active mode to NVM. Direct is | - | excluded because per-key framebuffer writes don't map to | - | a savable firmware effect on 0x8070. 0x8071/0x0600 | - | already persist on every write, so no Save button is | - | exposed there pending further research. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | On 0x8070 devices every effect write is ephemeral | + | by default (see DeviceUpdateMode persist branch | + | below). Add a Save button on firmware-effect | + | modes so users can explicitly commit the active | + | mode to NVM. Direct is excluded because per-key | + | framebuffer writes don't map to a savable | + | firmware effect on 0x8070. 0x8071/0x0600 already | + | persist on every write, so no Save button is | + | exposed there pending further research. | + \*-----------------------------------------------------*/ if(caps.rgb_feature_page == HIDPP20_FEAT_COLOR_LED_EFFECTS) { for(size_t i = 0; i < modes.size(); i++) @@ -657,32 +1292,46 @@ RGBController_LogitechHIDPP20::RGBController_LogitechHIDPP20(LogitechHIDPP20Cont SetupZones(); - /*----------------------------------------------------------*\ - | Register repaint callback and start power manager. | - | The callback triggers DeviceUpdateLEDs from the power | - | thread for dim/wake when no animation is driving updates. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Register repaint callback and start power | + | manager. The callback triggers | + | DeviceUpdateLEDs from the power thread for | + | dim/wake when no animation is driving updates. | + \*-----------------------------------------------------*/ controller->SetRepaintCallback( std::bind(&RGBController_LogitechHIDPP20::OnRepaintRequest, this)); controller->SetReapplyActiveModeCallback( std::bind(&RGBController_LogitechHIDPP20::ReapplyActiveMode, this)); + + /*-----------------------------------------------------*\ + | Per-key Direct frames ride the controller's sender | + | thread so the effect engine never blocks on the wire. | + \*-----------------------------------------------------*/ + if(controller->GetCapabilities().has_perkey) + { + controller->SetPerKeyFrameSender( + std::bind(&RGBController_LogitechHIDPP20::SendPerKeyFrame, this, std::placeholders::_1)); + } } -/*---------------------------------------------------------------*\ -| Repaint callback handler (request_repaint_fn). Invoked from the | -| power thread for dim/wake when no animation is driving updates. | -\*---------------------------------------------------------------*/ +/*---------------------------------------------------------*\ +| Repaint callback handler (request_repaint_fn). | +| Invoked from the power thread for dim/wake | +| when no animation is driving updates. | +\*---------------------------------------------------------*/ void RGBController_LogitechHIDPP20::OnRepaintRequest() { - /*-------------------------------------------------*\ - | If Wake() signaled a full repaint, invalidate | - | sent_colors so DeviceUpdateLEDs pushes every zone | - | regardless of delta. Uses HIDPP20_UNCOMMITTED | - | rather than clear() so sent_colors is non-empty — | - | that avoids the first_frame / prep trigger while | - | still forcing a full push. | - \*-------------------------------------------------*/ + std::lock_guard frame_guard(controller->TransactionMutex()); + + /*-----------------------------------------------------*\ + | If Wake() signaled a full repaint, invalidate | + | sent_colors so DeviceUpdateLEDs pushes every zone | + | regardless of delta. Uses HIDPP20_UNCOMMITTED | + | rather than clear() so sent_colors is non-empty; | + | that avoids the first_frame / prep trigger while | + | still forcing a full push. | + \*-----------------------------------------------------*/ if(controller->ConsumeWakeFullRepaint()) { for(size_t i = 0; i < sent_colors.size(); i++) @@ -702,66 +1351,142 @@ RGBController_LogitechHIDPP20::~RGBController_LogitechHIDPP20() delete controller; } +/*---------------------------------------------------------*\ +| Derive the KeyboardLayoutManager size + layout from the | +| discovered capabilities. Shared by the 0x8081 and 0x8080 | +| per-key keyboard branches. Layout codes follow the 0x4540 | +| code table used by the official app. Legend-only variants | +| with no distinct physical frame stay on the ANSI default. | +\*---------------------------------------------------------*/ +static void hidpp20_derive_keyboard_layout + ( + const HIDPP20DeviceCapabilities& caps, + KEYBOARD_SIZE& kb_size, + KEYBOARD_LAYOUT& kb_layout + ) +{ + kb_size = caps.has_numpad ? KEYBOARD_SIZE_FULL : KEYBOARD_SIZE_TKL; + + switch(caps.keyboard_layout_code) + { + case 0x04: // German + case 0x0D: // Swiss + case 0x14: // Czech + case 0x19: // Hungarian + case 0x41: // Czech + kb_layout = KEYBOARD_LAYOUT_ISO_QWERTZ; + break; + + case 0x05: // French + case 0x11: // Belgian + kb_layout = KEYBOARD_LAYOUT_ISO_AZERTY; + break; + + case 0x02: // International + case 0x03: // UK + case 0x08: // Nordic + case 0x0E: // Turkish + case 0x0F: // Spanish + case 0x16: // Nordic + case 0x1A: // Italian + case 0x1D: // Nordic + case 0x1F: // Portuguese + case 0x21: // Nordic + case 0x24: // Turkish + case 0x37: // International 2 + kb_layout = KEYBOARD_LAYOUT_ISO_QWERTY; + break; + + case 0x0A: // Japanese + kb_layout = KEYBOARD_LAYOUT_JIS; + break; + + case 0x38: // Brazilian Portuguese + kb_layout = KEYBOARD_LAYOUT_ABNT2; + break; + + case 0x01: // US + default: + kb_layout = KEYBOARD_LAYOUT_ANSI_QWERTY; + break; + } +} + +void RGBController_LogitechHIDPP20::AddLayoutZone + ( + const std::string& zone_name, + zone_type type, + unsigned int rows, + unsigned int cols, + const unsigned int* map, + const std::vector>& zone_leds + ) +{ + zone new_zone; + new_zone.name = zone_name; + new_zone.type = type; + new_zone.leds_min = (unsigned int)zone_leds.size(); + new_zone.leds_max = (unsigned int)zone_leds.size(); + new_zone.leds_count = (unsigned int)zone_leds.size(); + + if(type == ZONE_TYPE_MATRIX && map != nullptr) + { + new_zone.matrix_map.Set(rows, cols, (unsigned int*)map); + } + + zones.push_back(new_zone); + + for(size_t i = 0; i < zone_leds.size(); i++) + { + led new_led; + new_led.name = zone_leds[i].first; + new_led.value = zone_leds[i].second; + leds.push_back(new_led); + + led_to_zone_id.push_back(zone_leds[i].second); + } +} + void RGBController_LogitechHIDPP20::SetupZones() { const HIDPP20DeviceCapabilities& caps = controller->GetCapabilities(); led_to_zone_id.clear(); + led_to_keytype_keyid_8080.clear(); sent_colors.clear(); - if(caps.has_perkey) + if(PerKey8080Capable()) + { + SetupZones8080(); + } + else if(caps.has_perkey) { if(caps.device_type == LOGITECH_DEVICE_TYPE_KEYBOARD) { - /*--------------------------------------------------*\ - | Keyboard: use KeyboardLayoutManager for matrix | - | layout. Derive size from numpad presence, layout | - | from 0x4540 KeyboardLayout feature. | - \*--------------------------------------------------*/ - KEYBOARD_SIZE kb_size = caps.has_numpad - ? KEYBOARD_SIZE_FULL - : KEYBOARD_SIZE_TKL; - + /*---------------------------------------------*\ + | Keyboard: use KeyboardLayoutManager for | + | matrix layout. Size from numpad presence, | + | layout from the 0x4540 feature. | + \*---------------------------------------------*/ + KEYBOARD_SIZE kb_size; KEYBOARD_LAYOUT kb_layout; - - switch(caps.keyboard_layout_code) - { - case 3: // German - case 7: // Swiss - kb_layout = KEYBOARD_LAYOUT_ISO_QWERTZ; - break; - - case 4: // French - kb_layout = KEYBOARD_LAYOUT_ISO_AZERTY; - break; - - case 2: // UK - case 5: // Spanish - case 0x0B: // Italian - case 0x0D: // Portuguese - case 0x0E: // Belgian - case 0x0F: // Scandinavian - case 8: // Nordic - case 0x16: // Nordic - case 0x1D: // Nordic - case 0x21: // Nordic - case 0x24: // Belgian - kb_layout = KEYBOARD_LAYOUT_ISO_QWERTY; - break; - - case 9: // Japanese - case 0x3E: // Japanese - kb_layout = KEYBOARD_LAYOUT_JIS; - break; - - case 1: // US - default: - kb_layout = KEYBOARD_LAYOUT_ANSI_QWERTY; - break; - } + hidpp20_derive_keyboard_layout(caps, kb_size, kb_layout); KeyboardLayoutManager klm(kb_layout, kb_size); + /*---------------------------------------------*\ + | Apply any per-board layout edit (e.g. | + | a top strip of media/logo keys above | + | the F-row). KLM inserts the keys and | + | re-derives dimensions, so the matrix | + | build below needs no change. | + \*---------------------------------------------*/ + key_set kb_layout_edit = FindKbLayoutEdit(caps.pid_wired, caps.pid_wireless); + if(!kb_layout_edit.empty()) + { + klm.ChangeKeys(kb_layout_edit); + } + zone perkey_zone; perkey_zone.name = ZONE_EN_KEYBOARD; perkey_zone.type = ZONE_TYPE_MATRIX; @@ -775,26 +1500,52 @@ void RGBController_LogitechHIDPP20::SetupZones() { led new_led; std::string key_name = klm.GetKeyNameAt(i); - new_led.name = key_name; + std::string alt_name = klm.GetKeyAltNameAt(i); - /*---------------------------------------------*\ - | Look up zone ID by key name | - \*---------------------------------------------*/ - std::map::const_iterator it = hidpp20_key_name_to_zone.find(key_name); - unsigned int zone_id = (it != hidpp20_key_name_to_zone.end()) ? it->second : 0; + /*-----------------------------------------*\ + | Display the regional legend (KLM alt | + | name) when the layout defines one; the | + | positional name below still drives the | + | wire lookup. | + \*-----------------------------------------*/ + new_led.name = (alt_name != KEY_EN_UNUSED) ? alt_name : key_name; + + /*-----------------------------------------*\ + | Zone ID: a nonzero KLM value is a | + | board- specific wire address from a | + | layout edit (e.g. renumbered top- | + | strip keys); otherwise look it up by | + | key name from the standard table. | + \*-----------------------------------------*/ + unsigned int zone_id = klm.GetKeyValueAt(i); + if(zone_id == 0) + { + std::map::const_iterator it = hidpp20_key_name_to_zone.find(key_name); + zone_id = (it != hidpp20_key_name_to_zone.end()) ? it->second : 0; + } new_led.value = zone_id; leds.push_back(new_led); led_to_zone_id.push_back((uint16_t)zone_id); } - /*-------------------------------------------------*\ - | Extras: zones the device reported via paginated | - | 0x8081 GetInfo that aren't covered by KLM. On a | - | G915 this is media keys, G1-G5, brightness, and | - | logo. Append them as a separate linear zone so | - | users can still address them. | - \*-------------------------------------------------*/ + /*---------------------------------------------*\ + | Classify every reported zone the matrix did | + | not claim. Zone ids index the firmware's | + | fixed enumeration (superset reported, subset | + | stuffed). An unclaimed zone is one of: | + | - a reserved protocol id (>= 0xFE) | + | - a phantom this board misreports (per-PID) | + | - a named extra no KLM layout places | + | (media, G-keys, logo) | + | - a defined position this board does not | + | stuff (ISO on ANSI, numpad on TKL, F13-F15) | + | - unknown: surfaced in an Unmapped zone and | + | logged, so an unstudied board stays | + | controllable and a tester can report which | + | key lights; a report becomes a name or a | + | layout edit. | + \*---------------------------------------------*/ std::set klm_claimed_zones; for(uint16_t zid : led_to_zone_id) { @@ -804,18 +1555,22 @@ void RGBController_LogitechHIDPP20::SetupZones() } } - /*---------------------------------------------------*\ - | Extras candidates: zones reported by the device | - | that aren't claimed by KLM AND have a known name | - | in hidpp20_key_name_to_zone. We deliberately DROP | - | unnamed zones — firmware-side GetInfo bitmaps | - | enumerate phantom/reserved slots (G515 reports | - | 47, 97, 99-103, 254 among others) that aren't | - | wired to physical LEDs. Exposing them as "LED N" | - | created ghost entries in the GUI. Treat | - | hidpp20_key_name_to_zone as the curated allowlist. | - \*---------------------------------------------------*/ + const KbZonePolicy* zone_policy = FindKbZonePolicy(caps.pid_wired, caps.pid_wireless); + + /*---------------------------------------------*\ + | Plugin override: surface every | + | suppressed zone, for boards that stuff a | + | position no layout places (F13, Keypad | + | =) and for auditing policy entries. | + | Reserved ids stay hidden: 0xFF is the | + | all-keys broadcast, not an LED. | + \*---------------------------------------------*/ + LogitechHIDPP20IdleSettings::instance()->load(); + bool show_unmapped = LogitechHIDPP20IdleSettings::instance()->showUnmapped(); + std::vector> extras; + std::vector> unmapped; + for(uint16_t zid : caps.perkey_zone_ids) { if(klm_claimed_zones.count(zid) != 0) @@ -823,25 +1578,49 @@ void RGBController_LogitechHIDPP20::SetupZones() continue; } - std::string label; - for(const std::pair& kv : hidpp20_key_name_to_zone) + if(zid >= 0xFE) { - if(kv.second == zid) - { - label = kv.first; - break; - } - } - - if(label.empty()) - { - LOG_DEBUG("[LogitechHID++2.0 %s] Dropping unnamed per-key zone %u " - "(not in hidpp20_key_name_to_zone)", + LOG_DEBUG("[LogitechHID++2.0 %s] zone %u is a reserved protocol id", name.c_str(), (unsigned)zid); continue; } - extras.emplace_back(zid, label); + if(zone_policy != nullptr && + (zone_policy->suppress_unclaimed || zone_policy->excluded.count(zid) != 0)) + { + if(!show_unmapped) + { + LOG_DEBUG("[LogitechHID++2.0 %s] zone %u is a known phantom on this board", + name.c_str(), (unsigned)zid); + continue; + } + + unmapped.emplace_back(zid, "LED " + std::to_string(zid)); + continue; + } + + std::map::const_iterator xn = hidpp20_extras_names.find(zid); + if(xn != hidpp20_extras_names.end()) + { + extras.emplace_back(zid, xn->second); + continue; + } + + std::string position = HIDPP20ZonePositionName(zid); + if(!position.empty()) + { + if(!show_unmapped) + { + LOG_DEBUG("[LogitechHID++2.0 %s] zone %u (%s) is a defined position this board does not stuff", + name.c_str(), (unsigned)zid, position.c_str()); + continue; + } + + unmapped.emplace_back(zid, position); + continue; + } + + unmapped.emplace_back(zid, "LED " + std::to_string(zid)); } if(!extras.empty()) @@ -863,94 +1642,132 @@ void RGBController_LogitechHIDPP20::SetupZones() led_to_zone_id.push_back(extras[i].first); } } + + if(!unmapped.empty()) + { + std::string id_list; + for(const std::pair& um : unmapped) + { + if(!id_list.empty()) + { + id_list += " "; + } + id_list += std::to_string(um.first); + } + + LOG_INFO("[LogitechHID++2.0 %s] %zu unmapped per-key zone(s): %s, if coloring one " + "of these lights a key, please report which key it is (with PID %04X)", + name.c_str(), unmapped.size(), id_list.c_str(), caps.pid_wired); + + zone unmapped_zone; + unmapped_zone.name = "Unmapped"; + unmapped_zone.type = ZONE_TYPE_LINEAR; + unmapped_zone.leds_min = (unsigned int)unmapped.size(); + unmapped_zone.leds_max = (unsigned int)unmapped.size(); + unmapped_zone.leds_count = (unsigned int)unmapped.size(); + zones.push_back(unmapped_zone); + + for(const std::pair& um : unmapped) + { + led new_led; + new_led.name = um.second; + new_led.value = um.first; + leds.push_back(new_led); + led_to_zone_id.push_back(um.first); + } + } } else if(const MouseLayout* ml = FindMouseLayout(caps.device_name)) { - /*--------------------------------------------------*\ - | Known mouse: use table-defined matrix layout | - \*--------------------------------------------------*/ - zone perkey_zone; - perkey_zone.name = "Mouse LEDs"; - perkey_zone.type = ZONE_TYPE_MATRIX; - perkey_zone.leds_min = ml->led_count; - perkey_zone.leds_max = ml->led_count; - perkey_zone.leds_count = ml->led_count; - perkey_zone.matrix_map.Set(ml->rows, ml->cols, (unsigned int*)ml->map); - zones.push_back(perkey_zone); - + /*---------------------------------------------*\ + | Known mouse: use table-defined matrix layout | + \*---------------------------------------------*/ + std::vector> mouse_leds; for(unsigned int i = 0; i < ml->led_count && i < caps.perkey_zone_ids.size(); i++) { - led new_led; - new_led.name = ml->led_names[i]; - new_led.value = caps.perkey_zone_ids[i]; - leds.push_back(new_led); - - led_to_zone_id.push_back(caps.perkey_zone_ids[i]); + mouse_leds.push_back(std::make_pair(std::string(ml->led_names[i]), + (uint16_t)caps.perkey_zone_ids[i])); } + AddLayoutZone("Mouse LEDs", ZONE_TYPE_MATRIX, ml->rows, ml->cols, ml->map, mouse_leds); } else { - /*-------------------------------------------------*\ - | Other devices: linear zone with auto-named LEDs | - \*-------------------------------------------------*/ - zone perkey_zone; - perkey_zone.name = "LEDs"; - perkey_zone.type = ZONE_TYPE_LINEAR; - perkey_zone.leds_min = (unsigned int)caps.perkey_zone_ids.size(); - perkey_zone.leds_max = (unsigned int)caps.perkey_zone_ids.size(); - perkey_zone.leds_count = (unsigned int)caps.perkey_zone_ids.size(); - zones.push_back(perkey_zone); - + /*---------------------------------------------*\ + | Other devices: linear zone, auto-named LEDs | + \*---------------------------------------------*/ + std::vector> other_leds; for(size_t i = 0; i < caps.perkey_zone_ids.size(); i++) { - led new_led; - new_led.name = "LED " + std::to_string(caps.perkey_zone_ids[i]); - new_led.value = caps.perkey_zone_ids[i]; - leds.push_back(new_led); - - led_to_zone_id.push_back(caps.perkey_zone_ids[i]); + other_leds.push_back(std::make_pair("LED " + std::to_string(caps.perkey_zone_ids[i]), + (uint16_t)caps.perkey_zone_ids[i])); } + AddLayoutZone("LEDs", ZONE_TYPE_LINEAR, 0, 0, nullptr, other_leds); } } else if(caps.is_headset_rgb_hostmode) { - /*------------------------------------------------------*\ - | Headset RGB hostmode (0x0620): single linear zone with | - | one LED per discovered earcup zone ID. | - \*------------------------------------------------------*/ - size_t led_count = caps.headset_rgb_hostmode_zone_ids.size(); + const HeadsetLayout* hl = FindHeadsetLayout(caps.pid_wireless, caps.pid_wired); - zone headset_zone; - headset_zone.name = "Headset"; - headset_zone.type = ZONE_TYPE_LINEAR; - headset_zone.leds_min = (unsigned int)led_count; - headset_zone.leds_max = (unsigned int)led_count; - headset_zone.leds_count = (unsigned int)led_count; - zones.push_back(headset_zone); - - for(size_t i = 0; i < led_count; i++) + unsigned int layout_leds = 0; + if(hl != nullptr) { - led new_led; - new_led.name = (i == 0) ? "Left Earcup" - : (i == 1) ? "Right Earcup" - : "Zone " + std::to_string(i); - new_led.value = caps.headset_rgb_hostmode_zone_ids[i]; - leds.push_back(new_led); + for(unsigned int e = 0; e < hl->earcup_count; e++) + { + layout_leds += hl->earcups[e].led_count; + } + } - led_to_zone_id.push_back(caps.headset_rgb_hostmode_zone_ids[i]); + if(hl != nullptr && caps.headset_rgb_hostmode_zone_ids.size() == layout_leds) + { + /*---------------------------------------------*\ + | Known headset: one matrix zone per earcup | + | on the physical 2x2 grid; LED value = the | + | firmware zone ID the write path addresses. | + | A zone-count mismatch (firmware variant) | + | falls through to the generic path. | + \*---------------------------------------------*/ + for(unsigned int e = 0; e < hl->earcup_count; e++) + { + const HeadsetEarcup& ec = hl->earcups[e]; + + std::vector> earcup_leds; + for(unsigned int i = 0; i < ec.led_count; i++) + { + earcup_leds.push_back(std::make_pair("LED " + std::to_string(ec.zone_ids[i]), + (uint16_t)ec.zone_ids[i])); + } + AddLayoutZone(ec.name, ZONE_TYPE_MATRIX, ec.rows, ec.cols, ec.map, earcup_leds); + } + } + else + { + /*---------------------------------------------*\ + | Unknown 0x0620 headset: no geometry | + | table, so a single linear zone with | + | one LED per discovered zone ID. | + \*---------------------------------------------*/ + std::vector> headset_leds; + for(size_t i = 0; i < caps.headset_rgb_hostmode_zone_ids.size(); i++) + { + headset_leds.push_back(std::make_pair("LED " + std::to_string(caps.headset_rgb_hostmode_zone_ids[i]), + (uint16_t)caps.headset_rgb_hostmode_zone_ids[i])); + } + AddLayoutZone("Headset", ZONE_TYPE_LINEAR, 0, 0, nullptr, headset_leds); } } else if(caps.has_zone_effects) { - /*------------------------------------------------------*\ - | No per-key: create one zone per cluster | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | No per-key: create one zone per cluster | + \*-------------------------------------------------*/ for(size_t i = 0; i < caps.zone_clusters.size(); i++) { const HIDPP20ZoneCluster& cluster = caps.zone_clusters[i]; zone new_zone; - new_zone.name = zone_location_name(cluster.location); + new_zone.name = (caps.device_type == LOGITECH_DEVICE_TYPE_SPEAKER) + ? speaker_zone_location_name(cluster.location) + : zone_location_name(cluster.location); new_zone.type = ZONE_TYPE_SINGLE; new_zone.leds_min = 1; new_zone.leds_max = 1; @@ -966,12 +1783,13 @@ void RGBController_LogitechHIDPP20::SetupZones() } } - /*---------------------------------------------------------*\ - | Build the zone_id -> LED index reverse map. Indexed | - | 0..255 (zone IDs are bytes); -1 marks "no LED for this | - | zone". Used by the FrameEnd commit step to translate the | - | acked_zones list back into LED indices for sent_colors. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Build the zone_id -> LED index reverse map. | + | Indexed 0..255 (zone IDs are bytes); -1 marks | + | "no LED for this zone". Used by the FrameEnd | + | commit step to translate the acked_zones list | + | back into LED indices for sent_colors. | + \*-----------------------------------------------------*/ zone_id_to_led_idx.assign(256, -1); for(size_t i = 0; i < led_to_zone_id.size(); i++) @@ -986,38 +1804,387 @@ void RGBController_LogitechHIDPP20::SetupZones() SetupColors(); } +/*---------------------------------------------------------*\ +| Feature 0x8080 zone setup, fed entirely by | +| DiscoverPerKey8080(); nothing about the key set is | +| hardcoded. Keyboards get the KLM matrix with each key | +| resolved to (keyType, keyId) by its HID usage; the | +| keyboard keyType is the enumerated one whose keyIds best | +| match the HID keyboard page, not a hardcoded value. | +| Enumerated pairs the matrix does not claim go in an | +| Extras zone, named by HID usage where standard. | +\*---------------------------------------------------------*/ +bool RGBController_LogitechHIDPP20::PerKey8080Capable() const +{ + const HIDPP20DeviceCapabilities& caps = controller->GetCapabilities(); + + /*-----------------------------------------------------*\ + | 0x8080 per-key is usable if fn2 enumeration | + | confirmed keyTypes, OR the device is a known | + | keyboard model (its strip supplies the wire | + | addressing itself, so it works even when fn2 reads | + | are unavailable). | + \*-----------------------------------------------------*/ + return caps.has_perkey_8080 + || (caps.device_type == LOGITECH_DEVICE_TYPE_KEYBOARD + && caps.idx_perkey_8080 != 0 + && FindKb8080Strip(caps.pid_wired, caps.pid_wireless) != nullptr); +} + +void RGBController_LogitechHIDPP20::SetupZones8080() +{ + const HIDPP20DeviceCapabilities& caps = controller->GetCapabilities(); + + /*-----------------------------------------------------*\ + | Identify the keyboard keyType: the enumerated keyType | + | whose keyIds overlap the standard HID keyboard page | + | the most. Ties / no keyboard device -> none. | + \*-----------------------------------------------------*/ + int kb_type_index = -1; + size_t kb_best_overlap = 0; + + if(caps.device_type == LOGITECH_DEVICE_TYPE_KEYBOARD) + { + for(size_t t = 0; t < caps.perkey_8080_types.size(); t++) + { + size_t overlap = 0; + + for(uint8_t key_id : caps.perkey_8080_types[t].key_ids) + { + if(hidpp20_key_name_for_usage(key_id) != nullptr) + { + overlap++; + } + } + + if(overlap > kb_best_overlap) + { + kb_best_overlap = overlap; + kb_type_index = (int)t; + } + } + } + + /*-----------------------------------------------------*\ + | Track which (keyType, keyId) pairs the matrix claims | + | so the Extras zone can pick up everything else. | + \*-----------------------------------------------------*/ + std::set claimed; /* (keyType << 8) | keyId */ + + size_t matrix_total = 0; /* KLM keys in the matrix */ + size_t matrix_mapped = 0; /* matrix keys with a live keyId */ + + const Kb8080Strip* strip = FindKb8080Strip(caps.pid_wired, caps.pid_wireless); + + /*-----------------------------------------------------*\ + | Keyboard matrix: built when enumeration found a | + | keyboard keyType, or on a known-strip model, where | + | the main block claims unconditionally on keyType | + | 0x01 (the legacy controllers wrote every listed key | + | for years, absent keys just stay dark). | + \*-----------------------------------------------------*/ + bool have_enum = (kb_type_index >= 0); + + if(have_enum || strip != nullptr) + { + uint16_t kb_key_type = 0x0001; + std::set kb_key_ids; + + if(have_enum) + { + const HIDPP20PerKey8080Type& kb_type = caps.perkey_8080_types[kb_type_index]; + + kb_key_type = kb_type.key_type; + kb_key_ids.insert(kb_type.key_ids.begin(), kb_type.key_ids.end()); + } + + KEYBOARD_SIZE kb_size; + KEYBOARD_LAYOUT kb_layout; + hidpp20_derive_keyboard_layout(caps, kb_size, kb_layout); + + /*-------------------------------------------------*\ + | has_numpad comes from enumeration; without it | + | the strip entry supplies the frame size. | + \*-------------------------------------------------*/ + if(!have_enum && strip != nullptr) + { + kb_size = strip->full_size ? KEYBOARD_SIZE_FULL : KEYBOARD_SIZE_TKL; + } + + KeyboardLayoutManager klm(kb_layout, kb_size, hidpp20_kb_8080_values); + + if(strip != nullptr && strip->keys != nullptr) + { + klm.ChangeKeys(key_set(strip->keys, strip->keys + strip->key_count)); + } + + zone perkey_zone; + perkey_zone.name = ZONE_EN_KEYBOARD; + perkey_zone.type = ZONE_TYPE_MATRIX; + perkey_zone.leds_min = klm.GetKeyCount(); + perkey_zone.leds_max = klm.GetKeyCount(); + perkey_zone.leds_count = klm.GetKeyCount(); + + perkey_zone.matrix_map = klm.GetKeyMap(KEYBOARD_MAP_FILL_TYPE_COUNT, + (uint8_t)klm.GetRowCount(), + (uint8_t)klm.GetColumnCount()); + zones.push_back(perkey_zone); + + matrix_total = klm.GetKeyCount(); + + for(unsigned int i = 0; i < klm.GetKeyCount(); i++) + { + std::string key_name = klm.GetKeyNameAt(i); + std::string alt_name = klm.GetKeyAltNameAt(i); + + led new_led; + + /*---------------------------------------------*\ + | Display the regional legend (KLM alt name) | + | when the layout defines one; key_name stays | + | the positional name and drives the wire | + | lookup below. | + \*---------------------------------------------*/ + new_led.name = (alt_name != KEY_EN_UNUSED) ? alt_name : key_name; + + uint16_t key_type = 0; + uint8_t key_id = 0; + + /*---------------------------------------------*\ + | A nonzero KLM value is a strip key carrying | + | its wire address (keyType << 8) | keyId. | + | Main-block keys resolve by name to their HID | + | usage; with enumeration the keyId must be | + | one the device reported, on a known strip | + | model without enumeration every resolved key | + | is claimed. | + \*---------------------------------------------*/ + unsigned int strip_value = klm.GetKeyValueAt(i); + + if(strip_value != 0) + { + key_type = (uint16_t)(strip_value >> 8); + key_id = (uint8_t)(strip_value & 0xFF); + claimed.insert(strip_value); + matrix_mapped++; + } + else + { + std::map::const_iterator it = hidpp20_key_name_to_usage.find(key_name); + + if(it != hidpp20_key_name_to_usage.end() + && (have_enum ? kb_key_ids.count((uint8_t)it->second) != 0 : true)) + { + key_type = kb_key_type; + key_id = (uint8_t)it->second; + claimed.insert(((uint32_t)key_type << 8) | key_id); + matrix_mapped++; + } + } + + new_led.value = ((uint32_t)key_type << 8) | key_id; + leds.push_back(new_led); + led_to_keytype_keyid_8080.emplace_back(key_type, key_id); + } + } + + /*-----------------------------------------------------*\ + | Extras: every enumerated (keyType, keyId) the matrix | + | did not claim. Named by HID usage where the keyId is | + | a standard keyboard code, generically otherwise. | + \*-----------------------------------------------------*/ + std::vector> extras; + + /*-----------------------------------------------------*\ + | Known model without enumeration: keep the off-frame | + | backslash keys addressable (the ported maps listed | + | ANSI backslash, POUND and ISO backslash together and | + | let the absent ones sit dark). With enumeration the | + | loop below picks these up from the device instead. | + \*-----------------------------------------------------*/ + if(!have_enum && strip != nullptr) + { + static const uint8_t frame_usages[] = { 0x31, 0x32, 0x64 }; + + for(uint8_t usage : frame_usages) + { + if(claimed.count(((uint32_t)0x0001 << 8) | usage) == 0) + { + extras.emplace_back(0x0001, usage); + } + } + } + + /*-----------------------------------------------------*\ + | Studied-board phantom mask (G512: the firmware | + | reports all five international ids 0x87-0x8B and | + | the full G810 indicator set while only the ABNT2 | + | slash and two indicators are wired). Plugin | + | override surfaces them anyway for auditing new | + | variants (a JIS board may need id probing like the | + | ABNT2 slash, which lights on 0x88 not 0x87). | + \*-----------------------------------------------------*/ + LogitechHIDPP20IdleSettings::instance()->load(); + bool suppress_unclaimed = (strip != nullptr) + && strip->suppress_unclaimed + && !LogitechHIDPP20IdleSettings::instance()->showUnmapped(); + + for(size_t t = 0; t < caps.perkey_8080_types.size(); t++) + { + uint16_t key_type = caps.perkey_8080_types[t].key_type; + + for(uint8_t key_id : caps.perkey_8080_types[t].key_ids) + { + uint32_t pair_key = ((uint32_t)key_type << 8) | key_id; + + if(claimed.count(pair_key) != 0) + { + continue; + } + + if(suppress_unclaimed) + { + LOG_DEBUG("[LogitechHID++2.0 %s] 0x8080 keyType 0x%04X keyId 0x%02X " + "is a known phantom on this board", + name.c_str(), key_type, key_id); + continue; + } + + extras.emplace_back(key_type, key_id); + } + } + + if(!extras.empty()) + { + zone extras_zone; + extras_zone.name = "Extras"; + extras_zone.type = ZONE_TYPE_LINEAR; + extras_zone.leds_min = (unsigned int)extras.size(); + extras_zone.leds_max = (unsigned int)extras.size(); + extras_zone.leds_count = (unsigned int)extras.size(); + extras_zone.matrix_map.Reset(); + zones.push_back(extras_zone); + + for(size_t i = 0; i < extras.size(); i++) + { + uint16_t key_type = extras[i].first; + uint8_t key_id = extras[i].second; + const char* std_name = hidpp20_key_name_for_usage(key_id); + + led new_led; + + if(std_name != nullptr) + { + new_led.name = std_name; + } + else + { + char label[32]; + snprintf(label, sizeof(label), "Zone 0x%04X Key 0x%02X", + key_type, key_id); + new_led.name = label; + } + + new_led.value = ((uint32_t)key_type << 8) | key_id; + leds.push_back(new_led); + led_to_keytype_keyid_8080.emplace_back(key_type, key_id); + } + } + + /*-----------------------------------------------------*\ + | One-shot summary of how the build mapped onto the | + | UI. If the structure dump looked right but nothing | + | lights, this line localises the break: no keyboard | + | keyType, zero matrix keys mapped, or everything in | + | Extras all point at distinct faults. | + \*-----------------------------------------------------*/ + if(have_enum || strip != nullptr) + { + LOG_DEBUG("[LogitechHID++2.0 %s] 0x8080 zones: keyboard keyType 0x%04X%s%s, " + "%zu/%zu matrix keys mapped, %zu extra LED(s)", + name.c_str(), + have_enum ? caps.perkey_8080_types[kb_type_index].key_type : 0x0001, + have_enum ? "" : " (no enumeration, known-model claim)", + (strip != nullptr) ? ", strip applied" : "", + matrix_mapped, matrix_total, extras.size()); + } + else + { + LOG_DEBUG("[LogitechHID++2.0 %s] 0x8080 zones: no keyboard keyType " + "identified, %zu extra LED(s) only", + name.c_str(), extras.size()); + } +} + void RGBController_LogitechHIDPP20::DeviceUpdateLEDs() { + /*-----------------------------------------------------*\ + | Don't paint while the controller is | + | rediscovering its map: has_perkey and the | + | zone/effect data flap false, so a paint here | + | claims into an empty per-key layer. The effect | + | engine repaints next tick once discovery ends. | + \*-----------------------------------------------------*/ + if(controller->DiscoveryInProgress()) + { + return; + } + + /*-----------------------------------------------------*\ + | Per-key Direct rides the sender thread: snapshot, | + | hand over, return, the effect engine owns | + | colors[] and is never throttled by the wire. | + | Checked before the transaction guard so a frame | + | in flight cannot block this thread either. | + \*-----------------------------------------------------*/ + { + const HIDPP20DeviceCapabilities& caps_pre = controller->GetCapabilities(); + + if(caps_pre.has_perkey && !caps_pre.is_headset_rgb_hostmode && !PerKey8080Capable() && + (unsigned int)active_mode < modes.size() && + modes[active_mode].color_mode == MODE_COLORS_PER_LED && + controller->HasPerKeyFrameSender()) + { + controller->SubmitPerKeyFrame(colors); + return; + } + } + + std::lock_guard frame_guard(controller->TransactionMutex()); + if(!controller->IsOnline()) { return; } - /*----------------------------------------------------------*\ - | Ensure SW control is claimed on first actual color push. | - | Safe here because we have real colors in the buffer. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Ensure SW control is claimed on the first real | + | color push, safe here because the buffer holds | + | actual colors. | + \*-----------------------------------------------------*/ controller->ClaimSWControlIfNeeded(); const HIDPP20DeviceCapabilities& caps = controller->GetCapabilities(); - /*----------------------------------------------------------*\ - | Frame handling during SLEEPING: | - | | - | Default — suppress frames. A suppressed frame cannot | - | wake a device that treats writes as activity, so this is | - | the safe choice when we don't know how a particular | - | firmware handles host traffic during its fade. | - | | - | Quirk-gated — devices flagged FADE_ACCEPTS_WRITES opt out | - | of suppression because their firmware accepts writes | - | without cancelling sleep. Frames flow through SLEEPING | - | until deep sleep starts BUSY-NACKing every FrameEnd; | - | consecutive-failure tracking then sets deep_sleep and the | - | top IsDeepSleep() check takes over. | - | | - | Both paths suppress until Wake() clears the state. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Frame handling during SLEEPING: | + | | + | Default, suppress frames. A suppressed frame | + | cannot wake a device that treats writes as | + | activity, so this is the safe choice when we | + | don't know how a particular firmware handles | + | host traffic during its fade. | + | | + | Quirk-gated, devices flagged FADE_ACCEPTS_WRITES | + | opt out of suppression because their firmware | + | accepts writes without cancelling sleep. Frames | + | flow through SLEEPING until deep sleep starts BUSY- | + | NACKing every FrameEnd; consecutive-failure | + | tracking then sets deep_sleep and the top | + | IsDeepSleep() check takes over. | + | | + | Both paths suppress until Wake() clears the state. | + \*-----------------------------------------------------*/ if(controller->IsDeepSleep()) { return; @@ -1029,475 +2196,691 @@ void RGBController_LogitechHIDPP20::DeviceUpdateLEDs() return; } - /*----------------------------------------------------------*\ - | Feature 0x0620 Headset RGB Hostmode (Centurion G522 / | - | PRO X 2). Static-color only, two earcup zones. Bypasses | - | per-key, SetZoneEffect, and effect-card paths entirely — | - | 0x0620 has none of that. Claim was made once in | - | SetHostMode(); we just write colors + FrameEnd[0x01]. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | 0x0620 headset (G522 / PRO X 2): static color | + | only, two earcup zones; bypasses the per-key, | + | zone-effect and effect-card paths (0x0620 has | + | none). Host mode is already claimed (sticky); | + | just colors + FrameEnd[0x01]. | + \*-----------------------------------------------------*/ if(caps.is_headset_rgb_hostmode) { controller->SetHeadsetRGBHostmodeColors(colors); return; } - if(caps.has_perkey && (unsigned int)active_mode < modes.size() && + /*-----------------------------------------------------*\ + | Feature 0x8080 (Per Key Lighting). Full repaint | + | grouped by keyType, then one FlushLEDS. | + | led_to_keytype_keyid_8080 (built in SetupZones8080) | + | maps each LED to its wire (keyType, keyId). | + \*-----------------------------------------------------*/ + if(PerKey8080Capable() && (unsigned int)active_mode < modes.size() && modes[active_mode].color_mode == MODE_COLORS_PER_LED) { - uint8_t perkey_idx = (caps.idx_perkey_v2 != 0) ? caps.idx_perkey_v2 : caps.idx_perkey_v1; - - /*------------------------------------------------------*\ - | Detect re-initialization (reconnect, wake from sleep). | - | Device state is unknown — force full resend. | - \*------------------------------------------------------*/ - uint32_t gen = controller->GetInitGeneration(); - - if(gen != last_init_gen) - { - sent_colors.clear(); - last_init_gen = gen; - } - - /*-------------------------------------------------------*\ - | Per-key prep call. Two paths, selected by a runtime | - | capability probe at feature-discovery time: | - | | - | (A) Observed prep via DoObservedPerKeyPrep — two | - | SetEffectByIndex calls on 0x8071 cloned from | - | the observed vendor-app wire behavior. The | - | template bytes at | - | prep1 params[6..7] and the effectIdx at prep2 are | - | parameterized from device-discovery results | - | (caps.effect_card_template[], and | - | caps.zone_clusters[0].effects.size() respectively) | - | so the same code adapts to any device that shares | - | the G502-family prep pattern. | - | | - | Gated on caps.has_effect_cards, which is set by | - | DiscoverEffectCards iff the device responds | - | successfully to GetEffectSpecificInfo. Devices | - | without firmware effect cards leave this false | - | and fall through to path (B). | - | | - | (B) Static-pass-through prep (original fork behavior, | - | doc-verified on G515) — applied when the device | - | has no effect cards or uses 0x8070 / 0x0600 | - | instead of 0x8071. SetEffect cluster=0xFF, | - | effect=Static, RGB=(0,0,0), no fixed-color marker, | - | persist=1. | - | | - | An earlier revision used `effects.size() < 5` as a | - | heuristic proxy for "G502-shaped" devices. The proxy | - | accidentally correlated with "has effect cards" on | - | the two devices we knew about but had no principled | - | meaning — it's been replaced with the direct capability | - | probe. | - \*-------------------------------------------------------*/ - bool needs_prep = controller->NeedsPrepSequence(); - - if(needs_prep && caps.has_zone_effects) - { - bool shape_matches_keyboard_family = - caps.idx_disable_keys_by_usage != 0 - && caps.idx_perkey_v2 != 0 - && caps.rgb_feature_page == HIDPP20_FEAT_RGB_EFFECTS; - - bool shape_matches_observed_prep = - caps.has_effect_cards - && caps.rgb_feature_page == HIDPP20_FEAT_RGB_EFFECTS; - - if(shape_matches_keyboard_family) - { - /*---------------------------------------------*\ - | G815 / G915 / G Pro: per-cluster Off + primer | - | key + FrameEnd. Matches their legacy | - | InitializeDirect wire sequence. | - \*---------------------------------------------*/ - controller->DoKeyboardFamilyPerKeyPrep(); - } - else if(shape_matches_observed_prep) - { - controller->DoObservedPerKeyPrep(); - } - else - { - uint8_t static_effect_idx = 0; - - for(size_t j = 0; j < caps.zone_clusters[0].effects.size(); j++) - { - if(caps.zone_clusters[0].effects[j].effect_id == 0x0001) - { - static_effect_idx = caps.zone_clusters[0].effects[j].index; - break; - } - } - - controller->SetZoneEffect( - 0xFF, /* all clusters */ - static_effect_idx, - 0x0001, /* static effect */ - 0, 0, 0, /* black — no fixed-color marker */ - 0, - 100, /* brightness — unused for static */ - 0, /* direction — unused for static */ - true /* persist=true */); - } - } - - /*------------------------------------------------------*\ - | Snapshot colors to avoid races with effects updating | - | the colors array while we're sending. | - \*------------------------------------------------------*/ std::vector snapshot(colors.begin(), colors.end()); - /*-------------------------------------------------------*\ - | Apply dim brightness scaling if not at full brightness. | - | This modifies the OUTPUT only — the internal colors[] | - | buffer stays at full brightness for the animation. | - \*-------------------------------------------------------*/ int brightness = controller->GetDimBrightness(); - if(brightness < 100) + /*-------------------------------------------------*\ + | Group painted LEDs by keyType, preserving | + | keyType order of first appearance. Skip | + | unaddressable LEDs (keyType 0 + keyId 0, present | + | in the matrix but not enumerated on the device). | + \*-------------------------------------------------*/ + std::vector>>> by_type; + std::map type_slot; + + for(size_t i = 0; i < snapshot.size() && i < led_to_keytype_keyid_8080.size(); i++) { - for(size_t i = 0; i < snapshot.size(); i++) - { - uint8_t r = RGBGetRValue(snapshot[i]) * brightness / 100; - uint8_t g = RGBGetGValue(snapshot[i]) * brightness / 100; - uint8_t b = RGBGetBValue(snapshot[i]) * brightness / 100; - snapshot[i] = ToRGBColor(r, g, b); - } - } + uint16_t key_type = led_to_keytype_keyid_8080[i].first; + uint8_t key_id = led_to_keytype_keyid_8080[i].second; - /*------------------------------------------------------*\ - | Compute delta against last committed state. | - | First call (sent_colors empty) sends everything. | - \*------------------------------------------------------*/ - bool full_update = (sent_colors.size() != snapshot.size()); - - std::map> color_to_zones; - - for(size_t i = 0; i < snapshot.size() && i < led_to_zone_id.size(); i++) - { - if(led_to_zone_id[i] == 0 || led_to_zone_id[i] > 255) + if(key_type == 0 && key_id == 0) { continue; } - if(full_update || snapshot[i] != sent_colors[i]) + RGBColor color = snapshot[i]; + + if(brightness < 100) { - color_to_zones[snapshot[i]].push_back((uint8_t)led_to_zone_id[i]); + uint8_t r = RGBGetRValue(color) * brightness / 100; + uint8_t g = RGBGetGValue(color) * brightness / 100; + uint8_t b = RGBGetBValue(color) * brightness / 100; + color = ToRGBColor(r, g, b); } - } - if(color_to_zones.empty()) - { - return; - } + std::map::iterator slot = type_slot.find(key_type); - /*-----------------------------------------------------*\ - | Drain stale ACKs from previous frames before sending. | - | Without this, SendPerKeyData reads a stale ACK, | - | mistakes it for the current write's ACK, returns | - | early, and FrameEnd then races with the actual ACK. | - \*-----------------------------------------------------*/ - controller->FlushResponseQueue(); - - /*------------------------------------------------------*\ - | Batch changed keys for efficient wire encoding. | - | | - | For same-color groups (>= 2 keys): | - | Sort zone IDs and find contiguous runs. | - | fn5 (SET_RANGE) for runs of 3+: | - | [start, end, R, G, B] × 3 per packet | - | fn6 (SET_SINGLE_VALUE) for scattered remainder: | - | [R, G, B, zid, zid, ...] up to 13 per packet | - | | - | For single-occurrence colors: | - | fn1 (SET_INDIVIDUAL): [zid,R,G,B] × 4 per packet | - \*------------------------------------------------------*/ - std::vector> individual_pairs; - - for(std::pair>& entry : color_to_zones) - { - RGBColor color = entry.first; - std::vector& zone_ids = entry.second; - - if(zone_ids.size() >= 2) + if(slot == type_slot.end()) { - uint8_t r = RGBGetRValue(color); - uint8_t g = RGBGetGValue(color); - uint8_t b = RGBGetBValue(color); + type_slot[key_type] = by_type.size(); + by_type.emplace_back(key_type, std::vector>()); + slot = type_slot.find(key_type); + } - /*--------------------------------------------------*\ - | Sort zone IDs and extract contiguous runs for fn5 | - \*--------------------------------------------------*/ - std::sort(zone_ids.begin(), zone_ids.end()); + by_type[slot->second].second.emplace_back(key_id, color); + } - std::vector> ranges; - std::vector scattered; - size_t run_start = 0; + if(!by_type.empty()) + { + controller->SetPerKey8080(by_type); + controller->PerKeyCommit8080(); + } - for(size_t i = 1; i <= zone_ids.size(); i++) + return; + } + + if(caps.has_perkey && (unsigned int)active_mode < modes.size() && + modes[active_mode].color_mode == MODE_COLORS_PER_LED) + { + /*-------------------------------------------------*\ + | Sender thread not registered, send inline. | + \*-------------------------------------------------*/ + std::vector snapshot(colors.begin(), colors.end()); + SendPerKeyFrame(snapshot); + return; + } + + /*-----------------------------------------------------*\ + | Zone-effect devices without per-key (G560, | + | G810 family): one LED per cluster. Re-issue | + | the active effect on each changed zone with | + | that LED's color; Direct rides Static. | + \*-----------------------------------------------------*/ + if(caps.has_zone_effects && !caps.has_perkey && !PerKey8080Capable() && + (unsigned int)active_mode < modes.size() && + modes[active_mode].color_mode == MODE_COLORS_PER_LED) + { + const mode& current = modes[active_mode]; + + bool full_update = (sent_colors.size() != colors.size()); + bool is_direct = (current.name == "Direct"); + uint16_t period = SpeedSliderToPeriodMs(current.speed); + unsigned char brightness = (current.flags & MODE_FLAG_HAS_BRIGHTNESS) + ? (unsigned char)current.brightness + : 100; + + for(size_t i = 0; i < caps.zone_clusters.size() && i < colors.size(); i++) + { + if(!full_update && sent_colors[i] == colors[i]) + { + continue; + } + + const HIDPP20ZoneCluster& cluster = caps.zone_clusters[i]; + + uint8_t eff_idx = 0; + uint16_t eff_id = 0; + + for(size_t j = 0; j < cluster.effects.size(); j++) + { + bool match = is_direct ? (cluster.effects[j].effect_id == 0x0001) + : (cluster.effects[j].index == (uint8_t)current.value); + + if(match) { - if(i < zone_ids.size() && zone_ids[i] == zone_ids[i - 1] + 1) - { - continue; - } - - size_t run_len = i - run_start; - - if(run_len >= 3) - { - ranges.push_back({zone_ids[run_start], zone_ids[i - 1]}); - } - else - { - for(size_t j = run_start; j < i; j++) - { - scattered.push_back(zone_ids[j]); - } - } - - run_start = i; - } - - /*--------------------------------------------------*\ - | fn5 (SET_RANGE): 3 range entries per packet. | - | Track every zone in each packet's ranges so the | - | FrameEnd ACK matcher can mark them committed. | - \*--------------------------------------------------*/ - for(size_t i = 0; i < ranges.size(); i += 3) - { - uint8_t data[16] = {}; - std::vector packet_zones; - size_t batch = ranges.size() - i; - if(batch > 3) batch = 3; - - for(size_t j = 0; j < batch; j++) - { - data[j * 5 + 0] = ranges[i + j].first; - data[j * 5 + 1] = ranges[i + j].second; - data[j * 5 + 2] = r; - data[j * 5 + 3] = g; - data[j * 5 + 4] = b; - - for(uint8_t z = ranges[i + j].first; - z <= ranges[i + j].second; z++) - { - packet_zones.push_back(z); - } - } - - controller->SendPerKeyData(perkey_idx, FN_8081_SET_RANGE, - data, batch * 5, packet_zones); - } - - /*--------------------------------------------------*\ - | fn6 (SET_SINGLE_VALUE) for remaining scattered. | - | Track the listed zone IDs in each packet. | - \*--------------------------------------------------*/ - for(size_t i = 0; i < scattered.size(); i += 13) - { - uint8_t data[16] = {}; - std::vector packet_zones; - data[0] = r; - data[1] = g; - data[2] = b; - - size_t batch = scattered.size() - i; - if(batch > 13) batch = 13; - - for(size_t j = 0; j < batch; j++) - { - data[3 + j] = scattered[i + j]; - packet_zones.push_back(scattered[i + j]); - } - - controller->SendPerKeyData(perkey_idx, FN_8081_SET_SINGLE_VALUE, - data, 3 + batch, packet_zones); + eff_idx = cluster.effects[j].index; + eff_id = cluster.effects[j].effect_id; + break; } } - else + + if(eff_id == 0) { - for(uint8_t zid : zone_ids) - { - individual_pairs.push_back({zid, color}); - } - } - } - - if(!individual_pairs.empty()) - { - controller->SetPerKeyColors(individual_pairs); - } - - PerKeyFrameResult commit = controller->PerKeyFrameEnd(); - - /*-----------------------------------------------------*\ - | A frame is "fully committed" only if FrameEnd ACKed | - | AND every attempted zone also ACKed. FrameEnd alone | - | is not enough — the firmware happily ACKs FrameEnd | - | even when prior per-key writes were silently dropped | - | (observed on G502 X PLUS during wireless reconnect | - | transients, where the Set* writes return no response | - | but FrameEnd still lands cleanly). | - \*-----------------------------------------------------*/ - bool full_commit = commit.frame_end_acked - && (commit.acked_zones.size() - == commit.attempted_zones.size()); - - /*-----------------------------------------------------*\ - | Upgrade SW control flags from 6 → 5 once the per-key | - | layer is populated. ClaimSWControlIfNeeded leaves the | - | device at flags=6 (effect engine still autonomous) to | - | avoid the onboard→host transition flash; now that the | - | per-key layer is masking zone output, it's safe (and | - | required for idle/wake event generation) to claim the | - | effect bit. No-op if the upgrade has already happened | - | or if claim itself hasn't occurred. | - | | - | Gated on full_commit: upgrading into flags=5 with an | - | empty per-key buffer would leave the firmware with | - | nothing to render and expose its default LED buffer | - | (warm-white on the G502 X PLUS). | - \*-----------------------------------------------------*/ - if(full_commit) - { - controller->UpgradeSwControlAfterFirstPaint(); - } - - /*------------------------------------------------------*\ - | Retry scheduling — ONLY on the critical first paint | - | after a fresh claim (needs_prep == true). Streaming | - | animation frames regularly partial-commit due to | - | fire-and-forget timing, and the delta carry-over | - | (HIDPP20_UNCOMMITTED) already handles missed zones on | - | the next animation tick. Scheduling retries on every | - | partial streaming frame causes the power thread's | - | TickRetryPaintIfPending to fire request_repaint_fn | - | between animation frames, colliding with the animation | - | loop and producing visible stalls. | - | | - | For the first-paint-after-claim case (needs_prep), | - | there IS no "next animation frame" guaranteed, so the | - | retry is the only mechanism to recover from a partial | - | commit during the reconnect-transient window. | - \*------------------------------------------------------*/ - if(full_commit || !needs_prep) - { - controller->CancelRetryPaint(); - } - else - { - controller->ScheduleRetryPaint(); - } - - /*-------------------------------------------------------*\ - | Ensure sent_colors is sized to the snapshot before | - | the commit loop writes by index. On the first frame | - | (or after a reinit clear) sent_colors is empty, and | - | the per-zone writes below would silently no-op, | - | leaving sent_colors empty and re-firing the prep call | - | on every subsequent frame. | - | | - | Initial fill is HIDPP20_UNCOMMITTED so any LED that we | - | did not touch this frame stays "uncommitted" and gets | - | scheduled for the next delta. | - \*-------------------------------------------------------*/ - if(sent_colors.size() != snapshot.size()) - { - sent_colors.assign(snapshot.size(), HIDPP20_UNCOMMITTED); - } - - /*-----------------------------------------------------*\ - | Build a fast lookup of acked zones for this frame. | - \*-----------------------------------------------------*/ - std::set acked_set(commit.acked_zones.begin(), - commit.acked_zones.end()); - - if(commit.frame_end_acked) - { - /*----------------------------------------------------*\ - | Frame end ACKed: any zone whose write packet also | - | ACKed is now committed — advance sent_colors for | - | that LED. Any zone we attempted but never saw an | - | ACK for goes to HIDPP20_UNCOMMITTED so the next | - | frame's delta picks it up. | - \*----------------------------------------------------*/ - for(uint8_t zid : commit.attempted_zones) - { - int led_idx = zone_id_to_led_idx[zid]; - if(led_idx < 0 || (size_t)led_idx >= sent_colors.size()) - { - continue; - } - - if(acked_set.count(zid)) - { - sent_colors[led_idx] = snapshot[led_idx]; - } - else - { - sent_colors[led_idx] = HIDPP20_UNCOMMITTED; - } - } - } - else - { - /*---------------------------------------------------*\ - | Frame end timed out: we don't know what the device | - | committed. Mark every attempted LED uncommitted so | - | the next frame re-pushes them all. Don't bother | - | with the per-zone ACK info here — if FrameEnd | - | didn't land, the per-key writes that did ACK still | - | sit in the staging buffer un-swapped. | - \*---------------------------------------------------*/ - for(uint8_t zid : commit.attempted_zones) - { - int led_idx = zone_id_to_led_idx[zid]; - if(led_idx >= 0 && (size_t)led_idx < sent_colors.size()) - { - sent_colors[led_idx] = HIDPP20_UNCOMMITTED; - } + continue; } + + controller->SetZoneEffect(cluster.index, eff_idx, eff_id, + RGBGetRValue(colors[i]), + RGBGetGValue(colors[i]), + RGBGetBValue(colors[i]), + period, brightness, 0, false); } + sent_colors.assign(colors.begin(), colors.end()); + controller->UpgradeSwControlAfterFirstPaint(); + return; } } -void RGBController_LogitechHIDPP20::DeviceUpdateZoneLEDs(int /*zone*/) +/*---------------------------------------------------------*\ +| One per-key frame: diff the snapshot against sent_colors, | +| put the differences on the wire, settle the ACKs. Runs on | +| the controller's sender thread (or inline as a fallback | +| when no sender is registered), never on the thread that | +| animates the color buffer. | +\*---------------------------------------------------------*/ +void RGBController_LogitechHIDPP20::SendPerKeyFrame(std::vector& snapshot) { - DeviceUpdateLEDs(); -} + std::lock_guard frame_guard(controller->TransactionMutex()); -void RGBController_LogitechHIDPP20::DeviceUpdateSingleLED(int /*led*/) -{ - DeviceUpdateLEDs(); -} - -void RGBController_LogitechHIDPP20::DeviceUpdateMode() -{ if(!controller->IsOnline()) { return; } - /*----------------------------------------------------------*\ - | Drop mode changes while the firmware is fading to off. | - | The device owns its own power state — we don't force-wake | - | it from software. active_mode stays tracked framework- | - | side, and the next wake (firmware onUserActivity) or | - | reconnect will re-apply it through the reinit callback. | - \*----------------------------------------------------------*/ + const HIDPP20DeviceCapabilities& caps = controller->GetCapabilities(); + + if(controller->IsDeepSleep()) + { + return; + } + + if(controller->GetPowerState() == HIDPP20_POWER_SLEEPING + && !(caps.quirks & HIDPP20_QUIRK_FADE_ACCEPTS_WRITES)) + { + return; + } + + controller->ClaimSWControlIfNeeded(); + + if(!caps.has_perkey || (unsigned int)active_mode >= modes.size() || + modes[active_mode].color_mode != MODE_COLORS_PER_LED) + { + return; + } + + uint8_t perkey_idx = (caps.idx_perkey_v2 != 0) ? caps.idx_perkey_v2 : caps.idx_perkey_v1; + + /*-----------------------------------------------------*\ + | Detect re-initialization (reconnect, wake from | + | sleep). Device state is unknown, force full resend. | + \*-----------------------------------------------------*/ + uint32_t gen = controller->GetInitGeneration(); + + if(gen != last_init_gen) + { + sent_colors.clear(); + resync_cursor = 0; + last_init_gen = gen; + } + + /*-----------------------------------------------------*\ + | Per-key prep, selected by a capability probe at | + | discovery: (A) Observed prep (DoObservedPerKeyPrep): | + | two SetEffectByIndex calls on 0x8071 cloned from the | + | official app, parameterized from discovery (template | + | bytes, effect count) so it fits any device with | + | effect cards. Gated on caps.has_effect_cards (set iff | + | GetEffectSpecificInfo answers). (B) Static-pass- | + | through (doc-verified on G515), for devices without | + | effect cards or on 0x8070/0x0600: SetEffect | + | cluster=0xFF, Static, RGB 0, persist=1. | + \*-----------------------------------------------------*/ + bool needs_prep = controller->NeedsPrepSequence(); + bool prep_ran = false; + + if(needs_prep && caps.has_zone_effects) + { + prep_ran = true; + + /*-------------------------------------------------*\ + | Once per claim. The prep wipes the per-key | + | buffer, so running it again on a later | + | frame throws away everything painted since. | + \*-------------------------------------------------*/ + controller->MarkPrepApplied(); + + bool shape_matches_keyboard_family = + caps.idx_disable_keys_by_usage != 0 + && caps.idx_perkey_v2 != 0 + && caps.rgb_feature_page == HIDPP20_FEAT_RGB_EFFECTS; + + bool shape_matches_observed_prep = + caps.has_effect_cards + && caps.rgb_feature_page == HIDPP20_FEAT_RGB_EFFECTS; + + if(shape_matches_keyboard_family) + { + /*---------------------------------------------*\ + | G815 / G915 / G Pro: per-cluster Off + primer | + | key + FrameEnd. Matches their legacy | + | InitializeDirect wire sequence. | + \*---------------------------------------------*/ + controller->DoKeyboardFamilyPerKeyPrep(); + } + else if(shape_matches_observed_prep) + { + controller->DoObservedPerKeyPrep(); + } + else + { + /*---------------------------------------------*\ + | Per-cluster static-black primer. 0x8070 | + | rejects the cluster=0xFF broadcast (seen on | + | the G810), so address each cluster by its | + | own index, matching the Off-mode path. | + \*---------------------------------------------*/ + for(size_t i = 0; i < caps.zone_clusters.size(); i++) + { + for(size_t j = 0; j < caps.zone_clusters[i].effects.size(); j++) + { + if(caps.zone_clusters[i].effects[j].effect_id == 0x0001) + { + controller->SetZoneEffect( + caps.zone_clusters[i].index, + caps.zone_clusters[i].effects[j].index, + 0x0001, /* static effect */ + 0, 0, 0, /* black, no fixed-color marker */ + 0, + 100, /* brightness, unused for static */ + 0, /* direction, unused for static */ + true /* persist=true */); + break; + } + } + } + } + } + + /*-----------------------------------------------------*\ + | Apply dim brightness scaling if not at | + | full brightness. This modifies the OUTPUT | + | only: the internal colors[] buffer stays | + | at full brightness for the animation. | + \*-----------------------------------------------------*/ + int brightness = controller->GetDimBrightness(); + + if(brightness < 100) + { + for(size_t i = 0; i < snapshot.size(); i++) + { + uint8_t r = RGBGetRValue(snapshot[i]) * brightness / 100; + uint8_t g = RGBGetGValue(snapshot[i]) * brightness / 100; + uint8_t b = RGBGetBValue(snapshot[i]) * brightness / 100; + snapshot[i] = ToRGBColor(r, g, b); + } + } + + /*-----------------------------------------------------*\ + | Delta against last committed state; the first call | + | sends everything. A prep frame sends everything | + | too: the prep wipes the device buffer, so nothing | + | sent_colors calls committed is still there, and an | + | empty delta would return without painting, leaving | + | the device wiped, the 6->5 upgrade never firing, | + | and the prep re-running every frame. | + \*-----------------------------------------------------*/ + bool full_update = (sent_colors.size() != snapshot.size()) || prep_ran; + + std::map> color_to_zones; + std::vector zone_in_frame(256, false); + unsigned int delta_zones = 0; + + for(size_t i = 0; i < snapshot.size() && i < led_to_zone_id.size(); i++) + { + if(led_to_zone_id[i] == 0 || led_to_zone_id[i] > 255) + { + continue; + } + + if(full_update || snapshot[i] != sent_colors[i]) + { + color_to_zones[snapshot[i]].push_back((uint8_t)led_to_zone_id[i]); + zone_in_frame[led_to_zone_id[i]] = true; + delta_zones++; + } + } + + /*-----------------------------------------------------*\ + | Rolling resync (see HIDPP20_RESYNC_KEYS_MIN): repaint | + | the least recently visited keys whether or not they | + | look clean, so a key the device never applied heals. | + | The cursor walks every LED in turn, so coverage is | + | bounded even when one region changes every frame. | + \*-----------------------------------------------------*/ + if(!full_update && !led_to_zone_id.empty()) + { + unsigned int budget = (delta_zones <= HIDPP20_RESYNC_QUIET_ZONES) + ? HIDPP20_RESYNC_KEYS_MAX + : HIDPP20_RESYNC_KEYS_MIN; + unsigned int added = 0; + + for(size_t scanned = 0; scanned < led_to_zone_id.size() && added < budget; scanned++) + { + size_t idx = resync_cursor % led_to_zone_id.size(); + + resync_cursor++; + + if(idx >= snapshot.size() || led_to_zone_id[idx] == 0 || led_to_zone_id[idx] > 255) + { + continue; + } + + if(zone_in_frame[led_to_zone_id[idx]]) + { + continue; + } + + color_to_zones[snapshot[idx]].push_back((uint8_t)led_to_zone_id[idx]); + zone_in_frame[led_to_zone_id[idx]] = true; + added++; + } + } + + if(color_to_zones.empty()) + { + return; + } + + /*-----------------------------------------------------*\ + | Drain stale ACKs from previous frames before sending. | + | Without this, SendPerKeyData reads a stale ACK, | + | mistakes it for the current write's ACK, returns | + | early, and FrameEnd then races with the actual ACK. | + \*-----------------------------------------------------*/ + controller->FlushResponseQueue(); + + /*-----------------------------------------------------*\ + | Batch changed keys for wire encoding: same-color | + | groups (2+): sort zones; runs of 3+ go fn5 | + | SET_RANGE [start,end,R,G,B] x3/packet; scattered | + | go fn6 SET_SINGLE_VALUE [R,G,B,zid...] up to | + | 13/packet single-occurrence colors: fn1 | + | SET_INDIVIDUAL [zid,R,G,B] x4/packet | + \*-----------------------------------------------------*/ + std::vector> individual_pairs; + + for(std::pair>& entry : color_to_zones) + { + RGBColor color = entry.first; + std::vector& zone_ids = entry.second; + + if(zone_ids.size() >= 2) + { + uint8_t r = RGBGetRValue(color); + uint8_t g = RGBGetGValue(color); + uint8_t b = RGBGetBValue(color); + + /*---------------------------------------------*\ + | Sort zone IDs into contiguous runs for fn5 | + \*---------------------------------------------*/ + std::sort(zone_ids.begin(), zone_ids.end()); + + std::vector> ranges; + std::vector scattered; + size_t run_start = 0; + + for(size_t i = 1; i <= zone_ids.size(); i++) + { + if(i < zone_ids.size() && zone_ids[i] == zone_ids[i - 1] + 1) + { + continue; + } + + size_t run_len = i - run_start; + + if(run_len >= 3) + { + ranges.push_back({zone_ids[run_start], zone_ids[i - 1]}); + } + else + { + for(size_t j = run_start; j < i; j++) + { + scattered.push_back(zone_ids[j]); + } + } + + run_start = i; + } + + /*---------------------------------------------*\ + | fn5 (SET_RANGE): 3 range entries per | + | packet. Track every zone in each | + | packet's ranges so the FrameEnd ACK | + | matcher can mark them committed. | + \*---------------------------------------------*/ + for(size_t i = 0; i < ranges.size(); i += 3) + { + uint8_t data[16] = {}; + std::vector packet_zones; + size_t batch = ranges.size() - i; + if(batch > 3) batch = 3; + + for(size_t j = 0; j < batch; j++) + { + data[j * 5 + 0] = ranges[i + j].first; + data[j * 5 + 1] = ranges[i + j].second; + data[j * 5 + 2] = r; + data[j * 5 + 3] = g; + data[j * 5 + 4] = b; + + for(uint8_t z = ranges[i + j].first; + z <= ranges[i + j].second; z++) + { + packet_zones.push_back(z); + } + } + + controller->SendPerKeyData(perkey_idx, FN_8081_SET_RANGE, + data, batch * 5, packet_zones); + } + + /*---------------------------------------------*\ + | fn6 (SET_SINGLE_VALUE) for the scattered | + | remainder. Track listed zone IDs per packet. | + \*---------------------------------------------*/ + for(size_t i = 0; i < scattered.size(); i += 13) + { + uint8_t data[16] = {}; + std::vector packet_zones; + data[0] = r; + data[1] = g; + data[2] = b; + + size_t batch = scattered.size() - i; + if(batch > 13) batch = 13; + + for(size_t j = 0; j < batch; j++) + { + data[3 + j] = scattered[i + j]; + packet_zones.push_back(scattered[i + j]); + } + + controller->SendPerKeyData(perkey_idx, FN_8081_SET_SINGLE_VALUE, + data, 3 + batch, packet_zones); + } + } + else + { + for(uint8_t zid : zone_ids) + { + individual_pairs.push_back({zid, color}); + } + } + } + + if(!individual_pairs.empty()) + { + controller->SetPerKeyColors(individual_pairs); + } + + PerKeyFrameResult commit = controller->PerKeyFrameEnd(); + + /*-----------------------------------------------------*\ + | Size sent_colors to the snapshot before the | + | commit loop writes by index, empty on the | + | first frame (or after a reinit clear) the | + | writes would no-op and the prep would re-fire | + | every frame. Fill with HIDPP20_UNCOMMITTED so | + | untouched LEDs schedule for the next delta. | + \*-----------------------------------------------------*/ + if(sent_colors.size() != snapshot.size()) + { + sent_colors.assign(snapshot.size(), HIDPP20_UNCOMMITTED); + } + + /*-----------------------------------------------------*\ + | Build a fast lookup of acked zones for this frame. | + \*-----------------------------------------------------*/ + std::set acked_set(commit.acked_zones.begin(), + commit.acked_zones.end()); + + if(commit.frame_end_acked) + { + /*-------------------------------------------------*\ + | Frame end ACKed: any zone whose write packet also | + | ACKed is now committed, advance sent_colors for | + | that LED. Any zone we attempted but never saw an | + | ACK for goes to HIDPP20_UNCOMMITTED so the next | + | frame's delta picks it up. | + \*-------------------------------------------------*/ + for(uint8_t zid : commit.attempted_zones) + { + int led_idx = zone_id_to_led_idx[zid]; + if(led_idx < 0 || (size_t)led_idx >= sent_colors.size()) + { + continue; + } + + if(acked_set.count(zid)) + { + sent_colors[led_idx] = snapshot[led_idx]; + } + else + { + /*-----------------------------------------*\ + | A reply that did not arrive says nothing | + | about the position; leave it uncommitted | + | and let the next delta carry it again. | + \*-----------------------------------------*/ + sent_colors[led_idx] = HIDPP20_UNCOMMITTED; + } + } + } + else + { + /*-------------------------------------------------*\ + | FrameEnd timed out: unknown what committed, | + | mark every attempted LED uncommitted for | + | re-push. ACKed writes still sit un-swapped | + | in staging, so per-zone ACK info is moot. | + \*-------------------------------------------------*/ + for(uint8_t zid : commit.attempted_zones) + { + int led_idx = zone_id_to_led_idx[zid]; + if(led_idx >= 0 && (size_t)led_idx < sent_colors.size()) + { + sent_colors[led_idx] = HIDPP20_UNCOMMITTED; + } + } + } + + /*-----------------------------------------------------*\ + | Upgrade SW control 6 -> 5 only once the per-key | + | buffer holds a complete picture: at flags=5 the per- | + | key layer masks the zone layer, and unwritten keys | + | render black. "Every zone this frame attempted ACKed" | + | is not enough, a clean two-key delta right after the | + | prep wipe would upgrade a device holding two keys and | + | 93 blanks. sent_colors knows: when no mapped LED is | + | still HIDPP20_UNCOMMITTED, the device holds the whole | + | picture; reachable across several frames, where one | + | perfect frame on a 95-key board is not. | + \*-----------------------------------------------------*/ + bool buffer_complete = commit.frame_end_acked; + + for(size_t i = 0; buffer_complete && i < sent_colors.size() && i < led_to_zone_id.size(); i++) + { + if(led_to_zone_id[i] == 0 || led_to_zone_id[i] > 255) + { + continue; + } + + if(sent_colors[i] == HIDPP20_UNCOMMITTED) + { + buffer_complete = false; + } + } + + if(buffer_complete) + { + /*-------------------------------------------------*\ + | Now, and not one frame before. At flags=5 | + | the per-key layer masks the zone layer, so | + | whatever is in that buffer is what the | + | device shows, hand it the screen while it is | + | half painted and the keys we have not | + | written render as the prep left them. | + \*-------------------------------------------------*/ + controller->UpgradeSwControlAfterFirstPaint(); + + controller->CancelRetryPaint(); + } + else if(controller->AwaitingSWControlUpgrade()) + { + /*-------------------------------------------------*\ + | First paint after a claim: nothing guarantees a | + | next frame (a static color never calls again), so | + | retry until the buffer completes. Only in this | + | window, streaming frames partial-commit routinely | + | and their stragglers ride the next tick's delta; | + | retrying those stalls the animation. | + \*-------------------------------------------------*/ + if(!controller->ScheduleRetryPaint()) + { + /*---------------------------------------------*\ + | Ladder spent: hand the screen over anyway. A | + | zone that never confirms must not hold the | + | upgrade forever, and the zone layer beneath | + | is the worse picture of the two. | + \*---------------------------------------------*/ + LOG_INFO("[LogitechHID++2.0 %s] buffer never fully confirmed, upgrading anyway", + name.c_str()); + + controller->UpgradeSwControlAfterFirstPaint(); + controller->CancelRetryPaint(); + } + } + else + { + controller->CancelRetryPaint(); + } +} + +void RGBController_LogitechHIDPP20::DeviceUpdateZoneLEDs(int /*zone*/) +{ + std::lock_guard frame_guard(controller->TransactionMutex()); + + DeviceUpdateLEDs(); +} + +void RGBController_LogitechHIDPP20::DeviceUpdateSingleLED(int /*led*/) +{ + std::lock_guard frame_guard(controller->TransactionMutex()); + + DeviceUpdateLEDs(); +} + +void RGBController_LogitechHIDPP20::DeviceUpdateMode() +{ + std::lock_guard frame_guard(controller->TransactionMutex()); + + if(!controller->IsOnline()) + { + return; + } + + /*-----------------------------------------------------*\ + | Drop mode changes while the firmware is fading | + | to off. The device owns its own power state; we | + | don't force-wake it from software. active_mode | + | stays tracked framework- side, and the next | + | wake (firmware onUserActivity) or reconnect | + | will re-apply it through the reinit callback. | + \*-----------------------------------------------------*/ if(controller->GetPowerState() == HIDPP20_POWER_SLEEPING) { return; } - /*----------------------------------------------------------*\ - | Claim SW control on first mode set (deferred from init). | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Claim SW control on the first mode set, | + | deferred from init. | + \*-----------------------------------------------------*/ controller->ClaimSWControlIfNeeded(); const HIDPP20DeviceCapabilities& caps = controller->GetCapabilities(); @@ -1509,19 +2892,20 @@ void RGBController_LogitechHIDPP20::DeviceUpdateMode() const mode& current = modes[active_mode]; - /*----------------------------------------------------------*\ - | Direct mode: invalidate delta tracking so the next | - | DeviceUpdateLEDs sends a full frame with actual colors. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Direct mode: invalidate delta tracking so the | + | next DeviceUpdateLEDs sends a full frame with | + | actual colors. | + \*-----------------------------------------------------*/ if(current.name == "Direct") { sent_colors.clear(); DeviceUpdateLEDs(); - /*------------------------------------------------------*\ - | Start power manager (reader + power threads) if not | - | already running. | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | Start power manager (reader + power | + | threads) if not already running. | + \*-------------------------------------------------*/ controller->StartPowerManager(); if(caps.idx_wireless_status != 0 && !caps.has_power_mgmt) @@ -1534,20 +2918,57 @@ void RGBController_LogitechHIDPP20::DeviceUpdateMode() sent_colors.clear(); - /*----------------------------------------------------------*\ - | On per-key devices, single-color non-animated modes (Off, | - | Static) are applied through the per-key path so the LEDs | - | track the mode color cleanly. Animated effects (anything | - | with HAS_SPEED — Breathing, Cycle, Wave, Ripple) fall | - | through to the zone-effect path below; in practice zone | - | effects render correctly alongside per-key on the devices | - | we have data for. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Per-key devices: single-color non-animated modes | + | (Off, Static) go through the per-key path so LEDs | + | track the mode color; animated modes (HAS_SPEED) | + | fall through to zone effects, which render fine | + | alongside per-key on the devices we have data for. | + | | + | 0x8080: Direct is handled above and Off | + | blacks every enumerated key via per-key. Any | + | other mode is a 0x8070/0x8071 zone effect | + | and falls through; those modes only exist | + | when a zone-effect feature is advertised. | + \*-----------------------------------------------------*/ + if(PerKey8080Capable()) + { + if(current.value == 0xFF) + { + std::vector>>> by_type; + + for(size_t t = 0; t < caps.perkey_8080_types.size(); t++) + { + std::vector> keys; + + for(uint8_t key_id : caps.perkey_8080_types[t].key_ids) + { + keys.emplace_back(key_id, ToRGBColor(0, 0, 0)); + } + + by_type.emplace_back(caps.perkey_8080_types[t].key_type, keys); + } + + if(!by_type.empty()) + { + controller->SetPerKey8080(by_type); + controller->PerKeyCommit8080(); + } + + return; + } + + /*-------------------------------------------------*\ + | Any other mode is a zone effect, fall | + | through to the SetZoneEffect path below. | + \*-------------------------------------------------*/ + } + if(caps.has_perkey) { - /*------------------------------------------------------*\ - | Off mode via per-key: set all LEDs to black | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | Off mode via per-key: set all LEDs to black | + \*-------------------------------------------------*/ if(current.value == 0xFF) { controller->SetAllPerKeyColor(ToRGBColor(0, 0, 0)); @@ -1555,17 +2976,18 @@ void RGBController_LogitechHIDPP20::DeviceUpdateMode() return; } - /*------------------------------------------------------*\ - | Static mode via per-key: only used as a fallback when | - | the device exposes per-key but no zone effects. When | - | both are available we prefer the zone-effect path | - | because per-key writes alone don't fully claim against | - | the firmware effect engine on some devices (G502), | - | leaving the firmware fade fighting our per-key colors | - | until something else (e.g. Cycle) force-claims. | - | Gated on !HAS_SPEED so animated colored effects like | - | Breathing don't get clipped to a static color. | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | Static mode via per-key: only used as a fallback | + | when the device exposes per-key but no zone | + | effects. When both are available we prefer the | + | zone-effect path because per-key writes alone | + | don't fully claim against the firmware effect | + | engine on some devices (G502), leaving the | + | firmware fade fighting our per-key colors until | + | something else (e.g. Cycle) force-claims. Gated | + | on !HAS_SPEED so animated colored effects like | + | Breathing don't get clipped to a static color. | + \*-------------------------------------------------*/ if(!caps.has_zone_effects && current.color_mode == MODE_COLORS_MODE_SPECIFIC && current.colors.size() > 0 @@ -1576,28 +2998,28 @@ void RGBController_LogitechHIDPP20::DeviceUpdateMode() return; } - /*------------------------------------------------------*\ - | Animated effects (Breathing, Cycle, Wave, Ripple) on | - | per-key devices fall through to the zone effect path. | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | Animated effects (Breathing, Cycle, | + | Wave, Ripple) on per-key devices fall | + | through to the zone effect path. | + \*-------------------------------------------------*/ } - /*----------------------------------------------------------*\ - | Zone effect modes (devices without per-key, or animated | - | effects that can't be done via per-key) | - | | - | persist branching: | - | 0x8070: ephemeral by default; becomes persist=true | - | only when DeviceSaveMode has set save_pending. | - | 0x8071/0x0600: keeps the pre-existing per-branch | - | hardcoded values (Off=false, Effect=true) | - | pending 0x8071 save research. | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Zone effect modes (devices without per-key, or | + | animated effects that can't be done via per-key) | + | | + | persist branching: 0x8070: ephemeral by default; | + | becomes persist=true only when DeviceSaveMode has | + | set save_pending. 0x8071/0x0600: keeps the pre- | + | existing per-branch hardcoded values (Off=false, | + | Effect=true) pending 0x8071 save research. | + \*-----------------------------------------------------*/ const bool is_8070 = (caps.rgb_feature_page == HIDPP20_FEAT_COLOR_LED_EFFECTS); - /*----------------------------------------------------------*\ - | Off mode: set static black on all clusters | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Off mode: set static black on all clusters | + \*-----------------------------------------------------*/ if(current.value == 0xFF) { const bool off_persist = is_8070 ? save_pending : false; @@ -1620,25 +3042,26 @@ void RGBController_LogitechHIDPP20::DeviceUpdateMode() return; } - /*----------------------------------------------------------*\ - | Effect mode: apply to all clusters. | - | | - | Color source per cluster: | - | MODE_COLORS_PER_LED — current.colors[i] maps to | - | caps.zone_clusters[i] | - | (one LED per cluster on the | - | 0x8070 zone path) | - | MODE_COLORS_MODE_SPECIFIC — current.colors[0] for all | - | MODE_COLORS_NONE — zero (effect ignores RGB) | - \*----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Effect mode: apply to all clusters. | + | | + | Color source per cluster: | + | MODE_COLORS_PER_LED: current.colors[i] maps to | + | caps.zone_clusters[i] | + | (one LED per cluster on the | + | 0x8070 zone path) | + | MODE_COLORS_MODE_SPECIFIC, current.colors[0] for all | + | MODE_COLORS_NONE: zero (effect ignores RGB) | + \*-----------------------------------------------------*/ uint16_t period = SpeedSliderToPeriodMs(current.speed); - /*---------------------------------------------------------*\ - | Brightness defaults to 100 for modes that don't expose | - | a brightness slider — those modes ignore the value at the | - | wire level anyway. Modes flagged HAS_BRIGHTNESS take the | - | user-set value from current.brightness. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Brightness defaults to 100 for modes that | + | don't expose a brightness slider; those | + | modes ignore the value at the wire level | + | anyway. Modes flagged HAS_BRIGHTNESS take | + | the user-set value from current.brightness. | + \*-----------------------------------------------------*/ unsigned char brightness = (current.flags & MODE_FLAG_HAS_BRIGHTNESS) ? (unsigned char)current.brightness : 100; @@ -1659,11 +3082,16 @@ void RGBController_LogitechHIDPP20::DeviceUpdateMode() unsigned char r = 0, g = 0, b = 0; - if(current.color_mode == MODE_COLORS_PER_LED && i < current.colors.size()) + /*-------------------------------------------------*\ + | Per-LED modes carry their colors in | + | the LED buffer, not the mode color | + | list, the UI paints leds/colors. | + \*-------------------------------------------------*/ + if(current.color_mode == MODE_COLORS_PER_LED && i < colors.size()) { - r = RGBGetRValue(current.colors[i]); - g = RGBGetGValue(current.colors[i]); - b = RGBGetBValue(current.colors[i]); + r = RGBGetRValue(colors[i]); + g = RGBGetGValue(colors[i]); + b = RGBGetBValue(colors[i]); } else if(current.color_mode == MODE_COLORS_MODE_SPECIFIC && !current.colors.empty()) { @@ -1672,34 +3100,36 @@ void RGBController_LogitechHIDPP20::DeviceUpdateMode() b = RGBGetBValue(current.colors[0]); } - /*------------------------------------------------------*\ - | Ripple wants a narrower, much faster period range | - | (2..200ms) than the breathing/wave baseline of 1..20s. | - | Both Ripple variants — 0x000B and the saturation | - | 0x0017 — use the fast range; this mirrors Solaar's | - | LEDEffects table, where only Ripple carries a period | - | range override. Cycle (0x0003/0x0015) and Wave | - | (0x0004/0x0016) stay on the standard 1..20s range. | - \*------------------------------------------------------*/ + /*-------------------------------------------------*\ + | Ripple wants a narrower, much faster period | + | range (2..200ms) than the breathing/wave | + | baseline of 1..20s. Both Ripple variants, 0x000B | + | and the saturation 0x0017: use the fast range; | + | this mirrors Solaar's LEDEffects table, where | + | only Ripple carries a period range override. | + | Cycle (0x0003/0x0015) and Wave (0x0004/0x0016) | + | stay on the standard 1..20s range. | + \*-------------------------------------------------*/ uint16_t cluster_period = (eff_id == 0x000B || eff_id == 0x0017) ? RippleSpeedSliderToPeriodMs(current.speed) : period; - /*-----------------------------------------------------*\ - | 0x8071/0x0600: persist=true matches what the observed | - | vendor-app wire capture does for every mode-set on | - | these | - | devices. With persist=false the firmware appears to | - | accept the command without actually committing the | - | new effect, which is consistent with Static (which | - | gets prepped with persist=true at startup) being the | - | only effect that visibly works. | - | | - | 0x8070: ephemeral (persist=false) on live writes; | - | DeviceSaveMode flips save_pending true to replay the | - | active mode with persist=true and commit to NVM. | - \*-----------------------------------------------------*/ + /*-------------------------------------------------*\ + | 0x8071/0x0600: persist=true matches what the | + | observed vendor-app wire capture does for every | + | mode-set on these devices. With persist=false | + | the firmware appears to accept the command | + | without actually committing the new effect, | + | which is consistent with Static (which gets | + | prepped with persist=true at startup) being the | + | only effect that visibly works. | + | | + | 0x8070: ephemeral (persist=false) on live | + | writes; DeviceSaveMode flips save_pending | + | true to replay the active mode with | + | persist=true and commit to NVM. | + \*-------------------------------------------------*/ const bool effect_persist = is_8070 ? save_pending : true; controller->SetZoneEffect( @@ -1709,31 +3139,33 @@ void RGBController_LogitechHIDPP20::DeviceUpdateMode() WaveDirectionToWire(current.direction), effect_persist); } - /*-----------------------------------------------------------*\ - | The zone effects are now committed — safe to upgrade from | - | flags=6 to flags=5. Without this, devices that only use | - | zone effects (no per-key Direct path) would stay at | - | flags=6 forever and the firmware would never send | - | onUserActivity events for idle/sleep. | - \*-----------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | The zone effects are now committed, safe to upgrade | + | from flags=6 to flags=5. Without this, devices that | + | only use zone effects (no per-key Direct path) would | + | stay at flags=6 forever and the firmware would never | + | send onUserActivity events for idle/sleep. | + \*-----------------------------------------------------*/ controller->UpgradeSwControlAfterFirstPaint(); } void RGBController_LogitechHIDPP20::DeviceSaveMode() { - /*----------------------------------------------------------*\ - | 0x8071/0x0600 already write persist=true on every mode | - | change, so the Save button isn't exposed on those pages | - | (MODE_FLAG_MANUAL_SAVE is only set for 0x8070 modes in | - | the constructor). If a save ever lands here from those | - | pages anyway, nothing needs doing — the active mode is | - | already committed to NVM. | - | | - | 0x8070: replay the active mode through DeviceUpdateMode | - | with save_pending true so the zone effect writes go out | - | with persist=true, committing the currently-live effect | - | to flash. | - \*----------------------------------------------------------*/ + std::lock_guard frame_guard(controller->TransactionMutex()); + + /*-----------------------------------------------------*\ + | 0x8071/0x0600 already write persist=true on every | + | mode change, so the Save button isn't exposed on | + | those pages (MODE_FLAG_MANUAL_SAVE is only set for | + | 0x8070 modes in the constructor). If a save ever | + | lands here from those pages anyway, nothing needs | + | doing, the active mode is already committed to NVM. | + | | + | 0x8070: replay the active mode through | + | DeviceUpdateMode with save_pending true so the | + | zone effect writes go out with persist=true, | + | committing the currently-live effect to flash. | + \*-----------------------------------------------------*/ const HIDPP20DeviceCapabilities& caps = controller->GetCapabilities(); if(caps.rgb_feature_page != HIDPP20_FEAT_COLOR_LED_EFFECTS) { @@ -1747,29 +3179,33 @@ void RGBController_LogitechHIDPP20::DeviceSaveMode() bool RGBController_LogitechHIDPP20::ReapplyActiveMode() { - /*-----------------------------------------------------------*\ - | Re-establish the current active_mode on the device. Used | - | by the wake path (after SetRgbPowerMode(1) cancels the | - | firmware fade) and by the reconnect path (after a wireless | - | or USB reconnect). Handles both per-key Direct and zone | - | effect modes: | - | | - | 1. Claim SW control (host mode + flags + power mode). | - | Retried internally; returns true iff the final claim | - | ACKed. ReconnectDevice's fast-backoff loop uses this | - | as the accept signal. | - | 2. Clear sent_colors so the next frame is full-push. | - | Per the 0x8071 lifecycle, the device's LED buffer may | - | not survive mode 3→1 or a full reconnect, so we don't | - | trust it to remember any prior state. | - | 3. Route through DeviceUpdateMode so both per-key Direct | - | (full per-key frame via DeviceUpdateLEDs) and zone | - | effects (SetEffect per cluster) re-establish | - | correctly. Covers the case where the active_mode was | - | changed in the GUI while the device was fading — that | - | mode change was dropped at the time and needs to land | - | here on wake. | - \*-----------------------------------------------------------*/ + std::lock_guard frame_guard(controller->TransactionMutex()); + + /*-----------------------------------------------------*\ + | Re-establish the current active_mode on the device. | + | Used by the wake path (after SetRgbPowerMode(1) | + | cancels the firmware fade) and by the reconnect | + | path (after a wireless or USB reconnect). Handles | + | both per-key Direct and zone effect modes: | + | | + | 1. Claim SW control (host mode + flags + power | + | mode). Retried internally; returns true iff | + | the final claim ACKed. ReconnectDevice's | + | fast-backoff loop uses this as the accept | + | signal. | + | 2. Clear sent_colors so the next frame is a | + | full push. Per the 0x8071 lifecycle the | + | device's LED buffer may not survive mode | + | 3->1 or a full reconnect, so we do not | + | trust it to remember prior state. | + | 3. Route through DeviceUpdateMode so both | + | per-key Direct (full per-key frame via | + | DeviceUpdateLEDs) and zone effects (SetEffect | + | per cluster) re-establish correctly. Covers | + | the case where active_mode changed in the GUI | + | while the device was fading; that change was | + | dropped then and lands here on wake. | + \*-----------------------------------------------------*/ bool claimed = controller->ClaimSWControlIfNeeded(); sent_colors.clear(); DeviceUpdateMode(); diff --git a/Controllers/LogitechController/LogitechHIDPP20Controller/RGBController_LogitechHIDPP20.h b/Controllers/LogitechController/LogitechHIDPP20Controller/RGBController_LogitechHIDPP20.h index e9ea48670..954987c3e 100644 --- a/Controllers/LogitechController/LogitechHIDPP20Controller/RGBController_LogitechHIDPP20.h +++ b/Controllers/LogitechController/LogitechHIDPP20Controller/RGBController_LogitechHIDPP20.h @@ -19,6 +19,8 @@ public: ~RGBController_LogitechHIDPP20(); void SetupZones(); + void SetupZones8080(); + bool PerKey8080Capable() const; void DeviceUpdateLEDs(); void DeviceUpdateZoneLEDs(int zone); @@ -29,46 +31,89 @@ public: bool ReapplyActiveMode(); + LogitechHIDPP20Controller* GetController() const { return controller; } + private: LogitechHIDPP20Controller* controller; - /*---------------------------------------------------------*\ - | Repaint callback handler. Registered with the controller | - | as request_repaint_fn and invoked from the power thread | - | for dim/wake when no animation is driving updates. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Repaint callback handler. Registered with | + | the controller as request_repaint_fn and | + | invoked from the power thread for dim/wake | + | when no animation is driving updates. | + \*-----------------------------------------------------*/ void OnRepaintRequest(); - /*---------------------------------------------------------*\ - | When true, the next DeviceUpdateMode cycle sends its | - | SetZoneEffect calls with persist=true instead of the | - | default ephemeral write. Used by DeviceSaveMode to replay | - | the active mode as a NVM-committed effect on 0x8070 | - | devices, which default to non-persistent live writes. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Per-key frame body. Registered with the | + | controller as the frame sender callback and | + | invoked from its sender thread with the newest | + | submitted snapshot: diffs it against sent_colors | + | and puts the differences on the wire. | + \*-----------------------------------------------------*/ + void SendPerKeyFrame(std::vector& snapshot); + + /*-----------------------------------------------------*\ + | Build one matrix (map != nullptr) or linear | + | zone from a name + geometry + (led name, wire | + | value) list. Shared by the static-table layout | + | paths (mouse, headset). | + \*-----------------------------------------------------*/ + void AddLayoutZone(const std::string& zone_name, + zone_type type, + unsigned int rows, + unsigned int cols, + const unsigned int* map, + const std::vector>& zone_leds); + + /*-----------------------------------------------------*\ + | When true, the next DeviceUpdateMode cycle sends | + | its SetZoneEffect calls with persist=true instead | + | of the default ephemeral write. Used by | + | DeviceSaveMode to replay the active mode as a | + | NVM-committed effect on 0x8070 devices, which | + | default to non-persistent live writes. | + \*-----------------------------------------------------*/ bool save_pending = false; - /*---------------------------------------------------------*\ - | Maps OpenRGB LED index -> HID++ per-key zone ID | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Maps OpenRGB LED index -> HID++ per-key zone ID | + \*-----------------------------------------------------*/ std::vector led_to_zone_id; - /*---------------------------------------------------------*\ - | Reverse map: zone_id -> LED index (-1 if no LED). | - | Indexed 0..255 (zone IDs are bytes). Built once in | - | SetupZones to avoid scanning led_to_zone_id at commit | - | time, which would be O(N) per acked zone. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Reverse map: zone_id -> LED index (-1 if no LED). | + | Indexed 0..255 (zone IDs are bytes). Built once in | + | SetupZones to avoid scanning led_to_zone_id at commit | + | time, which would be O(N) per acked zone. | + \*-----------------------------------------------------*/ std::vector zone_id_to_led_idx; - /*---------------------------------------------------------*\ - | Last successfully committed colors for delta updates. | - | An entry of HIDPP20_UNCOMMITTED (0xFF000000) marks an LED | - | whose last write didn't ACK and which therefore needs to | - | be re-pushed in the next frame regardless of color delta. | - | The high byte (0xFF) is impossible for any value produced | - | by ToRGBColor() so it never collides with a real color. | - \*---------------------------------------------------------*/ + /*-----------------------------------------------------*\ + | Last successfully committed colors for delta | + | updates. An entry of HIDPP20_UNCOMMITTED | + | (0xFF000000) marks an LED whose last write didn't | + | ACK and which therefore needs to be re-pushed in the | + | next frame regardless of color delta. The high byte | + | (0xFF) is impossible for any value produced by | + | ToRGBColor() so it never collides with a real color. | + \*-----------------------------------------------------*/ std::vector sent_colors; uint32_t last_init_gen = 0; + + /*-----------------------------------------------------*\ + | Rolling resync cursor (see HIDPP20_RESYNC_KEYS_MIN). | + | Walks the LED list one frame at a time so every key | + | is repainted within a bounded number of frames. | + \*-----------------------------------------------------*/ + size_t resync_cursor = 0; + + /*-----------------------------------------------------*\ + | 0x8080 addressing: LED index -> (keyType, | + | keyId), built in SetupZones from the | + | enumeration. keyType 0 + keyId 0 marks a | + | matrix LED the device did not enumerate. | + | Parallel to led_to_zone_id; 0x8080 path only. | + \*-----------------------------------------------------*/ + std::vector> led_to_keytype_keyid_8080; }; diff --git a/Controllers/LogitechController/LogitechProtocolCommon.cpp b/Controllers/LogitechController/LogitechProtocolCommon.cpp index 16250ecdc..8d754b569 100644 --- a/Controllers/LogitechController/LogitechProtocolCommon.cpp +++ b/Controllers/LogitechController/LogitechProtocolCommon.cpp @@ -35,7 +35,7 @@ static std::vector logitech_RGB_pages = LOGITECH_HIDPP_PAGE_RGB_EFFECTS2 }; -int getWirelessDevice(usages device_usages, uint16_t pid, wireless_map *wireless_devices) +int getWirelessDevice(usages device_usages, uint16_t pid, wireless_map *wireless_devices, std::map *online_out) { hid_device* dev_use1; usages::iterator find_usage = device_usages.find(1); @@ -115,6 +115,20 @@ int getWirelessDevice(usages device_usages, uint16_t pid, wireless_map *wireless if(devices.device_index != LOGITECH_RECEIVER_DEVICE_INDEX) { wireless_devices->emplace(wireless_PID, devices.device_index); + + /*-------------------------------------*\ + | data[0] bit 0x40 of the connection | + | notification is the link flag: set | + | means the device is paired but not | + | currently linked (off / asleep). | + | Record online state so callers can | + | tell a sleeping device apart from | + | an absent slot. | + \*-------------------------------------*/ + if(online_out) + { + online_out->emplace(devices.device_index, !(devices.data[0] & 0x40)); + } } } } @@ -127,6 +141,164 @@ int getWirelessDevice(usages device_usages, uint16_t pid, wireless_map *wireless return((int)wireless_devices->size()); } +std::string getWirelessDeviceName(usages device_usages, uint8_t device_index) +{ + usages::iterator find_usage = device_usages.find(1); + + if(find_usage == device_usages.end() || device_index < 1) + { + return ""; + } + + hid_device* dev_use1 = find_usage->second; + + /*-----------------------------------------------------*\ + | GET_LONG_REGISTER 0xB5 (receiver info), sub 0x40 + N | + | - 1 = device name: the codename the receiver stores | + | for the paired device. Answered by the receiver | + | itself, so it works even when the device is asleep. | + \*-----------------------------------------------------*/ + shortFAPrequest get_name; + get_name.init(LOGITECH_RECEIVER_DEVICE_INDEX, LOGITECH_GET_LONG_REGISTER_REQUEST); + get_name.feature_command = 0xB5; + get_name.data[0] = (uint8_t)(0x40 + device_index - 1); + + hid_write(dev_use1, get_name.buffer, get_name.size()); + + /*-----------------------------------------------------*\ + | Read until the matching response, skipping | + | unrelated frames (link notifications etc.) up to | + | a small budget. | + \*-----------------------------------------------------*/ + for(int reads = 0; reads < 8; reads++) + { + blankFAPmessage response; + response.init(); + + int rd = hid_read_timeout(dev_use1, response.buffer, response.size(), LOGITECH_PROTOCOL_TIMEOUT); + + if(rd <= 0) + { + break; + } + + if(response.feature_index != LOGITECH_GET_LONG_REGISTER_REQUEST || + response.feature_command != 0xB5 || + response.data[0] != (uint8_t)(0x40 + device_index - 1)) + { + continue; + } + + unsigned int name_len = response.data[1]; + + LOG_DEBUG("Pairing name reply idx=%u len=%u raw=[%02X %02X %02X %02X %02X %02X %02X %02X]", + device_index, name_len, + response.data[0], response.data[1], response.data[2], response.data[3], + response.data[4], response.data[5], response.data[6], response.data[7]); + + if(name_len == 0 || name_len > 14) + { + break; + } + + std::string name((char*)&response.data[2], name_len); + + while(!name.empty() && (name.back() == '\0' || name.back() == ' ')) + { + name.pop_back(); + } + + /*-------------------------------------------------*\ + | Reject a garbled read: right length, junk | + | bytes. Callers read an empty return as | + | "no pairing name". | + \*-------------------------------------------------*/ + for(unsigned char c : name) + { + if(c < 0x20 || c > 0x7E) + { + LOG_DEBUG("Pairing name for idx=%u is not printable, discarding", device_index); + return ""; + } + } + + return name; + } + + return ""; +} + +/*---------------------------------------------------------*\ +| Receiver-stored serial for a paired slot: | +| GET_LONG_REGISTER 0xB5 sub 0x30+N-1 (extended | +| pairing info), bytes 1..4, the same value the device | +| reports as its HID++ 2.0 unit id. Answered from the | +| receiver's own registers, so it works while the | +| device is asleep, off, or away on its cable: a | +| device identity that needs no reachable device. | +\*---------------------------------------------------------*/ +std::string getWirelessDeviceSerial(usages device_usages, uint8_t device_index) +{ + usages::iterator find_usage = device_usages.find(1); + + if(find_usage == device_usages.end() || device_index < 1) + { + return ""; + } + + hid_device* dev_use1 = find_usage->second; + + shortFAPrequest get_serial; + get_serial.init(LOGITECH_RECEIVER_DEVICE_INDEX, LOGITECH_GET_LONG_REGISTER_REQUEST); + get_serial.feature_command = 0xB5; + get_serial.data[0] = (uint8_t)(0x30 + device_index - 1); + + hid_write(dev_use1, get_serial.buffer, get_serial.size()); + + for(int reads = 0; reads < 8; reads++) + { + blankFAPmessage response; + response.init(); + + int rd = hid_read_timeout(dev_use1, response.buffer, response.size(), LOGITECH_PROTOCOL_TIMEOUT); + + if(rd <= 0) + { + break; + } + + if(response.feature_index != LOGITECH_GET_LONG_REGISTER_REQUEST || + response.feature_command != 0xB5 || + response.data[0] != (uint8_t)(0x30 + device_index - 1)) + { + continue; + } + + char serial[9]; + + snprintf(serial, sizeof(serial), "%02X%02X%02X%02X", + response.data[1], response.data[2], response.data[3], response.data[4]); + + std::string serial_str(serial); + + LOG_DEBUG("Pairing serial reply idx=%u serial=%s", device_index, serial_str.c_str()); + + /*-------------------------------------------------*\ + | An all-zero serial is the receiver saying | + | it has none. Useless as an identity, | + | callers must not treat it as one. | + \*-------------------------------------------------*/ + if(serial_str == "00000000") + { + return ""; + } + + return serial_str; + } + + return ""; +} + logitech_device::logitech_device(char *path, usages _usages, uint8_t _device_index, bool _wireless) { device_index = _device_index; diff --git a/Controllers/LogitechController/LogitechProtocolCommon.h b/Controllers/LogitechController/LogitechProtocolCommon.h index f283a2b7c..794a741e5 100644 --- a/Controllers/LogitechController/LogitechProtocolCommon.h +++ b/Controllers/LogitechController/LogitechProtocolCommon.h @@ -22,12 +22,15 @@ #define LOGITECH_SHORT_MESSAGE_LEN 7 #define LOGITECH_LONG_MESSAGE 0x11 #define LOGITECH_LONG_MESSAGE_LEN 20 +#define LOGITECH_VERY_LONG_MESSAGE 0x12 +#define LOGITECH_VERY_LONG_MESSAGE_LEN 64 #define LOGITECH_FAP_RESPONSE_LEN 64 //Define a universal response buffer and allow the hidapi to determine the size #define LOGITECH_DEFAULT_DEVICE_INDEX 0xFF #define LOGITECH_RECEIVER_DEVICE_INDEX 0xFF //The Unifying receiver uses RAP or register access protocol #define LOGITECH_SET_REGISTER_REQUEST 0x80 #define LOGITECH_GET_REGISTER_REQUEST 0x81 +#define LOGITECH_GET_LONG_REGISTER_REQUEST 0x83 #define LOGITECH_HIDPP_PAGE_ROOT_IDX 0x00 //Used for querying the feature index #define LOGITECH_CMD_ROOT_GET_FEATURE 0x01 @@ -63,7 +66,18 @@ enum LOGITECH_DEVICE_TYPE LOGITECH_DEVICE_TYPE_TRACKBALL = 5, LOGITECH_DEVICE_TYPE_PRESENTER = 6, LOGITECH_DEVICE_TYPE_RECEIVER = 7, - LOGITECH_DEVICE_TYPE_HEADSET = 8 + LOGITECH_DEVICE_TYPE_HEADSET = 8, + LOGITECH_DEVICE_TYPE_WEBCAM = 9, + LOGITECH_DEVICE_TYPE_STEERINGWHEEL = 10, + LOGITECH_DEVICE_TYPE_JOYSTICK = 11, + LOGITECH_DEVICE_TYPE_GAMEPAD = 12, + LOGITECH_DEVICE_TYPE_DOCK = 13, + LOGITECH_DEVICE_TYPE_SPEAKER = 14, + LOGITECH_DEVICE_TYPE_MICROPHONE = 15, + LOGITECH_DEVICE_TYPE_LIGHT = 16, + LOGITECH_DEVICE_TYPE_PROGRAMMABLE = 17, + LOGITECH_DEVICE_TYPE_CARSIMPEDALS = 18, + LOGITECH_DEVICE_TYPE_ADAPTER = 19 }; enum LOGITECH_DEVICE_MODE @@ -270,7 +284,9 @@ struct logitech_led leds_fx fx; }; -int getWirelessDevice(usages _usages, uint16_t pid, wireless_map *wireless_devices); //Helper function needed outside of class +int getWirelessDevice(usages _usages, uint16_t pid, wireless_map *wireless_devices, std::map *online_out = nullptr); //Helper function needed outside of class; online_out (optional) reports per-index link state +std::string getWirelessDeviceName(usages _usages, uint8_t device_index); //Receiver-stored device codename (reg 0xB5 sub 0x4N); works while the device is asleep +std::string getWirelessDeviceSerial(usages _usages, uint8_t device_index); //Receiver-stored device serial (extended pairing info reg 0xB5 sub 0x3N); works while the device is asleep, and matches the device's own HID++ unit id class logitech_device { diff --git a/scripts/build-udev-rules.sh b/scripts/build-udev-rules.sh index ca3967d53..25333347f 100755 --- a/scripts/build-udev-rules.sh +++ b/scripts/build-udev-rules.sh @@ -25,6 +25,7 @@ echo -e "Adding Static Headers" UDEV_HEADER=${UDEV_LINE}'# OpenRGB udev rules - Git Commit: '${GIT_SHORT_HASH:0:8}' #\n'${UDEV_LINE}'\n' UDEV_HEADER+=${UDEV_LINE}'# User I2C/SMBus Access #\n'${UDEV_LINE}'KERNEL=="i2c-[0-99]*", TAG+="uaccess"\n\n' UDEV_HEADER+=${UDEV_LINE}'# Super I/O Access #\n'${UDEV_LINE}'KERNEL=="port", TAG+="uaccess"\n\n' +UDEV_HEADER+=${UDEV_LINE}'# Logitech HID++ 2.0 - VID-generic detection #\n'${UDEV_LINE}'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", TAG+="uaccess", TAG+="Logitech_HID_20"\nSUBSYSTEM=="usb", ATTR{idVendor}=="046d", TAG+="uaccess", TAG+="Logitech_HID_20"\n\n' # Faustus rules ASUS_TUF_DEVICES=('blue' 'flags' 'green' 'mode' 'red' 'set' 'speed')