mirror of
https://github.com/CalcProgrammer1/OpenRGB.git
synced 2026-06-24 13:48:45 -04:00
1797 lines
76 KiB
C++
1797 lines
76 KiB
C++
/*---------------------------------------------------------*\
|
||
| RGBController_LogitechHIDPP20.cpp |
|
||
| |
|
||
| RGBController for unified Logitech HID++ 2.0 devices |
|
||
| |
|
||
| This file is part of the OpenRGB project |
|
||
| SPDX-License-Identifier: GPL-2.0-or-later |
|
||
\*---------------------------------------------------------*/
|
||
|
||
#include <map>
|
||
#include <set>
|
||
#include <chrono>
|
||
#include <algorithm>
|
||
#include <functional>
|
||
#include "RGBController_LogitechHIDPP20.h"
|
||
#include "RGBControllerKeyNames.h"
|
||
#include "KeyboardLayoutManager.h"
|
||
#include "LogManager.h"
|
||
|
||
/*----------------------------------------------------------*\
|
||
| Sentinel for "this LED's last write didn't ACK". |
|
||
| Stored in sent_colors[i] to force the next frame to |
|
||
| re-push the LED regardless of color delta. The high byte |
|
||
| 0xFF is unreachable from any ToRGBColor(r,g,b) value |
|
||
| (those have high byte 0), so the sentinel never collides |
|
||
| with a real color including black (0x00000000) or |
|
||
| white (0x00FFFFFF). |
|
||
\*----------------------------------------------------------*/
|
||
static constexpr RGBColor HIDPP20_UNCOMMITTED = 0xFF000000;
|
||
|
||
/*---------------------------------------------------------*\
|
||
| Effect period range, in milliseconds. Matches the |
|
||
| 1000..20000ms range Logitech firmware tests against and |
|
||
| the vendor app clamps to in observed wire captures. |
|
||
| Out-of-band values can |
|
||
| produce flashy / invisible animations on real hardware, |
|
||
| so the slider stays clamped here on our side. |
|
||
\*---------------------------------------------------------*/
|
||
static const uint16_t HIDPP20_PERIOD_MIN_MS = 1000;
|
||
static const uint16_t HIDPP20_PERIOD_MAX_MS = 20000;
|
||
|
||
/*---------------------------------------------------------*\
|
||
| Ripple has its own narrower, much faster period range. |
|
||
| Values taken from G915's LOGITECH_G915_SPEED_RIPPLE_* |
|
||
| constants: 2ms..200ms. A ripple feels right when quick — |
|
||
| using the breathing range (1..20s) makes it invisible. |
|
||
\*---------------------------------------------------------*/
|
||
static const uint16_t HIDPP20_RIPPLE_PERIOD_MIN_MS = 2;
|
||
static const uint16_t HIDPP20_RIPPLE_PERIOD_MAX_MS = 200;
|
||
|
||
/*---------------------------------------------------------*\
|
||
| Speed slider range presented to the user. 1..100 matches |
|
||
| our brightness convention. Higher = faster animation, |
|
||
| inverted on the wire because lower period = faster cycle. |
|
||
\*---------------------------------------------------------*/
|
||
static const int HIDPP20_SPEED_SLIDER_MIN = 1;
|
||
static const int HIDPP20_SPEED_SLIDER_MAX = 100;
|
||
|
||
static uint16_t SliderToPeriodMs(int slider, uint16_t period_min_ms, uint16_t period_max_ms)
|
||
{
|
||
if(slider <= HIDPP20_SPEED_SLIDER_MIN) return period_max_ms;
|
||
if(slider >= HIDPP20_SPEED_SLIDER_MAX) return period_min_ms;
|
||
|
||
const int period_range = period_max_ms - period_min_ms;
|
||
const int slider_range = HIDPP20_SPEED_SLIDER_MAX - HIDPP20_SPEED_SLIDER_MIN;
|
||
|
||
return (uint16_t)(period_max_ms
|
||
- ((slider - HIDPP20_SPEED_SLIDER_MIN) * period_range) / slider_range);
|
||
}
|
||
|
||
static uint16_t SpeedSliderToPeriodMs(int slider)
|
||
{
|
||
return SliderToPeriodMs(slider, HIDPP20_PERIOD_MIN_MS, HIDPP20_PERIOD_MAX_MS);
|
||
}
|
||
|
||
static uint16_t RippleSpeedSliderToPeriodMs(int slider)
|
||
{
|
||
return SliderToPeriodMs(slider, HIDPP20_RIPPLE_PERIOD_MIN_MS, HIDPP20_RIPPLE_PERIOD_MAX_MS);
|
||
}
|
||
|
||
/*---------------------------------------------------------*\
|
||
| Color Wave 0x0016 carries a direction byte. Map OpenRGB's |
|
||
| 6 direction slots onto the Logitech wire values (Solaar |
|
||
| LedDirectionChoices). Logitech defines 8 directions; the |
|
||
| G515 uses 6 of them, which line up 1:1 with OpenRGB's set |
|
||
| (In / Out are not exposed on this keyboard). |
|
||
\*---------------------------------------------------------*/
|
||
static uint8_t WaveDirectionToWire(unsigned int dir)
|
||
{
|
||
switch(dir)
|
||
{
|
||
case MODE_DIRECTION_LEFT: return 6; /* Left */
|
||
case MODE_DIRECTION_RIGHT: return 1; /* Right */
|
||
case MODE_DIRECTION_UP: return 7; /* Up */
|
||
case MODE_DIRECTION_DOWN: return 2; /* Down */
|
||
case MODE_DIRECTION_HORIZONTAL: return 3; /* Center Out */
|
||
case MODE_DIRECTION_VERTICAL: return 8; /* Center In */
|
||
default: return 1; /* Right */
|
||
}
|
||
}
|
||
|
||
/**------------------------------------------------------------------*\
|
||
@name Logitech HID++ 2.0
|
||
@category Keyboard,Mouse,Headset
|
||
@type USB
|
||
@save :x:
|
||
@direct :white_check_mark:
|
||
@effects :white_check_mark:
|
||
@detectors DetectLogitechHIDPP20
|
||
@comment
|
||
Unified HID++ 2.0 controller that dynamically discovers device
|
||
capabilities via feature probing. Supports per-key lighting
|
||
(0x8081/0x8080) and zone-based effects (0x8071/0x8070).
|
||
\*-------------------------------------------------------------------*/
|
||
|
||
static const char* zone_location_name(uint16_t location)
|
||
{
|
||
switch(location)
|
||
{
|
||
case 0x0001: return "All";
|
||
case 0x0002: return "Primary";
|
||
case 0x0003: return "Combined";
|
||
case 0x0004: return "Logo";
|
||
case 0x0005: return "Left";
|
||
case 0x0006: return "Right";
|
||
case 0x0007: return "Group 1";
|
||
case 0x0008: return "Group 2";
|
||
case 0x0009: return "Group 3";
|
||
case 0x000A: return "Group 4";
|
||
case 0x000B: return "Group 5";
|
||
case 0x2000: return "Top";
|
||
case 0x4000: return "Bottom";
|
||
default:
|
||
{
|
||
static char buf[16];
|
||
snprintf(buf, sizeof(buf), "Zone 0x%04X", location);
|
||
return buf;
|
||
}
|
||
}
|
||
}
|
||
|
||
/*---------------------------------------------------------*\
|
||
| HID++ per-key zone ID to OpenRGB key name mapping |
|
||
| Zone IDs follow Solaar's KEYCODES (special_keys.py) |
|
||
| Used to look up zone IDs by key name after KLM builds |
|
||
| the keymap in its own sorted order. |
|
||
\*---------------------------------------------------------*/
|
||
static const std::map<std::string, unsigned int> hidpp20_key_name_to_zone =
|
||
{
|
||
{ KEY_EN_A, 1 },
|
||
{ KEY_EN_B, 2 },
|
||
{ KEY_EN_C, 3 },
|
||
{ KEY_EN_D, 4 },
|
||
{ KEY_EN_E, 5 },
|
||
{ KEY_EN_F, 6 },
|
||
{ KEY_EN_G, 7 },
|
||
{ KEY_EN_H, 8 },
|
||
{ KEY_EN_I, 9 },
|
||
{ KEY_EN_J, 10 },
|
||
{ KEY_EN_K, 11 },
|
||
{ KEY_EN_L, 12 },
|
||
{ KEY_EN_M, 13 },
|
||
{ KEY_EN_N, 14 },
|
||
{ KEY_EN_O, 15 },
|
||
{ KEY_EN_P, 16 },
|
||
{ KEY_EN_Q, 17 },
|
||
{ KEY_EN_R, 18 },
|
||
{ KEY_EN_S, 19 },
|
||
{ KEY_EN_T, 20 },
|
||
{ KEY_EN_U, 21 },
|
||
{ KEY_EN_V, 22 },
|
||
{ KEY_EN_W, 23 },
|
||
{ KEY_EN_X, 24 },
|
||
{ KEY_EN_Y, 25 },
|
||
{ KEY_EN_Z, 26 },
|
||
{ KEY_EN_1, 27 },
|
||
{ KEY_EN_2, 28 },
|
||
{ KEY_EN_3, 29 },
|
||
{ KEY_EN_4, 30 },
|
||
{ KEY_EN_5, 31 },
|
||
{ KEY_EN_6, 32 },
|
||
{ KEY_EN_7, 33 },
|
||
{ KEY_EN_8, 34 },
|
||
{ KEY_EN_9, 35 },
|
||
{ KEY_EN_0, 36 },
|
||
{ KEY_EN_ANSI_ENTER, 37 },
|
||
{ KEY_EN_ESCAPE, 38 },
|
||
{ KEY_EN_BACKSPACE, 39 },
|
||
{ KEY_EN_TAB, 40 },
|
||
{ KEY_EN_SPACE, 41 },
|
||
{ KEY_EN_MINUS, 42 },
|
||
{ KEY_EN_EQUALS, 43 },
|
||
{ KEY_EN_LEFT_BRACKET, 44 },
|
||
{ KEY_EN_RIGHT_BRACKET, 45 },
|
||
{ KEY_EN_ANSI_BACK_SLASH, 46 },
|
||
{ KEY_EN_SEMICOLON, 48 },
|
||
{ KEY_EN_QUOTE, 49 },
|
||
{ KEY_EN_BACK_TICK, 50 },
|
||
{ KEY_EN_COMMA, 51 },
|
||
{ KEY_EN_PERIOD, 52 },
|
||
{ KEY_EN_FORWARD_SLASH, 53 },
|
||
{ KEY_EN_CAPS_LOCK, 54 },
|
||
{ KEY_EN_F1, 55 },
|
||
{ KEY_EN_F2, 56 },
|
||
{ KEY_EN_F3, 57 },
|
||
{ KEY_EN_F4, 58 },
|
||
{ KEY_EN_F5, 59 },
|
||
{ KEY_EN_F6, 60 },
|
||
{ KEY_EN_F7, 61 },
|
||
{ KEY_EN_F8, 62 },
|
||
{ KEY_EN_F9, 63 },
|
||
{ KEY_EN_F10, 64 },
|
||
{ KEY_EN_F11, 65 },
|
||
{ KEY_EN_F12, 66 },
|
||
{ KEY_EN_PRINT_SCREEN, 67 },
|
||
{ KEY_EN_SCROLL_LOCK, 68 },
|
||
{ KEY_EN_PAUSE_BREAK, 69 },
|
||
{ KEY_EN_INSERT, 70 },
|
||
{ KEY_EN_HOME, 71 },
|
||
{ KEY_EN_PAGE_UP, 72 },
|
||
{ KEY_EN_DELETE, 73 },
|
||
{ KEY_EN_END, 74 },
|
||
{ KEY_EN_PAGE_DOWN, 75 },
|
||
{ KEY_EN_RIGHT_ARROW, 76 },
|
||
{ KEY_EN_LEFT_ARROW, 77 },
|
||
{ KEY_EN_DOWN_ARROW, 78 },
|
||
{ KEY_EN_UP_ARROW, 79 },
|
||
{ KEY_EN_RIGHT_FUNCTION, 111 },
|
||
{ KEY_EN_MENU, 98 },
|
||
|
||
/*------------------------------------------------------*\
|
||
| Numpad zones (Solaar KEYCODES 80-96). |
|
||
| Required for any full-size HID++ keyboard. |
|
||
\*------------------------------------------------------*/
|
||
{ KEY_EN_NUMPAD_LOCK, 80 },
|
||
{ KEY_EN_NUMPAD_DIVIDE, 81 },
|
||
{ KEY_EN_NUMPAD_TIMES, 82 },
|
||
{ KEY_EN_NUMPAD_MINUS, 83 },
|
||
{ KEY_EN_NUMPAD_PLUS, 84 },
|
||
{ KEY_EN_NUMPAD_ENTER, 85 },
|
||
{ KEY_EN_NUMPAD_1, 86 },
|
||
{ KEY_EN_NUMPAD_2, 87 },
|
||
{ KEY_EN_NUMPAD_3, 88 },
|
||
{ KEY_EN_NUMPAD_4, 89 },
|
||
{ KEY_EN_NUMPAD_5, 90 },
|
||
{ KEY_EN_NUMPAD_6, 91 },
|
||
{ KEY_EN_NUMPAD_7, 92 },
|
||
{ KEY_EN_NUMPAD_8, 93 },
|
||
{ KEY_EN_NUMPAD_9, 94 },
|
||
{ KEY_EN_NUMPAD_0, 95 },
|
||
{ KEY_EN_NUMPAD_PERIOD, 96 },
|
||
|
||
{ KEY_EN_LEFT_CONTROL, 104 },
|
||
{ KEY_EN_LEFT_SHIFT, 105 },
|
||
{ KEY_EN_LEFT_ALT, 106 },
|
||
{ KEY_EN_LEFT_WINDOWS, 107 },
|
||
{ KEY_EN_RIGHT_CONTROL, 108 },
|
||
{ KEY_EN_RIGHT_SHIFT, 109 },
|
||
{ KEY_EN_RIGHT_ALT, 110 },
|
||
{ KEY_EN_RIGHT_WINDOWS, 111 },
|
||
|
||
/*------------------------------------------------------*\
|
||
| G915 (and similar) out-of-KLM LEDs. |
|
||
| Zone IDs from Solaar KEYCODES. Names match the legacy |
|
||
| G915 controller so existing users don't see their LED |
|
||
| labels change when they move onto the unified driver. |
|
||
\*------------------------------------------------------*/
|
||
{ "Key: Brightness", 153 },
|
||
{ KEY_EN_MEDIA_PLAY_PAUSE, 155 },
|
||
{ KEY_EN_MEDIA_MUTE, 156 },
|
||
{ KEY_EN_MEDIA_NEXT, 157 },
|
||
{ KEY_EN_MEDIA_PREVIOUS, 158 },
|
||
{ "Key: G1", 180 },
|
||
{ "Key: G2", 181 },
|
||
{ "Key: G3", 182 },
|
||
{ "Key: G4", 183 },
|
||
{ "Key: G5", 184 },
|
||
{ "Logo", 210 },
|
||
};
|
||
|
||
/*---------------------------------------------------------*\
|
||
| Mouse LED layout table |
|
||
| Each entry defines a matrix layout for a known mouse. |
|
||
| Looked up by substring match on device name. |
|
||
| To add a new mouse: add an entry with name pattern, |
|
||
| grid dimensions, LED count, map, and LED names. |
|
||
\*---------------------------------------------------------*/
|
||
#define ML_NA 0xFFFFFFFF
|
||
|
||
struct MouseLayout
|
||
{
|
||
const char* name_match;
|
||
unsigned int rows;
|
||
unsigned int cols;
|
||
unsigned int led_count;
|
||
const unsigned int* map;
|
||
const char* const* led_names;
|
||
};
|
||
|
||
static const unsigned int g502x_map[3 * 7] =
|
||
{
|
||
/* C . . . . . B */
|
||
2, ML_NA, ML_NA, ML_NA, ML_NA, ML_NA, 1,
|
||
/* . D H G F E . */
|
||
ML_NA, 3, 7, 6, 5, 4, ML_NA,
|
||
/* . . . . . . A */
|
||
ML_NA, ML_NA, ML_NA, ML_NA, ML_NA, ML_NA, 0,
|
||
};
|
||
|
||
static const char* g502x_led_names[] =
|
||
{
|
||
"LED A", "LED B", "LED C", "LED D",
|
||
"LED E", "LED F", "LED G", "LED H",
|
||
};
|
||
|
||
static const MouseLayout known_mouse_layouts[] =
|
||
{
|
||
{ "G502 X", 3, 7, 8, g502x_map, g502x_led_names },
|
||
/*-------------------------------------------------------*\
|
||
| Add new mice here: |
|
||
| { "G PRO X", rows, cols, count, map_ptr, names_ptr }, |
|
||
\*-------------------------------------------------------*/
|
||
{ nullptr, 0, 0, 0, nullptr, nullptr }
|
||
};
|
||
|
||
static const MouseLayout* FindMouseLayout(const std::string& device_name)
|
||
{
|
||
for(const MouseLayout* ml = known_mouse_layouts; ml->name_match != nullptr; ml++)
|
||
{
|
||
if(device_name.find(ml->name_match) != std::string::npos)
|
||
{
|
||
return ml;
|
||
}
|
||
}
|
||
|
||
return nullptr;
|
||
}
|
||
|
||
RGBController_LogitechHIDPP20::RGBController_LogitechHIDPP20(LogitechHIDPP20Controller* controller_ptr)
|
||
{
|
||
controller = controller_ptr;
|
||
|
||
const HIDPP20DeviceCapabilities& caps = controller->GetCapabilities();
|
||
|
||
name = caps.device_name;
|
||
vendor = "Logitech";
|
||
description = "Logitech HID++ 2.0 Device";
|
||
version = caps.firmware_version;
|
||
location = controller->GetDeviceLocation();
|
||
serial = controller->GetSerialString();
|
||
|
||
switch(caps.device_type)
|
||
{
|
||
case LOGITECH_DEVICE_TYPE_KEYBOARD:
|
||
type = DEVICE_TYPE_KEYBOARD;
|
||
break;
|
||
case LOGITECH_DEVICE_TYPE_MOUSE:
|
||
case LOGITECH_DEVICE_TYPE_TRACKBALL:
|
||
type = DEVICE_TYPE_MOUSE;
|
||
break;
|
||
case LOGITECH_DEVICE_TYPE_HEADSET:
|
||
type = DEVICE_TYPE_HEADSET;
|
||
break;
|
||
case LOGITECH_DEVICE_TYPE_MOUSEPAD:
|
||
type = DEVICE_TYPE_MOUSEMAT;
|
||
break;
|
||
default:
|
||
type = DEVICE_TYPE_UNKNOWN;
|
||
break;
|
||
}
|
||
|
||
/*----------------------------------------------------------*\
|
||
| Build mode list from discovered capabilities |
|
||
\*----------------------------------------------------------*/
|
||
|
||
/*----------------------------------------------------------*\
|
||
| Direct mode: per-key control via 0x8081 |
|
||
\*----------------------------------------------------------*/
|
||
if(caps.has_perkey)
|
||
{
|
||
mode Direct;
|
||
Direct.name = "Direct";
|
||
Direct.value = 0;
|
||
Direct.flags = MODE_FLAG_HAS_PER_LED_COLOR;
|
||
Direct.color_mode = MODE_COLORS_PER_LED;
|
||
modes.push_back(Direct);
|
||
}
|
||
|
||
/*----------------------------------------------------------*\
|
||
| Off mode: always available |
|
||
\*----------------------------------------------------------*/
|
||
{
|
||
mode Off;
|
||
Off.name = "Off";
|
||
Off.value = 0xFF;
|
||
Off.flags = 0;
|
||
Off.color_mode = MODE_COLORS_NONE;
|
||
modes.push_back(Off);
|
||
}
|
||
|
||
/*----------------------------------------------------------*\
|
||
| 0x0620 Headset RGB Hostmode has no effect cards. Provide |
|
||
| a single Direct mode that maps every LED to the frame |
|
||
| buffer; SetHeadsetRGBHostmodeColors writes them straight |
|
||
| to the earcup zones. |
|
||
\*----------------------------------------------------------*/
|
||
if(caps.is_headset_rgb_hostmode)
|
||
{
|
||
mode Direct;
|
||
Direct.name = "Direct";
|
||
Direct.value = 0;
|
||
Direct.flags = MODE_FLAG_HAS_PER_LED_COLOR;
|
||
Direct.color_mode = MODE_COLORS_PER_LED;
|
||
modes.push_back(Direct);
|
||
}
|
||
|
||
/*----------------------------------------------------------*\
|
||
| Effect modes from zone cluster discovery |
|
||
| Scan effects from the first cluster (effects are usually |
|
||
| the same across clusters) |
|
||
\*----------------------------------------------------------*/
|
||
if(caps.has_zone_effects && !caps.zone_clusters.empty() && !caps.is_headset_rgb_hostmode)
|
||
{
|
||
const HIDPP20ZoneCluster& cluster = caps.zone_clusters[0];
|
||
|
||
for(size_t i = 0; i < cluster.effects.size(); i++)
|
||
{
|
||
const HIDPP20Effect& fx = cluster.effects[i];
|
||
|
||
switch(fx.effect_id)
|
||
{
|
||
case 0x0001: // Static / Fixed Color
|
||
{
|
||
mode Static;
|
||
Static.name = "Static";
|
||
Static.value = fx.index;
|
||
|
||
/*-----------------------------------------------------*\
|
||
| Multi-cluster devices (mice with logo/scroll/DPI) |
|
||
| get per-LED colors so each zone can be painted |
|
||
| independently in Static. Single-cluster devices |
|
||
| (keyboards, single-zone mice) keep the single-color |
|
||
| MODE_COLORS_MODE_SPECIFIC UX. |
|
||
\*-----------------------------------------------------*/
|
||
if(caps.zone_clusters.size() > 1)
|
||
{
|
||
Static.flags = MODE_FLAG_HAS_PER_LED_COLOR;
|
||
Static.color_mode = MODE_COLORS_PER_LED;
|
||
}
|
||
else
|
||
{
|
||
Static.flags = MODE_FLAG_HAS_MODE_SPECIFIC_COLOR;
|
||
Static.colors_min = 1;
|
||
Static.colors_max = 1;
|
||
Static.color_mode = MODE_COLORS_MODE_SPECIFIC;
|
||
Static.colors.resize(1);
|
||
}
|
||
modes.push_back(Static);
|
||
break;
|
||
}
|
||
|
||
case 0x0003: // Color Cycle / Spectrum
|
||
{
|
||
mode Cycle;
|
||
Cycle.name = "Spectrum Cycle";
|
||
Cycle.value = fx.index;
|
||
Cycle.flags = MODE_FLAG_HAS_SPEED
|
||
| MODE_FLAG_HAS_BRIGHTNESS;
|
||
Cycle.speed_min = HIDPP20_SPEED_SLIDER_MIN;
|
||
Cycle.speed_max = HIDPP20_SPEED_SLIDER_MAX;
|
||
Cycle.speed = 80; /* ~4.9s, lively medium */
|
||
Cycle.brightness_min = 1;
|
||
Cycle.brightness_max = 100;
|
||
Cycle.brightness = 100;
|
||
Cycle.color_mode = MODE_COLORS_NONE;
|
||
modes.push_back(Cycle);
|
||
break;
|
||
}
|
||
|
||
case 0x000A: // Breathing
|
||
{
|
||
mode Breathing;
|
||
Breathing.name = "Breathing";
|
||
Breathing.value = fx.index;
|
||
Breathing.speed_min = HIDPP20_SPEED_SLIDER_MIN;
|
||
Breathing.speed_max = HIDPP20_SPEED_SLIDER_MAX;
|
||
Breathing.speed = 70; /* ~6.8s, calm medium */
|
||
Breathing.brightness_min = 1;
|
||
Breathing.brightness_max = 100;
|
||
Breathing.brightness = 100;
|
||
|
||
/*-----------------------------------------------------*\
|
||
| See Static above — multi-cluster gets per-LED colors. |
|
||
\*-----------------------------------------------------*/
|
||
if(caps.zone_clusters.size() > 1)
|
||
{
|
||
Breathing.flags = MODE_FLAG_HAS_PER_LED_COLOR
|
||
| MODE_FLAG_HAS_SPEED
|
||
| MODE_FLAG_HAS_BRIGHTNESS;
|
||
Breathing.color_mode = MODE_COLORS_PER_LED;
|
||
}
|
||
else
|
||
{
|
||
Breathing.flags = MODE_FLAG_HAS_MODE_SPECIFIC_COLOR
|
||
| MODE_FLAG_HAS_SPEED
|
||
| MODE_FLAG_HAS_BRIGHTNESS;
|
||
Breathing.colors_min = 1;
|
||
Breathing.colors_max = 1;
|
||
Breathing.color_mode = MODE_COLORS_MODE_SPECIFIC;
|
||
Breathing.colors.resize(1);
|
||
}
|
||
modes.push_back(Breathing);
|
||
break;
|
||
}
|
||
|
||
case 0x0004: // Color Wave
|
||
{
|
||
mode Wave;
|
||
Wave.name = "Color Wave";
|
||
Wave.value = fx.index;
|
||
Wave.flags = MODE_FLAG_HAS_SPEED
|
||
| MODE_FLAG_HAS_BRIGHTNESS;
|
||
Wave.speed_min = HIDPP20_SPEED_SLIDER_MIN;
|
||
Wave.speed_max = HIDPP20_SPEED_SLIDER_MAX;
|
||
Wave.speed = 80; /* ~4.9s, lively medium */
|
||
Wave.brightness_min = 1;
|
||
Wave.brightness_max = 100;
|
||
Wave.brightness = 100;
|
||
Wave.color_mode = MODE_COLORS_NONE;
|
||
modes.push_back(Wave);
|
||
break;
|
||
}
|
||
|
||
case 0x000B: // Ripple
|
||
{
|
||
mode Ripple;
|
||
Ripple.name = "Ripple";
|
||
Ripple.value = fx.index;
|
||
Ripple.flags = MODE_FLAG_HAS_MODE_SPECIFIC_COLOR
|
||
| MODE_FLAG_HAS_SPEED
|
||
| MODE_FLAG_HAS_BRIGHTNESS;
|
||
Ripple.speed_min = HIDPP20_SPEED_SLIDER_MIN;
|
||
Ripple.speed_max = HIDPP20_SPEED_SLIDER_MAX;
|
||
Ripple.speed = 70; /* ~6.8s, calm medium */
|
||
Ripple.brightness_min = 1;
|
||
Ripple.brightness_max = 100;
|
||
Ripple.brightness = 100;
|
||
Ripple.colors_min = 1;
|
||
Ripple.colors_max = 1;
|
||
Ripple.color_mode = MODE_COLORS_MODE_SPECIFIC;
|
||
Ripple.colors.resize(1);
|
||
modes.push_back(Ripple);
|
||
break;
|
||
}
|
||
|
||
case 0x0015: // Cycle (saturation variant)
|
||
{
|
||
/*-----------------------------------------------*\
|
||
| Saturation-bearing variant of 0x0003. Same UI |
|
||
| (speed = period, brightness = intensity); the |
|
||
| saturation byte is hardcoded full on the wire. |
|
||
\*-----------------------------------------------*/
|
||
mode Cycle;
|
||
Cycle.name = "Spectrum Cycle";
|
||
Cycle.value = fx.index;
|
||
Cycle.flags = MODE_FLAG_HAS_SPEED
|
||
| MODE_FLAG_HAS_BRIGHTNESS;
|
||
Cycle.speed_min = HIDPP20_SPEED_SLIDER_MIN;
|
||
Cycle.speed_max = HIDPP20_SPEED_SLIDER_MAX;
|
||
Cycle.speed = 80; /* ~4.9s, lively medium */
|
||
Cycle.brightness_min = 1;
|
||
Cycle.brightness_max = 100;
|
||
Cycle.brightness = 100;
|
||
Cycle.color_mode = MODE_COLORS_NONE;
|
||
modes.push_back(Cycle);
|
||
break;
|
||
}
|
||
|
||
case 0x0016: // Wave (saturation variant)
|
||
{
|
||
/*-----------------------------------------------*\
|
||
| Saturation-bearing variant of 0x0004. Period |
|
||
| is a BE16 ms value on the standard 1..20s range |
|
||
| (Solaar's LEDEffects table has no period range |
|
||
| override for Wave); saturation is hardcoded on |
|
||
| the wire. |
|
||
\*-----------------------------------------------*/
|
||
mode Wave;
|
||
Wave.name = "Color Wave";
|
||
Wave.value = fx.index;
|
||
Wave.flags = MODE_FLAG_HAS_SPEED
|
||
| MODE_FLAG_HAS_BRIGHTNESS
|
||
| MODE_FLAG_HAS_DIRECTION_LR
|
||
| MODE_FLAG_HAS_DIRECTION_UD
|
||
| MODE_FLAG_HAS_DIRECTION_HV;
|
||
Wave.speed_min = HIDPP20_SPEED_SLIDER_MIN;
|
||
Wave.speed_max = HIDPP20_SPEED_SLIDER_MAX;
|
||
Wave.speed = 80; /* ~4.9s, lively medium */
|
||
Wave.brightness_min = 1;
|
||
Wave.brightness_max = 100;
|
||
Wave.brightness = 100;
|
||
Wave.direction = MODE_DIRECTION_RIGHT;
|
||
Wave.color_mode = MODE_COLORS_NONE;
|
||
modes.push_back(Wave);
|
||
break;
|
||
}
|
||
|
||
case 0x0017: // Ripple (saturation variant)
|
||
{
|
||
/*-----------------------------------------------*\
|
||
| Saturation-bearing variant of 0x000B. Carries |
|
||
| color + period only — no intensity param, so |
|
||
| no brightness slider. Saturation is hardcoded |
|
||
| full on the wire. |
|
||
\*-----------------------------------------------*/
|
||
mode Ripple;
|
||
Ripple.name = "Ripple";
|
||
Ripple.value = fx.index;
|
||
Ripple.flags = MODE_FLAG_HAS_MODE_SPECIFIC_COLOR
|
||
| MODE_FLAG_HAS_SPEED;
|
||
Ripple.speed_min = HIDPP20_SPEED_SLIDER_MIN;
|
||
Ripple.speed_max = HIDPP20_SPEED_SLIDER_MAX;
|
||
Ripple.speed = 70; /* mid, fast ripple range */
|
||
Ripple.colors_min = 1;
|
||
Ripple.colors_max = 1;
|
||
Ripple.color_mode = MODE_COLORS_MODE_SPECIFIC;
|
||
Ripple.colors.resize(1);
|
||
modes.push_back(Ripple);
|
||
break;
|
||
}
|
||
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
/*---------------------------------------------------------*\
|
||
| On 0x8070 devices every effect write is ephemeral by |
|
||
| default (see DeviceUpdateMode persist branch below). Add |
|
||
| a Save button on firmware-effect modes so users can |
|
||
| explicitly commit the active mode to NVM. Direct is |
|
||
| excluded because per-key framebuffer writes don't map to |
|
||
| a savable firmware effect on 0x8070. 0x8071/0x0600 |
|
||
| already persist on every write, so no Save button is |
|
||
| exposed there pending further research. |
|
||
\*---------------------------------------------------------*/
|
||
if(caps.rgb_feature_page == HIDPP20_FEAT_COLOR_LED_EFFECTS)
|
||
{
|
||
for(size_t i = 0; i < modes.size(); i++)
|
||
{
|
||
if(modes[i].name != "Direct")
|
||
{
|
||
modes[i].flags |= MODE_FLAG_MANUAL_SAVE;
|
||
}
|
||
}
|
||
}
|
||
|
||
SetupZones();
|
||
|
||
/*----------------------------------------------------------*\
|
||
| Register repaint callback and start power manager. |
|
||
| The callback triggers DeviceUpdateLEDs from the power |
|
||
| thread for dim/wake when no animation is driving updates. |
|
||
\*----------------------------------------------------------*/
|
||
controller->SetRepaintCallback(
|
||
std::bind(&RGBController_LogitechHIDPP20::OnRepaintRequest, this));
|
||
|
||
controller->SetReapplyActiveModeCallback(
|
||
std::bind(&RGBController_LogitechHIDPP20::ReapplyActiveMode, this));
|
||
}
|
||
|
||
/*---------------------------------------------------------------*\
|
||
| Repaint callback handler (request_repaint_fn). Invoked from the |
|
||
| power thread for dim/wake when no animation is driving updates. |
|
||
\*---------------------------------------------------------------*/
|
||
void RGBController_LogitechHIDPP20::OnRepaintRequest()
|
||
{
|
||
/*-------------------------------------------------*\
|
||
| If Wake() signaled a full repaint, invalidate |
|
||
| sent_colors so DeviceUpdateLEDs pushes every zone |
|
||
| regardless of delta. Uses HIDPP20_UNCOMMITTED |
|
||
| rather than clear() so sent_colors is non-empty — |
|
||
| that avoids the first_frame / prep trigger while |
|
||
| still forcing a full push. |
|
||
\*-------------------------------------------------*/
|
||
if(controller->ConsumeWakeFullRepaint())
|
||
{
|
||
for(size_t i = 0; i < sent_colors.size(); i++)
|
||
{
|
||
sent_colors[i] = HIDPP20_UNCOMMITTED;
|
||
}
|
||
}
|
||
DeviceUpdateLEDs();
|
||
}
|
||
|
||
RGBController_LogitechHIDPP20::~RGBController_LogitechHIDPP20()
|
||
{
|
||
controller->StopPowerManager();
|
||
delete controller;
|
||
}
|
||
|
||
void RGBController_LogitechHIDPP20::SetupZones()
|
||
{
|
||
const HIDPP20DeviceCapabilities& caps = controller->GetCapabilities();
|
||
|
||
led_to_zone_id.clear();
|
||
sent_colors.clear();
|
||
|
||
if(caps.has_perkey)
|
||
{
|
||
if(caps.device_type == LOGITECH_DEVICE_TYPE_KEYBOARD)
|
||
{
|
||
/*--------------------------------------------------*\
|
||
| Keyboard: use KeyboardLayoutManager for matrix |
|
||
| layout. Derive size from numpad presence, layout |
|
||
| from 0x4540 KeyboardLayout feature. |
|
||
\*--------------------------------------------------*/
|
||
KEYBOARD_SIZE kb_size = caps.has_numpad
|
||
? KEYBOARD_SIZE_FULL
|
||
: KEYBOARD_SIZE_TKL;
|
||
|
||
KEYBOARD_LAYOUT kb_layout;
|
||
|
||
switch(caps.keyboard_layout_code)
|
||
{
|
||
case 3: // German
|
||
case 7: // Swiss
|
||
kb_layout = KEYBOARD_LAYOUT_ISO_QWERTZ;
|
||
break;
|
||
|
||
case 4: // French
|
||
kb_layout = KEYBOARD_LAYOUT_ISO_AZERTY;
|
||
break;
|
||
|
||
case 2: // UK
|
||
case 5: // Spanish
|
||
case 0x0B: // Italian
|
||
case 0x0D: // Portuguese
|
||
case 0x0E: // Belgian
|
||
case 0x0F: // Scandinavian
|
||
case 8: // Nordic
|
||
case 0x16: // Nordic
|
||
case 0x1D: // Nordic
|
||
case 0x21: // Nordic
|
||
case 0x24: // Belgian
|
||
kb_layout = KEYBOARD_LAYOUT_ISO_QWERTY;
|
||
break;
|
||
|
||
case 9: // Japanese
|
||
case 0x3E: // Japanese
|
||
kb_layout = KEYBOARD_LAYOUT_JIS;
|
||
break;
|
||
|
||
case 1: // US
|
||
default:
|
||
kb_layout = KEYBOARD_LAYOUT_ANSI_QWERTY;
|
||
break;
|
||
}
|
||
|
||
KeyboardLayoutManager klm(kb_layout, kb_size);
|
||
|
||
zone perkey_zone;
|
||
perkey_zone.name = ZONE_EN_KEYBOARD;
|
||
perkey_zone.type = ZONE_TYPE_MATRIX;
|
||
perkey_zone.leds_min = klm.GetKeyCount();
|
||
perkey_zone.leds_max = klm.GetKeyCount();
|
||
perkey_zone.leds_count = klm.GetKeyCount();
|
||
|
||
matrix_map_type* new_map = new matrix_map_type;
|
||
new_map->height = klm.GetRowCount();
|
||
new_map->width = klm.GetColumnCount();
|
||
new_map->map = new unsigned int[new_map->height * new_map->width];
|
||
klm.GetKeyMap(new_map->map, KEYBOARD_MAP_FILL_TYPE_COUNT,
|
||
new_map->height, new_map->width);
|
||
perkey_zone.matrix_map = new_map;
|
||
zones.push_back(perkey_zone);
|
||
|
||
for(unsigned int i = 0; i < klm.GetKeyCount(); i++)
|
||
{
|
||
led new_led;
|
||
std::string key_name = klm.GetKeyNameAt(i);
|
||
new_led.name = key_name;
|
||
|
||
/*---------------------------------------------*\
|
||
| Look up zone ID by key name |
|
||
\*---------------------------------------------*/
|
||
std::map<std::string, unsigned int>::const_iterator it = hidpp20_key_name_to_zone.find(key_name);
|
||
unsigned int zone_id = (it != hidpp20_key_name_to_zone.end()) ? it->second : 0;
|
||
new_led.value = zone_id;
|
||
leds.push_back(new_led);
|
||
|
||
led_to_zone_id.push_back((uint16_t)zone_id);
|
||
}
|
||
|
||
/*-------------------------------------------------*\
|
||
| Extras: zones the device reported via paginated |
|
||
| 0x8081 GetInfo that aren't covered by KLM. On a |
|
||
| G915 this is media keys, G1-G5, brightness, and |
|
||
| logo. Append them as a separate linear zone so |
|
||
| users can still address them. |
|
||
\*-------------------------------------------------*/
|
||
std::set<uint16_t> klm_claimed_zones;
|
||
for(uint16_t zid : led_to_zone_id)
|
||
{
|
||
if(zid != 0)
|
||
{
|
||
klm_claimed_zones.insert(zid);
|
||
}
|
||
}
|
||
|
||
/*---------------------------------------------------*\
|
||
| Extras candidates: zones reported by the device |
|
||
| that aren't claimed by KLM AND have a known name |
|
||
| in hidpp20_key_name_to_zone. We deliberately DROP |
|
||
| unnamed zones — firmware-side GetInfo bitmaps |
|
||
| enumerate phantom/reserved slots (G515 reports |
|
||
| 47, 97, 99-103, 254 among others) that aren't |
|
||
| wired to physical LEDs. Exposing them as "LED N" |
|
||
| created ghost entries in the GUI. Treat |
|
||
| hidpp20_key_name_to_zone as the curated allowlist. |
|
||
\*---------------------------------------------------*/
|
||
std::vector<std::pair<uint16_t, std::string>> extras;
|
||
for(uint16_t zid : caps.perkey_zone_ids)
|
||
{
|
||
if(klm_claimed_zones.count(zid) != 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
std::string label;
|
||
for(const std::pair<const std::string, unsigned int>& kv : hidpp20_key_name_to_zone)
|
||
{
|
||
if(kv.second == zid)
|
||
{
|
||
label = kv.first;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if(label.empty())
|
||
{
|
||
LOG_DEBUG("[LogitechHID++2.0 %s] Dropping unnamed per-key zone %u "
|
||
"(not in hidpp20_key_name_to_zone)",
|
||
name.c_str(), (unsigned)zid);
|
||
continue;
|
||
}
|
||
|
||
extras.emplace_back(zid, label);
|
||
}
|
||
|
||
if(!extras.empty())
|
||
{
|
||
zone extras_zone;
|
||
extras_zone.name = "Extras";
|
||
extras_zone.type = ZONE_TYPE_LINEAR;
|
||
extras_zone.leds_min = (unsigned int)extras.size();
|
||
extras_zone.leds_max = (unsigned int)extras.size();
|
||
extras_zone.leds_count = (unsigned int)extras.size();
|
||
extras_zone.matrix_map = nullptr;
|
||
zones.push_back(extras_zone);
|
||
|
||
for(size_t i = 0; i < extras.size(); i++)
|
||
{
|
||
led new_led;
|
||
new_led.name = extras[i].second;
|
||
new_led.value = extras[i].first;
|
||
leds.push_back(new_led);
|
||
led_to_zone_id.push_back(extras[i].first);
|
||
}
|
||
}
|
||
}
|
||
else if(const MouseLayout* ml = FindMouseLayout(caps.device_name))
|
||
{
|
||
/*--------------------------------------------------*\
|
||
| Known mouse: use table-defined matrix layout |
|
||
\*--------------------------------------------------*/
|
||
zone perkey_zone;
|
||
perkey_zone.name = "Mouse LEDs";
|
||
perkey_zone.type = ZONE_TYPE_MATRIX;
|
||
perkey_zone.leds_min = ml->led_count;
|
||
perkey_zone.leds_max = ml->led_count;
|
||
perkey_zone.leds_count = ml->led_count;
|
||
|
||
matrix_map_type* new_map = new matrix_map_type;
|
||
new_map->height = ml->rows;
|
||
new_map->width = ml->cols;
|
||
new_map->map = new unsigned int[ml->rows * ml->cols];
|
||
memcpy(new_map->map, ml->map, ml->rows * ml->cols * sizeof(unsigned int));
|
||
perkey_zone.matrix_map = new_map;
|
||
zones.push_back(perkey_zone);
|
||
|
||
for(unsigned int i = 0; i < ml->led_count && i < caps.perkey_zone_ids.size(); i++)
|
||
{
|
||
led new_led;
|
||
new_led.name = ml->led_names[i];
|
||
new_led.value = caps.perkey_zone_ids[i];
|
||
leds.push_back(new_led);
|
||
|
||
led_to_zone_id.push_back(caps.perkey_zone_ids[i]);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
/*-------------------------------------------------*\
|
||
| Other devices: linear zone with auto-named LEDs |
|
||
\*-------------------------------------------------*/
|
||
zone perkey_zone;
|
||
perkey_zone.name = "LEDs";
|
||
perkey_zone.type = ZONE_TYPE_LINEAR;
|
||
perkey_zone.leds_min = (unsigned int)caps.perkey_zone_ids.size();
|
||
perkey_zone.leds_max = (unsigned int)caps.perkey_zone_ids.size();
|
||
perkey_zone.leds_count = (unsigned int)caps.perkey_zone_ids.size();
|
||
perkey_zone.matrix_map = nullptr;
|
||
zones.push_back(perkey_zone);
|
||
|
||
for(size_t i = 0; i < caps.perkey_zone_ids.size(); i++)
|
||
{
|
||
led new_led;
|
||
new_led.name = "LED " + std::to_string(caps.perkey_zone_ids[i]);
|
||
new_led.value = caps.perkey_zone_ids[i];
|
||
leds.push_back(new_led);
|
||
|
||
led_to_zone_id.push_back(caps.perkey_zone_ids[i]);
|
||
}
|
||
}
|
||
}
|
||
else if(caps.is_headset_rgb_hostmode)
|
||
{
|
||
/*------------------------------------------------------*\
|
||
| Headset RGB hostmode (0x0620): single linear zone with |
|
||
| one LED per discovered earcup zone ID. |
|
||
\*------------------------------------------------------*/
|
||
size_t led_count = caps.headset_rgb_hostmode_zone_ids.size();
|
||
|
||
zone headset_zone;
|
||
headset_zone.name = "Headset";
|
||
headset_zone.type = ZONE_TYPE_LINEAR;
|
||
headset_zone.leds_min = (unsigned int)led_count;
|
||
headset_zone.leds_max = (unsigned int)led_count;
|
||
headset_zone.leds_count = (unsigned int)led_count;
|
||
headset_zone.matrix_map = nullptr;
|
||
zones.push_back(headset_zone);
|
||
|
||
for(size_t i = 0; i < led_count; i++)
|
||
{
|
||
led new_led;
|
||
new_led.name = (i == 0) ? "Left Earcup"
|
||
: (i == 1) ? "Right Earcup"
|
||
: "Zone " + std::to_string(i);
|
||
new_led.value = caps.headset_rgb_hostmode_zone_ids[i];
|
||
leds.push_back(new_led);
|
||
|
||
led_to_zone_id.push_back(caps.headset_rgb_hostmode_zone_ids[i]);
|
||
}
|
||
}
|
||
else if(caps.has_zone_effects)
|
||
{
|
||
/*------------------------------------------------------*\
|
||
| No per-key: create one zone per cluster |
|
||
\*------------------------------------------------------*/
|
||
for(size_t i = 0; i < caps.zone_clusters.size(); i++)
|
||
{
|
||
const HIDPP20ZoneCluster& cluster = caps.zone_clusters[i];
|
||
|
||
zone new_zone;
|
||
new_zone.name = zone_location_name(cluster.location);
|
||
new_zone.type = ZONE_TYPE_SINGLE;
|
||
new_zone.leds_min = 1;
|
||
new_zone.leds_max = 1;
|
||
new_zone.leds_count = 1;
|
||
new_zone.matrix_map = nullptr;
|
||
zones.push_back(new_zone);
|
||
|
||
led new_led;
|
||
new_led.name = new_zone.name;
|
||
new_led.value = cluster.index;
|
||
leds.push_back(new_led);
|
||
|
||
led_to_zone_id.push_back(cluster.index);
|
||
}
|
||
}
|
||
|
||
/*---------------------------------------------------------*\
|
||
| Build the zone_id -> LED index reverse map. Indexed |
|
||
| 0..255 (zone IDs are bytes); -1 marks "no LED for this |
|
||
| zone". Used by the FrameEnd commit step to translate the |
|
||
| acked_zones list back into LED indices for sent_colors. |
|
||
\*---------------------------------------------------------*/
|
||
zone_id_to_led_idx.assign(256, -1);
|
||
|
||
for(size_t i = 0; i < led_to_zone_id.size(); i++)
|
||
{
|
||
uint16_t zid = led_to_zone_id[i];
|
||
if(zid > 0 && zid < 256)
|
||
{
|
||
zone_id_to_led_idx[zid] = (int)i;
|
||
}
|
||
}
|
||
|
||
SetupColors();
|
||
}
|
||
|
||
void RGBController_LogitechHIDPP20::ResizeZone(int /*zone*/, int /*new_size*/)
|
||
{
|
||
}
|
||
|
||
void RGBController_LogitechHIDPP20::DeviceUpdateLEDs()
|
||
{
|
||
if(!controller->IsOnline())
|
||
{
|
||
return;
|
||
}
|
||
|
||
/*----------------------------------------------------------*\
|
||
| Ensure SW control is claimed on first actual color push. |
|
||
| Safe here because we have real colors in the buffer. |
|
||
\*----------------------------------------------------------*/
|
||
controller->ClaimSWControlIfNeeded();
|
||
|
||
const HIDPP20DeviceCapabilities& caps = controller->GetCapabilities();
|
||
|
||
/*----------------------------------------------------------*\
|
||
| Frame handling during SLEEPING: |
|
||
| |
|
||
| Default — suppress frames. A suppressed frame cannot |
|
||
| wake a device that treats writes as activity, so this is |
|
||
| the safe choice when we don't know how a particular |
|
||
| firmware handles host traffic during its fade. |
|
||
| |
|
||
| Quirk-gated — devices flagged FADE_ACCEPTS_WRITES opt out |
|
||
| of suppression because their firmware accepts writes |
|
||
| without cancelling sleep. Frames flow through SLEEPING |
|
||
| until deep sleep starts BUSY-NACKing every FrameEnd; |
|
||
| consecutive-failure tracking then sets deep_sleep and the |
|
||
| top IsDeepSleep() check takes over. |
|
||
| |
|
||
| Both paths suppress until Wake() clears the state. |
|
||
\*----------------------------------------------------------*/
|
||
if(controller->IsDeepSleep())
|
||
{
|
||
return;
|
||
}
|
||
|
||
if(controller->GetPowerState() == HIDPP20_POWER_SLEEPING
|
||
&& !(caps.quirks & HIDPP20_QUIRK_FADE_ACCEPTS_WRITES))
|
||
{
|
||
return;
|
||
}
|
||
|
||
/*----------------------------------------------------------*\
|
||
| Feature 0x0620 Headset RGB Hostmode (Centurion G522 / |
|
||
| PRO X 2). Static-color only, two earcup zones. Bypasses |
|
||
| per-key, SetZoneEffect, and effect-card paths entirely — |
|
||
| 0x0620 has none of that. Claim was made once in |
|
||
| SetHostMode(); we just write colors + FrameEnd[0x01]. |
|
||
\*----------------------------------------------------------*/
|
||
if(caps.is_headset_rgb_hostmode)
|
||
{
|
||
controller->SetHeadsetRGBHostmodeColors(colors);
|
||
return;
|
||
}
|
||
|
||
if(caps.has_perkey && (unsigned int)active_mode < modes.size() &&
|
||
modes[active_mode].color_mode == MODE_COLORS_PER_LED)
|
||
{
|
||
uint8_t perkey_idx = (caps.idx_perkey_v2 != 0) ? caps.idx_perkey_v2 : caps.idx_perkey_v1;
|
||
|
||
/*------------------------------------------------------*\
|
||
| Detect re-initialization (reconnect, wake from sleep). |
|
||
| Device state is unknown — force full resend. |
|
||
\*------------------------------------------------------*/
|
||
uint32_t gen = controller->GetInitGeneration();
|
||
|
||
if(gen != last_init_gen)
|
||
{
|
||
sent_colors.clear();
|
||
last_init_gen = gen;
|
||
}
|
||
|
||
/*-------------------------------------------------------*\
|
||
| Per-key prep call. Two paths, selected by a runtime |
|
||
| capability probe at feature-discovery time: |
|
||
| |
|
||
| (A) Observed prep via DoObservedPerKeyPrep — two |
|
||
| SetEffectByIndex calls on 0x8071 cloned from |
|
||
| the observed vendor-app wire behavior. The |
|
||
| template bytes at |
|
||
| prep1 params[6..7] and the effectIdx at prep2 are |
|
||
| parameterized from device-discovery results |
|
||
| (caps.effect_card_template[], and |
|
||
| caps.zone_clusters[0].effects.size() respectively) |
|
||
| so the same code adapts to any device that shares |
|
||
| the G502-family prep pattern. |
|
||
| |
|
||
| Gated on caps.has_effect_cards, which is set by |
|
||
| DiscoverEffectCards iff the device responds |
|
||
| successfully to GetEffectSpecificInfo. Devices |
|
||
| without firmware effect cards leave this false |
|
||
| and fall through to path (B). |
|
||
| |
|
||
| (B) Static-pass-through prep (original fork behavior, |
|
||
| doc-verified on G515) — applied when the device |
|
||
| has no effect cards or uses 0x8070 / 0x0600 |
|
||
| instead of 0x8071. SetEffect cluster=0xFF, |
|
||
| effect=Static, RGB=(0,0,0), no fixed-color marker, |
|
||
| persist=1. |
|
||
| |
|
||
| An earlier revision used `effects.size() < 5` as a |
|
||
| heuristic proxy for "G502-shaped" devices. The proxy |
|
||
| accidentally correlated with "has effect cards" on |
|
||
| the two devices we knew about but had no principled |
|
||
| meaning — it's been replaced with the direct capability |
|
||
| probe. |
|
||
\*-------------------------------------------------------*/
|
||
bool needs_prep = controller->NeedsPrepSequence();
|
||
|
||
if(needs_prep && caps.has_zone_effects)
|
||
{
|
||
bool shape_matches_keyboard_family =
|
||
caps.idx_disable_keys_by_usage != 0
|
||
&& caps.idx_perkey_v2 != 0
|
||
&& caps.rgb_feature_page == HIDPP20_FEAT_RGB_EFFECTS;
|
||
|
||
bool shape_matches_observed_prep =
|
||
caps.has_effect_cards
|
||
&& caps.rgb_feature_page == HIDPP20_FEAT_RGB_EFFECTS;
|
||
|
||
if(shape_matches_keyboard_family)
|
||
{
|
||
/*---------------------------------------------*\
|
||
| G815 / G915 / G Pro: per-cluster Off + primer |
|
||
| key + FrameEnd. Matches their legacy |
|
||
| InitializeDirect wire sequence. |
|
||
\*---------------------------------------------*/
|
||
controller->DoKeyboardFamilyPerKeyPrep();
|
||
}
|
||
else if(shape_matches_observed_prep)
|
||
{
|
||
controller->DoObservedPerKeyPrep();
|
||
}
|
||
else
|
||
{
|
||
uint8_t static_effect_idx = 0;
|
||
|
||
for(size_t j = 0; j < caps.zone_clusters[0].effects.size(); j++)
|
||
{
|
||
if(caps.zone_clusters[0].effects[j].effect_id == 0x0001)
|
||
{
|
||
static_effect_idx = caps.zone_clusters[0].effects[j].index;
|
||
break;
|
||
}
|
||
}
|
||
|
||
controller->SetZoneEffect(
|
||
0xFF, /* all clusters */
|
||
static_effect_idx,
|
||
0x0001, /* static effect */
|
||
0, 0, 0, /* black — no fixed-color marker */
|
||
0,
|
||
100, /* brightness — unused for static */
|
||
0, /* direction — unused for static */
|
||
true /* persist=true */);
|
||
}
|
||
}
|
||
|
||
/*------------------------------------------------------*\
|
||
| Snapshot colors to avoid races with effects updating |
|
||
| the colors array while we're sending. |
|
||
\*------------------------------------------------------*/
|
||
std::vector<RGBColor> snapshot(colors.begin(), colors.end());
|
||
|
||
/*-------------------------------------------------------*\
|
||
| Apply dim brightness scaling if not at full brightness. |
|
||
| This modifies the OUTPUT only — the internal colors[] |
|
||
| buffer stays at full brightness for the animation. |
|
||
\*-------------------------------------------------------*/
|
||
int brightness = controller->GetDimBrightness();
|
||
|
||
if(brightness < 100)
|
||
{
|
||
for(size_t i = 0; i < snapshot.size(); i++)
|
||
{
|
||
uint8_t r = RGBGetRValue(snapshot[i]) * brightness / 100;
|
||
uint8_t g = RGBGetGValue(snapshot[i]) * brightness / 100;
|
||
uint8_t b = RGBGetBValue(snapshot[i]) * brightness / 100;
|
||
snapshot[i] = ToRGBColor(r, g, b);
|
||
}
|
||
}
|
||
|
||
/*------------------------------------------------------*\
|
||
| Compute delta against last committed state. |
|
||
| First call (sent_colors empty) sends everything. |
|
||
\*------------------------------------------------------*/
|
||
bool full_update = (sent_colors.size() != snapshot.size());
|
||
|
||
std::map<RGBColor, std::vector<uint8_t>> color_to_zones;
|
||
|
||
for(size_t i = 0; i < snapshot.size() && i < led_to_zone_id.size(); i++)
|
||
{
|
||
if(led_to_zone_id[i] == 0 || led_to_zone_id[i] > 255)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if(full_update || snapshot[i] != sent_colors[i])
|
||
{
|
||
color_to_zones[snapshot[i]].push_back((uint8_t)led_to_zone_id[i]);
|
||
}
|
||
}
|
||
|
||
if(color_to_zones.empty())
|
||
{
|
||
return;
|
||
}
|
||
|
||
/*-----------------------------------------------------*\
|
||
| Drain stale ACKs from previous frames before sending. |
|
||
| Without this, SendPerKeyData reads a stale ACK, |
|
||
| mistakes it for the current write's ACK, returns |
|
||
| early, and FrameEnd then races with the actual ACK. |
|
||
\*-----------------------------------------------------*/
|
||
controller->FlushResponseQueue();
|
||
|
||
/*------------------------------------------------------*\
|
||
| Batch changed keys for efficient wire encoding. |
|
||
| |
|
||
| For same-color groups (>= 2 keys): |
|
||
| Sort zone IDs and find contiguous runs. |
|
||
| fn5 (SET_RANGE) for runs of 3+: |
|
||
| [start, end, R, G, B] × 3 per packet |
|
||
| fn6 (SET_SINGLE_VALUE) for scattered remainder: |
|
||
| [R, G, B, zid, zid, ...] up to 13 per packet |
|
||
| |
|
||
| For single-occurrence colors: |
|
||
| fn1 (SET_INDIVIDUAL): [zid,R,G,B] × 4 per packet |
|
||
\*------------------------------------------------------*/
|
||
std::vector<std::pair<uint16_t, RGBColor>> individual_pairs;
|
||
|
||
for(std::pair<const RGBColor, std::vector<uint8_t>>& entry : color_to_zones)
|
||
{
|
||
RGBColor color = entry.first;
|
||
std::vector<uint8_t>& zone_ids = entry.second;
|
||
|
||
if(zone_ids.size() >= 2)
|
||
{
|
||
uint8_t r = RGBGetRValue(color);
|
||
uint8_t g = RGBGetGValue(color);
|
||
uint8_t b = RGBGetBValue(color);
|
||
|
||
/*--------------------------------------------------*\
|
||
| Sort zone IDs and extract contiguous runs for fn5 |
|
||
\*--------------------------------------------------*/
|
||
std::sort(zone_ids.begin(), zone_ids.end());
|
||
|
||
std::vector<std::pair<uint8_t, uint8_t>> ranges;
|
||
std::vector<uint8_t> 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<uint8_t> 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<uint8_t> packet_zones;
|
||
data[0] = r;
|
||
data[1] = g;
|
||
data[2] = b;
|
||
|
||
size_t batch = scattered.size() - i;
|
||
if(batch > 13) batch = 13;
|
||
|
||
for(size_t j = 0; j < batch; j++)
|
||
{
|
||
data[3 + j] = scattered[i + j];
|
||
packet_zones.push_back(scattered[i + j]);
|
||
}
|
||
|
||
controller->SendPerKeyData(perkey_idx, FN_8081_SET_SINGLE_VALUE,
|
||
data, 3 + batch, packet_zones);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
for(uint8_t zid : zone_ids)
|
||
{
|
||
individual_pairs.push_back({zid, color});
|
||
}
|
||
}
|
||
}
|
||
|
||
if(!individual_pairs.empty())
|
||
{
|
||
controller->SetPerKeyColors(individual_pairs);
|
||
}
|
||
|
||
PerKeyFrameResult commit = controller->PerKeyFrameEnd();
|
||
|
||
/*-----------------------------------------------------*\
|
||
| A frame is "fully committed" only if FrameEnd ACKed |
|
||
| AND every attempted zone also ACKed. FrameEnd alone |
|
||
| is not enough — the firmware happily ACKs FrameEnd |
|
||
| even when prior per-key writes were silently dropped |
|
||
| (observed on G502 X PLUS during wireless reconnect |
|
||
| transients, where the Set* writes return no response |
|
||
| but FrameEnd still lands cleanly). |
|
||
\*-----------------------------------------------------*/
|
||
bool full_commit = commit.frame_end_acked
|
||
&& (commit.acked_zones.size()
|
||
== commit.attempted_zones.size());
|
||
|
||
/*-----------------------------------------------------*\
|
||
| Upgrade SW control flags from 6 → 5 once the per-key |
|
||
| layer is populated. ClaimSWControlIfNeeded leaves the |
|
||
| device at flags=6 (effect engine still autonomous) to |
|
||
| avoid the onboard→host transition flash; now that the |
|
||
| per-key layer is masking zone output, it's safe (and |
|
||
| required for idle/wake event generation) to claim the |
|
||
| effect bit. No-op if the upgrade has already happened |
|
||
| or if claim itself hasn't occurred. |
|
||
| |
|
||
| Gated on full_commit: upgrading into flags=5 with an |
|
||
| empty per-key buffer would leave the firmware with |
|
||
| nothing to render and expose its default LED buffer |
|
||
| (warm-white on the G502 X PLUS). |
|
||
\*-----------------------------------------------------*/
|
||
if(full_commit)
|
||
{
|
||
controller->UpgradeSwControlAfterFirstPaint();
|
||
}
|
||
|
||
/*------------------------------------------------------*\
|
||
| Retry scheduling — ONLY on the critical first paint |
|
||
| after a fresh claim (needs_prep == true). Streaming |
|
||
| animation frames regularly partial-commit due to |
|
||
| fire-and-forget timing, and the delta carry-over |
|
||
| (HIDPP20_UNCOMMITTED) already handles missed zones on |
|
||
| the next animation tick. Scheduling retries on every |
|
||
| partial streaming frame causes the power thread's |
|
||
| TickRetryPaintIfPending to fire request_repaint_fn |
|
||
| between animation frames, colliding with the animation |
|
||
| loop and producing visible stalls. |
|
||
| |
|
||
| For the first-paint-after-claim case (needs_prep), |
|
||
| there IS no "next animation frame" guaranteed, so the |
|
||
| retry is the only mechanism to recover from a partial |
|
||
| commit during the reconnect-transient window. |
|
||
\*------------------------------------------------------*/
|
||
if(full_commit || !needs_prep)
|
||
{
|
||
controller->CancelRetryPaint();
|
||
}
|
||
else
|
||
{
|
||
controller->ScheduleRetryPaint();
|
||
}
|
||
|
||
/*-------------------------------------------------------*\
|
||
| Ensure sent_colors is sized to the snapshot before |
|
||
| the commit loop writes by index. On the first frame |
|
||
| (or after a reinit clear) sent_colors is empty, and |
|
||
| the per-zone writes below would silently no-op, |
|
||
| leaving sent_colors empty and re-firing the prep call |
|
||
| on every subsequent frame. |
|
||
| |
|
||
| Initial fill is HIDPP20_UNCOMMITTED so any LED that we |
|
||
| did not touch this frame stays "uncommitted" and gets |
|
||
| scheduled for the next delta. |
|
||
\*-------------------------------------------------------*/
|
||
if(sent_colors.size() != snapshot.size())
|
||
{
|
||
sent_colors.assign(snapshot.size(), HIDPP20_UNCOMMITTED);
|
||
}
|
||
|
||
/*-----------------------------------------------------*\
|
||
| Build a fast lookup of acked zones for this frame. |
|
||
\*-----------------------------------------------------*/
|
||
std::set<uint8_t> acked_set(commit.acked_zones.begin(),
|
||
commit.acked_zones.end());
|
||
|
||
if(commit.frame_end_acked)
|
||
{
|
||
/*----------------------------------------------------*\
|
||
| Frame end ACKed: any zone whose write packet also |
|
||
| ACKed is now committed — advance sent_colors for |
|
||
| that LED. Any zone we attempted but never saw an |
|
||
| ACK for goes to HIDPP20_UNCOMMITTED so the next |
|
||
| frame's delta picks it up. |
|
||
\*----------------------------------------------------*/
|
||
for(uint8_t zid : commit.attempted_zones)
|
||
{
|
||
int led_idx = zone_id_to_led_idx[zid];
|
||
if(led_idx < 0 || (size_t)led_idx >= sent_colors.size())
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if(acked_set.count(zid))
|
||
{
|
||
sent_colors[led_idx] = snapshot[led_idx];
|
||
}
|
||
else
|
||
{
|
||
sent_colors[led_idx] = HIDPP20_UNCOMMITTED;
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
/*---------------------------------------------------*\
|
||
| Frame end timed out: we don't know what the device |
|
||
| committed. Mark every attempted LED uncommitted so |
|
||
| the next frame re-pushes them all. Don't bother |
|
||
| with the per-zone ACK info here — if FrameEnd |
|
||
| didn't land, the per-key writes that did ACK still |
|
||
| sit in the staging buffer un-swapped. |
|
||
\*---------------------------------------------------*/
|
||
for(uint8_t zid : commit.attempted_zones)
|
||
{
|
||
int led_idx = zone_id_to_led_idx[zid];
|
||
if(led_idx >= 0 && (size_t)led_idx < sent_colors.size())
|
||
{
|
||
sent_colors[led_idx] = HIDPP20_UNCOMMITTED;
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
}
|
||
|
||
void RGBController_LogitechHIDPP20::UpdateZoneLEDs(int /*zone*/)
|
||
{
|
||
DeviceUpdateLEDs();
|
||
}
|
||
|
||
void RGBController_LogitechHIDPP20::UpdateSingleLED(int /*led*/)
|
||
{
|
||
DeviceUpdateLEDs();
|
||
}
|
||
|
||
void RGBController_LogitechHIDPP20::DeviceUpdateMode()
|
||
{
|
||
if(!controller->IsOnline())
|
||
{
|
||
return;
|
||
}
|
||
|
||
/*----------------------------------------------------------*\
|
||
| Drop mode changes while the firmware is fading to off. |
|
||
| The device owns its own power state — we don't force-wake |
|
||
| it from software. active_mode stays tracked framework- |
|
||
| side, and the next wake (firmware onUserActivity) or |
|
||
| reconnect will re-apply it through the reinit callback. |
|
||
\*----------------------------------------------------------*/
|
||
if(controller->GetPowerState() == HIDPP20_POWER_SLEEPING)
|
||
{
|
||
return;
|
||
}
|
||
|
||
/*----------------------------------------------------------*\
|
||
| Claim SW control on first mode set (deferred from init). |
|
||
\*----------------------------------------------------------*/
|
||
controller->ClaimSWControlIfNeeded();
|
||
|
||
const HIDPP20DeviceCapabilities& caps = controller->GetCapabilities();
|
||
|
||
if((unsigned int)active_mode >= modes.size())
|
||
{
|
||
return;
|
||
}
|
||
|
||
const mode& current = modes[active_mode];
|
||
|
||
/*----------------------------------------------------------*\
|
||
| Direct mode: invalidate delta tracking so the next |
|
||
| DeviceUpdateLEDs sends a full frame with actual colors. |
|
||
\*----------------------------------------------------------*/
|
||
if(current.name == "Direct")
|
||
{
|
||
sent_colors.clear();
|
||
DeviceUpdateLEDs();
|
||
|
||
/*------------------------------------------------------*\
|
||
| Start power manager (reader + power threads) if not |
|
||
| already running. |
|
||
\*------------------------------------------------------*/
|
||
controller->StartPowerManager();
|
||
|
||
if(caps.idx_wireless_status != 0 && !caps.has_power_mgmt)
|
||
{
|
||
controller->StartEventWatcher();
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
sent_colors.clear();
|
||
|
||
/*----------------------------------------------------------*\
|
||
| On per-key devices, single-color non-animated modes (Off, |
|
||
| Static) are applied through the per-key path so the LEDs |
|
||
| track the mode color cleanly. Animated effects (anything |
|
||
| with HAS_SPEED — Breathing, Cycle, Wave, Ripple) fall |
|
||
| through to the zone-effect path below; in practice zone |
|
||
| effects render correctly alongside per-key on the devices |
|
||
| we have data for. |
|
||
\*----------------------------------------------------------*/
|
||
if(caps.has_perkey)
|
||
{
|
||
/*------------------------------------------------------*\
|
||
| Off mode via per-key: set all LEDs to black |
|
||
\*------------------------------------------------------*/
|
||
if(current.value == 0xFF)
|
||
{
|
||
controller->SetAllPerKeyColor(ToRGBColor(0, 0, 0));
|
||
controller->PerKeyFrameEnd();
|
||
return;
|
||
}
|
||
|
||
/*------------------------------------------------------*\
|
||
| Static mode via per-key: only used as a fallback when |
|
||
| the device exposes per-key but no zone effects. When |
|
||
| both are available we prefer the zone-effect path |
|
||
| because per-key writes alone don't fully claim against |
|
||
| the firmware effect engine on some devices (G502), |
|
||
| leaving the firmware fade fighting our per-key colors |
|
||
| until something else (e.g. Cycle) force-claims. |
|
||
| Gated on !HAS_SPEED so animated colored effects like |
|
||
| Breathing don't get clipped to a static color. |
|
||
\*------------------------------------------------------*/
|
||
if(!caps.has_zone_effects
|
||
&& current.color_mode == MODE_COLORS_MODE_SPECIFIC
|
||
&& current.colors.size() > 0
|
||
&& !(current.flags & MODE_FLAG_HAS_SPEED))
|
||
{
|
||
controller->SetAllPerKeyColor(current.colors[0]);
|
||
controller->PerKeyFrameEnd();
|
||
return;
|
||
}
|
||
|
||
/*------------------------------------------------------*\
|
||
| Animated effects (Breathing, Cycle, Wave, Ripple) on |
|
||
| per-key devices fall through to the zone effect path. |
|
||
\*------------------------------------------------------*/
|
||
}
|
||
|
||
/*----------------------------------------------------------*\
|
||
| Zone effect modes (devices without per-key, or animated |
|
||
| effects that can't be done via per-key) |
|
||
| |
|
||
| persist branching: |
|
||
| 0x8070: ephemeral by default; becomes persist=true |
|
||
| only when DeviceSaveMode has set save_pending. |
|
||
| 0x8071/0x0600: keeps the pre-existing per-branch |
|
||
| hardcoded values (Off=false, Effect=true) |
|
||
| pending 0x8071 save research. |
|
||
\*----------------------------------------------------------*/
|
||
const bool is_8070 = (caps.rgb_feature_page == HIDPP20_FEAT_COLOR_LED_EFFECTS);
|
||
|
||
/*----------------------------------------------------------*\
|
||
| Off mode: set static black on all clusters |
|
||
\*----------------------------------------------------------*/
|
||
if(current.value == 0xFF)
|
||
{
|
||
const bool off_persist = is_8070 ? save_pending : false;
|
||
|
||
for(size_t i = 0; i < caps.zone_clusters.size(); i++)
|
||
{
|
||
for(size_t j = 0; j < caps.zone_clusters[i].effects.size(); j++)
|
||
{
|
||
if(caps.zone_clusters[i].effects[j].effect_id == 0x0001)
|
||
{
|
||
controller->SetZoneEffect(
|
||
caps.zone_clusters[i].index,
|
||
caps.zone_clusters[i].effects[j].index,
|
||
0x0001, 0, 0, 0, 0, 100, 0, off_persist);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
controller->UpgradeSwControlAfterFirstPaint();
|
||
return;
|
||
}
|
||
|
||
/*----------------------------------------------------------*\
|
||
| Effect mode: apply to all clusters. |
|
||
| |
|
||
| Color source per cluster: |
|
||
| MODE_COLORS_PER_LED — current.colors[i] maps to |
|
||
| caps.zone_clusters[i] |
|
||
| (one LED per cluster on the |
|
||
| 0x8070 zone path) |
|
||
| MODE_COLORS_MODE_SPECIFIC — current.colors[0] for all |
|
||
| MODE_COLORS_NONE — zero (effect ignores RGB) |
|
||
\*----------------------------------------------------------*/
|
||
uint16_t period = SpeedSliderToPeriodMs(current.speed);
|
||
|
||
/*---------------------------------------------------------*\
|
||
| Brightness defaults to 100 for modes that don't expose |
|
||
| a brightness slider — those modes ignore the value at the |
|
||
| wire level anyway. Modes flagged HAS_BRIGHTNESS take the |
|
||
| user-set value from current.brightness. |
|
||
\*---------------------------------------------------------*/
|
||
unsigned char brightness = (current.flags & MODE_FLAG_HAS_BRIGHTNESS)
|
||
? (unsigned char)current.brightness
|
||
: 100;
|
||
|
||
for(size_t i = 0; i < caps.zone_clusters.size(); i++)
|
||
{
|
||
uint16_t eff_id = 0;
|
||
|
||
const std::vector<HIDPP20Effect>& cluster_effects = caps.zone_clusters[i].effects;
|
||
for(size_t j = 0; j < cluster_effects.size(); j++)
|
||
{
|
||
if(cluster_effects[j].index == (uint8_t)current.value)
|
||
{
|
||
eff_id = cluster_effects[j].effect_id;
|
||
break;
|
||
}
|
||
}
|
||
|
||
unsigned char r = 0, g = 0, b = 0;
|
||
|
||
if(current.color_mode == MODE_COLORS_PER_LED && i < current.colors.size())
|
||
{
|
||
r = RGBGetRValue(current.colors[i]);
|
||
g = RGBGetGValue(current.colors[i]);
|
||
b = RGBGetBValue(current.colors[i]);
|
||
}
|
||
else if(current.color_mode == MODE_COLORS_MODE_SPECIFIC && !current.colors.empty())
|
||
{
|
||
r = RGBGetRValue(current.colors[0]);
|
||
g = RGBGetGValue(current.colors[0]);
|
||
b = RGBGetBValue(current.colors[0]);
|
||
}
|
||
|
||
/*------------------------------------------------------*\
|
||
| Ripple wants a narrower, much faster period range |
|
||
| (2..200ms) than the breathing/wave baseline of 1..20s. |
|
||
| Both Ripple variants — 0x000B and the saturation |
|
||
| 0x0017 — use the fast range; this mirrors Solaar's |
|
||
| LEDEffects table, where only Ripple carries a period |
|
||
| range override. Cycle (0x0003/0x0015) and Wave |
|
||
| (0x0004/0x0016) stay on the standard 1..20s range. |
|
||
\*------------------------------------------------------*/
|
||
uint16_t cluster_period = (eff_id == 0x000B
|
||
|| eff_id == 0x0017)
|
||
? RippleSpeedSliderToPeriodMs(current.speed)
|
||
: period;
|
||
|
||
/*-----------------------------------------------------*\
|
||
| 0x8071/0x0600: persist=true matches what the observed |
|
||
| vendor-app wire capture does for every mode-set on |
|
||
| these |
|
||
| devices. With persist=false the firmware appears to |
|
||
| accept the command without actually committing the |
|
||
| new effect, which is consistent with Static (which |
|
||
| gets prepped with persist=true at startup) being the |
|
||
| only effect that visibly works. |
|
||
| |
|
||
| 0x8070: ephemeral (persist=false) on live writes; |
|
||
| DeviceSaveMode flips save_pending true to replay the |
|
||
| active mode with persist=true and commit to NVM. |
|
||
\*-----------------------------------------------------*/
|
||
const bool effect_persist = is_8070 ? save_pending : true;
|
||
|
||
controller->SetZoneEffect(
|
||
caps.zone_clusters[i].index,
|
||
current.value,
|
||
eff_id, r, g, b, cluster_period, brightness,
|
||
WaveDirectionToWire(current.direction), effect_persist);
|
||
}
|
||
|
||
/*-----------------------------------------------------------*\
|
||
| The zone effects are now committed — safe to upgrade from |
|
||
| flags=6 to flags=5. Without this, devices that only use |
|
||
| zone effects (no per-key Direct path) would stay at |
|
||
| flags=6 forever and the firmware would never send |
|
||
| onUserActivity events for idle/sleep. |
|
||
\*-----------------------------------------------------------*/
|
||
controller->UpgradeSwControlAfterFirstPaint();
|
||
}
|
||
|
||
void RGBController_LogitechHIDPP20::DeviceSaveMode()
|
||
{
|
||
/*----------------------------------------------------------*\
|
||
| 0x8071/0x0600 already write persist=true on every mode |
|
||
| change, so the Save button isn't exposed on those pages |
|
||
| (MODE_FLAG_MANUAL_SAVE is only set for 0x8070 modes in |
|
||
| the constructor). If a save ever lands here from those |
|
||
| pages anyway, nothing needs doing — the active mode is |
|
||
| already committed to NVM. |
|
||
| |
|
||
| 0x8070: replay the active mode through DeviceUpdateMode |
|
||
| with save_pending true so the zone effect writes go out |
|
||
| with persist=true, committing the currently-live effect |
|
||
| to flash. |
|
||
\*----------------------------------------------------------*/
|
||
const HIDPP20DeviceCapabilities& caps = controller->GetCapabilities();
|
||
if(caps.rgb_feature_page != HIDPP20_FEAT_COLOR_LED_EFFECTS)
|
||
{
|
||
return;
|
||
}
|
||
|
||
save_pending = true;
|
||
DeviceUpdateMode();
|
||
save_pending = false;
|
||
}
|
||
|
||
bool RGBController_LogitechHIDPP20::ReapplyActiveMode()
|
||
{
|
||
/*-----------------------------------------------------------*\
|
||
| Re-establish the current active_mode on the device. Used |
|
||
| by the wake path (after SetRgbPowerMode(1) cancels the |
|
||
| firmware fade) and by the reconnect path (after a wireless |
|
||
| or USB reconnect). Handles both per-key Direct and zone |
|
||
| effect modes: |
|
||
| |
|
||
| 1. Claim SW control (host mode + flags + power mode). |
|
||
| Retried internally; returns true iff the final claim |
|
||
| ACKed. ReconnectDevice's fast-backoff loop uses this |
|
||
| as the accept signal. |
|
||
| 2. Clear sent_colors so the next frame is full-push. |
|
||
| Per the 0x8071 lifecycle, the device's LED buffer may |
|
||
| not survive mode 3→1 or a full reconnect, so we don't |
|
||
| trust it to remember any prior state. |
|
||
| 3. Route through DeviceUpdateMode so both per-key Direct |
|
||
| (full per-key frame via DeviceUpdateLEDs) and zone |
|
||
| effects (SetEffect per cluster) re-establish |
|
||
| correctly. Covers the case where the active_mode was |
|
||
| changed in the GUI while the device was fading — that |
|
||
| mode change was dropped at the time and needs to land |
|
||
| here on wake. |
|
||
\*-----------------------------------------------------------*/
|
||
bool claimed = controller->ClaimSWControlIfNeeded();
|
||
sent_colors.clear();
|
||
DeviceUpdateMode();
|
||
return claimed;
|
||
}
|