# Changelog (/docs/changelog)



{/* AUTO-GENERATED by scripts/generate-changelog.mjs — do not edit by hand. */}

Every OpenLogi release, generated from the project's [GitHub Releases](https://github.com/AprilNEA/OpenLogi/releases). Release notes are written in English.

## v0.8.16 [#v0816]

*Released October 21, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.8.16)

#### macOS: shortcut keys now go through a virtual HID keyboard [#macos-shortcut-keys-now-go-through-a-virtual-hid-keyboard]

When a button sends a keyboard shortcut, OpenLogi now presses the keys on a virtual keyboard named "OpenLogi Virtual Keyboard" instead of posting software key events. macOS handles these keys the way it handles keys from a USB keyboard. This is for shortcuts that work from a real keyboard but did not work from OpenLogi ([#1653](https://github.com/AprilNEA/OpenLogi/issues/1653), [#744](https://github.com/AprilNEA/OpenLogi/issues/744)). Apple granted OpenLogi the restricted HID virtual-device entitlement for this, and the signed OpenLogi Agent carries it ([#1918](https://github.com/AprilNEA/OpenLogi/pull/1918), [#1929](https://github.com/AprilNEA/OpenLogi/pull/1929)).

* You do not need to grant a new permission. The agent's signing identity did not change, so your Accessibility and Input Monitoring grants keep working.
* If macOS does not let OpenLogi create the virtual keyboard, OpenLogi types the keys the old way.
* Only the signed release builds have it. Builds from source and dev builds still use the old way.
* To check that it is active, run `ioreg -r -c IOHIDUserDevice | grep "OpenLogi Virtual Keyboard"` while OpenLogi runs.
* If a shortcut now behaves differently than in 0.8.15, please open an issue.

#### What's Changed [#whats-changed]

* chore: track rust 1.99 as the stable floor by @AprilNEA in [#1877](https://github.com/AprilNEA/OpenLogi/pull/1877)
* feat(inject): type synthesized macOS keys through a virtual HID keyboard by @AprilNEA in [#1918](https://github.com/AprilNEA/OpenLogi/pull/1918)
* fix(agent): relaunch when Input Monitoring is granted after launch by @AprilNEA in [#1919](https://github.com/AprilNEA/OpenLogi/pull/1919)
* fix(deps): bump rustls-webpki and webpki roots for emSign advisories by @AprilNEA in [#1924](https://github.com/AprilNEA/OpenLogi/pull/1924)
* feat(inject,gui): slide desktop switches like a three-finger swipe by @AprilNEA in [#1921](https://github.com/AprilNEA/OpenLogi/pull/1921)
* feat(macos,gui): say when Secure Input pauses key remapping by @AprilNEA in [#1926](https://github.com/AprilNEA/OpenLogi/pull/1926)
* test(inject): check that the host's choice decides the next desktop switch by @AprilNEA in [#1928](https://github.com/AprilNEA/OpenLogi/pull/1928)
* feat(xtask,ci): sign the macOS agent with its HID virtual-device profile by @AprilNEA in [#1929](https://github.com/AprilNEA/OpenLogi/pull/1929)
* feat(core,agent,gui): pointer speed and precision mode for 0x2205 mice by @AprilNEA in [#1922](https://github.com/AprilNEA/OpenLogi/pull/1922)
* feat(release): ship Sparkle delta updates by @AprilNEA in [#1925](https://github.com/AprilNEA/OpenLogi/pull/1925)
* fix(device,agent): move linked devices after a keyboard that switches by itself by @AprilNEA in [#1920](https://github.com/AprilNEA/OpenLogi/pull/1920)
* fix(desktop): show Pointer tab for mice and trackballs with software scroll inversion by @spartak8310 in [#1880](https://github.com/AprilNEA/OpenLogi/pull/1880)
* feat(hidpp,device,agent): precision mode on mice with adjustable DPI by @AprilNEA in [#1932](https://github.com/AprilNEA/OpenLogi/pull/1932)
* fix(hidpp): keep unmodelled backlight option bits on write by @AprilNEA in [#1933](https://github.com/AprilNEA/OpenLogi/pull/1933)
* fix(device): restore thumb-wheel reporting as found and harden its capture by @AprilNEA in [#1935](https://github.com/AprilNEA/OpenLogi/pull/1935)
* fix(hidpp,device): write all 0x8070 zones safely and gate v1 functions by @AprilNEA in [#1936](https://github.com/AprilNEA/OpenLogi/pull/1936)
* fix(hidpp): gate the illumination effective maximum in its wrapper by @AprilNEA in [#1937](https://github.com/AprilNEA/OpenLogi/pull/1937)
* fix(hidpp): bound 0x2111 torque and refuse a short capabilities reply by @AprilNEA in [#1939](https://github.com/AprilNEA/OpenLogi/pull/1939)
* feat(pointer): light the DPI LED in precision mode by @AprilNEA in [#1934](https://github.com/AprilNEA/OpenLogi/pull/1934)
* fix(hidpp,device): re-read settings on a 0x4521 disabled-key report by @AprilNEA in [#1940](https://github.com/AprilNEA/OpenLogi/pull/1940)
* fix(hidpp,device,cli): keep getFwInfo transport bytes 9-15 raw by @AprilNEA in [#1945](https://github.com/AprilNEA/OpenLogi/pull/1945)
* fix(gui): save a DPI only after the device confirms it by @AprilNEA in [#1943](https://github.com/AprilNEA/OpenLogi/pull/1943)
* fix(hidpp,device): reconcile DPI after a sensor calibration broadcast by @AprilNEA in [#1947](https://github.com/AprilNEA/OpenLogi/pull/1947)
* fix(agent): hand the haptic enable back after Actions Ring sessions by @AprilNEA in [#1942](https://github.com/AprilNEA/OpenLogi/pull/1942)
* fix(device): drop short 0x2121 movements and re-read SmartShift on a ratchet switch by @AprilNEA in [#1944](https://github.com/AprilNEA/OpenLogi/pull/1944)
* fix(hidpp): decode unified battery status bytes 3-5 by feature version by @AprilNEA in [#1941](https://github.com/AprilNEA/OpenLogi/pull/1941)
* fix(hidpp,device): publish 0x1b04 reporting changes made by other clients by @AprilNEA in [#1946](https://github.com/AprilNEA/OpenLogi/pull/1946)
* fix(device): read the 0x1000 battery capability before mapping levels by @AprilNEA in [#1938](https://github.com/AprilNEA/OpenLogi/pull/1938)
* fix(hidpp,device): read the 0x2201 default DPI only from feature version 1 by @AprilNEA in [#1952](https://github.com/AprilNEA/OpenLogi/pull/1952)
* fix(gui): choose the receiver before pairing a device by @hornej in [#1614](https://github.com/AprilNEA/OpenLogi/pull/1614)
* fix(hidpp): publish the 0x8071 nv-effect sync event as raw bytes by @AprilNEA in [#1951](https://github.com/AprilNEA/OpenLogi/pull/1951)
* chore: release v0.8.16 by @aprilnea\[bot] in [#1923](https://github.com/AprilNEA/OpenLogi/pull/1923)

#### New Contributors [#new-contributors]

* @spartak8310 made their first contribution in [#1880](https://github.com/AprilNEA/OpenLogi/pull/1880)
* @hornej made their first contribution in [#1614](https://github.com/AprilNEA/OpenLogi/pull/1614)

**Full Changelog**: [v0.8.15...v0.8.16](https://github.com/AprilNEA/OpenLogi/compare/v0.8.15...v0.8.16)

## v0.8.15 [#v0815]

*Released October 20, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.8.15)

#### What's Changed [#whats-changed-1]

* feat(device): hold Back or Forward to scroll sideways with the main wheel (0x1b04 raw wheel) by @AprilNEA in [#1828](https://github.com/AprilNEA/OpenLogi/pull/1828)
* test(gui): draw dialogs at rest before clicking their buttons by @AprilNEA in [#1832](https://github.com/AprilNEA/OpenLogi/pull/1832)
* feat(desktop): offer hold-to-scroll-sideways for Back and Forward by @AprilNEA in [#1835](https://github.com/AprilNEA/OpenLogi/pull/1835)
* feat(device): smooth a mouse's main wheel by diverting it (0x2121) by @AprilNEA in [#1837](https://github.com/AprilNEA/OpenLogi/pull/1837)
* feat(desktop): add a per-device smooth wheel switch to the Scrolling card by @AprilNEA in [#1839](https://github.com/AprilNEA/OpenLogi/pull/1839)
* feat(device): reach Litra lights over HID++ 0x1990 with read-back by @AprilNEA in [#1841](https://github.com/AprilNEA/OpenLogi/pull/1841)
* fix(gui): show the Actions Ring tab for keyboards with a Keys panel by @4ni1ak in [#1844](https://github.com/AprilNEA/OpenLogi/pull/1844)
* fix(gui): retain observed connection icons for offline devices by @i0ntempest in [#1830](https://github.com/AprilNEA/OpenLogi/pull/1830)
* fix(cli): drop a paired device with no identity from fixture capture by @4ni1ak in [#1861](https://github.com/AprilNEA/OpenLogi/pull/1861)
* fix(gui): recognize the MX Ergo precision-mode button slot by @4ni1ak in [#1855](https://github.com/AprilNEA/OpenLogi/pull/1855)
* fix(agent): log thumb-wheel continuous-scroll dispatch by @4ni1ak in [#1852](https://github.com/AprilNEA/OpenLogi/pull/1852)
* fix(i18n): translate the hold-modifiers and tap-key actions in Czech by @Imbecile6197 in [#1850](https://github.com/AprilNEA/OpenLogi/pull/1850)
* feat(gui): add a Next / Previous Desktop thumb-wheel preset by @Rothschildiuk in [#1848](https://github.com/AprilNEA/OpenLogi/pull/1848)
* fix(agent): write a light only when a reload changes its settings by @AprilNEA in [#1865](https://github.com/AprilNEA/OpenLogi/pull/1865)
* feat(agent): keep a change made on a Litra as its setting and publish it by @AprilNEA in [#1866](https://github.com/AprilNEA/OpenLogi/pull/1866)
* feat(gui): add device default option for keyboard bindings by @clifinger in [#702](https://github.com/AprilNEA/OpenLogi/pull/702)
* fix(linux): report input access as denied when the event node is unreadable by @AalmanSadath in [#787](https://github.com/AprilNEA/OpenLogi/pull/787)
* fix(gui): embed the Sleep action's moon glyph by @AalmanSadath in [#790](https://github.com/AprilNEA/OpenLogi/pull/790)
* feat(gui): add a Reset to defaults button to the F-key binding panel by @4ni1ak in [#1255](https://github.com/AprilNEA/OpenLogi/pull/1255)
* fix(gui): enlarge About panel logo by @4ni1ak in [#1364](https://github.com/AprilNEA/OpenLogi/pull/1364)
* fix(hid): open HID++ channels concurrently, not one node at a time by @4ni1ak in [#1443](https://github.com/AprilNEA/OpenLogi/pull/1443)
* feat(gui): add Power User actions to the Action Ring editor by @4ni1ak in [#1445](https://github.com/AprilNEA/OpenLogi/pull/1445)
* fix(inject): give the Windows Screenshot action its own Win+PrtScn chord by @AprilNEA in [#1867](https://github.com/AprilNEA/OpenLogi/pull/1867)
* fix(linux): keep "pen" out of the uinput device names by @AprilNEA in [#1868](https://github.com/AprilNEA/OpenLogi/pull/1868)
* fix(linux): grab a mouse again after it reconnects or appears late by @AprilNEA in [#1869](https://github.com/AprilNEA/OpenLogi/pull/1869)
* feat(desktop): save a change made on a Litra as its setting by @AprilNEA in [#1870](https://github.com/AprilNEA/OpenLogi/pull/1870)
* fix(hook): fall back to a recent device attribution on macOS by @andybateman in [#805](https://github.com/AprilNEA/OpenLogi/pull/805)
* fix(hid): suppress duplicate Bolt pairing slots by @xcouturecc in [#963](https://github.com/AprilNEA/OpenLogi/pull/963)
* fix(gui): allow clearing function-key bindings by @xcouturecc in [#962](https://github.com/AprilNEA/OpenLogi/pull/962)
* feat(gui): show the receiver slot on each device card by @garysassano in [#1802](https://github.com/AprilNEA/OpenLogi/pull/1802)
* fix(gui): fold leftover receiver pairings into one device card by @garysassano in [#1801](https://github.com/AprilNEA/OpenLogi/pull/1801)
* fix(hid): capture the M720 Triathlon's multiplatform gesture button by @kurtlieber in [#1613](https://github.com/AprilNEA/OpenLogi/pull/1613)
* fix(i18n): route hardcoded GUI labels through the catalog by @eibidia in [#1331](https://github.com/AprilNEA/OpenLogi/pull/1331)
* fix(macos): preserve fractional wheel distance by @li-shch in [#1154](https://github.com/AprilNEA/OpenLogi/pull/1154)
* feat(linux): route window-manager native actions through KWin D-Bus on KDE by @werwolf2303 in [#1087](https://github.com/AprilNEA/OpenLogi/pull/1087)
* feat(linux): support Hyprland native remaps by @shawnyeager in [#1152](https://github.com/AprilNEA/OpenLogi/pull/1152)
* fix(agent): include MiddleClick in the macOS HID++ gesture ownership by @tims2002 in [#1380](https://github.com/AprilNEA/OpenLogi/pull/1380)
* test(inject): cover the macOS Lock Screen chord by @AprilNEA in [#1885](https://github.com/AprilNEA/OpenLogi/pull/1885)
* feat(overlay): show keyboard backlight level by @nathan-fontenele in [#1695](https://github.com/AprilNEA/OpenLogi/pull/1695)
* ci(macos): lint the macOS cfg with clippy by @AprilNEA in [#1883](https://github.com/AprilNEA/OpenLogi/pull/1883)
* chore: drop stale lint suppressions and reject module-wide #!\[allow] by @AprilNEA in [#1884](https://github.com/AprilNEA/OpenLogi/pull/1884)
* feat(core,gui): allow middle click gesture mode by @PedroDiogo in [#1738](https://github.com/AprilNEA/OpenLogi/pull/1738)
* feat(hidpp): add scrolling force control for smartshift enhanced mice by @pedrohgmacedo in [#1636](https://github.com/AprilNEA/OpenLogi/pull/1636)
* fix(core,agent): chain swipes within one gesture-button hold by @sarat03 in [#1652](https://github.com/AprilNEA/OpenLogi/pull/1652)
* feat(linux): ship a Flatpak bundle with each release by @AalmanSadath in [#767](https://github.com/AprilNEA/OpenLogi/pull/767)
* ci: sweep stale #\[allow] on Linux, Windows, and macOS weekly by @AprilNEA in [#1887](https://github.com/AprilNEA/OpenLogi/pull/1887)
* feat(overlay): show a notice when a device's battery runs low by @AprilNEA in [#1891](https://github.com/AprilNEA/OpenLogi/pull/1891)
* feat(overlay): show an indicator when a keyboard turns a lock key on or off by @AprilNEA in [#1898](https://github.com/AprilNEA/OpenLogi/pull/1898)
* fix(device): drop a lock-state read that answers after a reconnection by @AprilNEA in [#1899](https://github.com/AprilNEA/OpenLogi/pull/1899)
* fix(overlay): draw one Actions Ring per display on Wayland by @AprilNEA in [#1906](https://github.com/AprilNEA/OpenLogi/pull/1906)
* feat(core,gui): share per-app profiles across platforms with \[apps] aliases by @AprilNEA in [#1907](https://github.com/AprilNEA/OpenLogi/pull/1907)
* chore: release v0.8.15 by @aprilnea\[bot] in [#1831](https://github.com/AprilNEA/OpenLogi/pull/1831)

#### New Contributors [#new-contributors-1]

* @i0ntempest made their first contribution in [#1830](https://github.com/AprilNEA/OpenLogi/pull/1830)
* @Rothschildiuk made their first contribution in [#1848](https://github.com/AprilNEA/OpenLogi/pull/1848)
* @andybateman made their first contribution in [#805](https://github.com/AprilNEA/OpenLogi/pull/805)
* @xcouturecc made their first contribution in [#963](https://github.com/AprilNEA/OpenLogi/pull/963)
* @garysassano made their first contribution in [#1802](https://github.com/AprilNEA/OpenLogi/pull/1802)
* @kurtlieber made their first contribution in [#1613](https://github.com/AprilNEA/OpenLogi/pull/1613)
* @li-shch made their first contribution in [#1154](https://github.com/AprilNEA/OpenLogi/pull/1154)
* @werwolf2303 made their first contribution in [#1087](https://github.com/AprilNEA/OpenLogi/pull/1087)
* @shawnyeager made their first contribution in [#1152](https://github.com/AprilNEA/OpenLogi/pull/1152)
* @tims2002 made their first contribution in [#1380](https://github.com/AprilNEA/OpenLogi/pull/1380)
* @PedroDiogo made their first contribution in [#1738](https://github.com/AprilNEA/OpenLogi/pull/1738)
* @pedrohgmacedo made their first contribution in [#1636](https://github.com/AprilNEA/OpenLogi/pull/1636)
* @sarat03 made their first contribution in [#1652](https://github.com/AprilNEA/OpenLogi/pull/1652)

**Full Changelog**: [v0.8.14...v0.8.15](https://github.com/AprilNEA/OpenLogi/compare/v0.8.14...v0.8.15)

## v0.8.14 [#v0814]

*Released October 15, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.8.14)

#### What's Changed [#whats-changed-2]

* fix(gui): prefer the Buttons panel over Keys when kind resolution is wrong by @4ni1ak in [#1702](https://github.com/AprilNEA/OpenLogi/pull/1702)
* docs(readme): add Spanish translation by @eibidia in [#1314](https://github.com/AprilNEA/OpenLogi/pull/1314)
* fix(gui): let the theme card name shrink so the badge fits by @4ni1ak in [#1435](https://github.com/AprilNEA/OpenLogi/pull/1435)
* fix(hook): carry the mouse's vendor/product ID onto its uinput mirror by @4ni1ak in [#1453](https://github.com/AprilNEA/OpenLogi/pull/1453)
* fix(cli): let macOS prompt for Input Monitoring before diag opens a device by @4ni1ak in [#1256](https://github.com/AprilNEA/OpenLogi/pull/1256)
* fix(gui): draw the client-side titlebar only where the compositor declines one by @4ni1ak in [#945](https://github.com/AprilNEA/OpenLogi/pull/945)
* fix(inject): distinguish Linux Capture Region from Screenshot by @4ni1ak in [#1433](https://github.com/AprilNEA/OpenLogi/pull/1433)
* fix(hook): preserve low-res wheel events during Linux pass-through by @nathan-fontenele in [#1693](https://github.com/AprilNEA/OpenLogi/pull/1693)
* fix(i18n): use Spain's Spanish terms in the es catalog by @eibidia in [#1326](https://github.com/AprilNEA/OpenLogi/pull/1326)
* docs(i18n): add Portuguese README translation by @obrenoalvim in [#1371](https://github.com/AprilNEA/OpenLogi/pull/1371)
* fix(windows): enable HID++ side-button gestures by @c1m-Chenym in [#1635](https://github.com/AprilNEA/OpenLogi/pull/1635)
* fix(agent): emit scroll input queued across a smoothing toggle instead of dropping it by @isleofgreg in [#1721](https://github.com/AprilNEA/OpenLogi/pull/1721)
* fix(agent): honor an overflow cancel from a generation the scroll worker has not seen by @isleofgreg in [#1720](https://github.com/AprilNEA/OpenLogi/pull/1720)
* feat(device): Detect Logitech G735 headset dongle by @xvilo in [#1040](https://github.com/AprilNEA/OpenLogi/pull/1040)
* fix(i18n): translate the custom-action input error in Czech by @Imbecile6197 in [#1728](https://github.com/AprilNEA/OpenLogi/pull/1728)
* fix(overlay): set the macOS activation policy before gpui\_platform starts by @4ni1ak in [#1692](https://github.com/AprilNEA/OpenLogi/pull/1692)
* fix(hid): capture alternate gesture controls by @4dux in [#1561](https://github.com/AprilNEA/OpenLogi/pull/1561)
* feat(camera): expose pan, tilt, gain and backlight compensation by @yuzi-co in [#1020](https://github.com/AprilNEA/OpenLogi/pull/1020)
* feat(gui): add precise camera control entry by @vinayaksodar in [#1047](https://github.com/AprilNEA/OpenLogi/pull/1047)
* fix(device): omit raw-xy flags when diverting or restoring keyboard controls by @henius98 in [#1726](https://github.com/AprilNEA/OpenLogi/pull/1726)
* fix(inject): send hi-res wheel events for Linux scroll actions (by @RisenID) by @Fede2782 in [#1719](https://github.com/AprilNEA/OpenLogi/pull/1719)
* fix(assets): resolve variants for depots whose manifests key on depot name or stem by @felipecadal in [#1295](https://github.com/AprilNEA/OpenLogi/pull/1295)
* fix(gui): render camera depots that ship no hotspot metadata by @MattijsE in [#1265](https://github.com/AprilNEA/OpenLogi/pull/1265)
* fix(inject): give App Expose/Mission Control/Show Desktop/Launchpad a GNOME implementation by @4ni1ak in [#1446](https://github.com/AprilNEA/OpenLogi/pull/1446)
* fix(gui): open the config folder from the fail-closed config screen by @yuzi-co in [#942](https://github.com/AprilNEA/OpenLogi/pull/942)
* fix(gui): give MenuRow-based list rows an accessible name by @4ni1ak in [#1448](https://github.com/AprilNEA/OpenLogi/pull/1448)
* fix(camera): collapse a Brio's IR node into one Linux camera entry by @4ni1ak in [#1234](https://github.com/AprilNEA/OpenLogi/pull/1234)
* docs: add Turkish README translation by @4ni1ak in [#1734](https://github.com/AprilNEA/OpenLogi/pull/1734)
* feat(hook): reverse the wheel in software for devices without 0x2121 by @4ni1ak in [#847](https://github.com/AprilNEA/OpenLogi/pull/847)
* feat(gui): add custom-shortcut Tap/Hold recorder to the button picker by @fahadjibransheikh in [#1396](https://github.com/AprilNEA/OpenLogi/pull/1396)
* fix(agent): reapply volatile settings on device reconnection and retry disagreeing DPI writes by @isleofgreg in [#1270](https://github.com/AprilNEA/OpenLogi/pull/1270)
* fix(overlay): properly position and scale Actions Ring on Windows mixed-DPI displays by @c1m-Chenym in [#1745](https://github.com/AprilNEA/OpenLogi/pull/1745)
* fix(camera): pass the pan/tilt payload by value by @SMRehan in [#1740](https://github.com/AprilNEA/OpenLogi/pull/1740)
* feat(core): add an action that holds modifiers and taps the key once by @Rick0525 in [#1161](https://github.com/AprilNEA/OpenLogi/pull/1161)
* feat(gui): update macOS through Sparkle by @AprilNEA in [#1744](https://github.com/AprilNEA/OpenLogi/pull/1744)
* docs(ipc): move PROTOCOL\_VERSION once per release by @AprilNEA in [#1760](https://github.com/AprilNEA/OpenLogi/pull/1760)
* fix(hidpp): write 0x1990 non-linear level tables whole by @AprilNEA in [#1765](https://github.com/AprilNEA/OpenLogi/pull/1765)
* fix(hidpp): read the 0x4530 platform with function 0 by @AprilNEA in [#1766](https://github.com/AprilNEA/OpenLogi/pull/1766)
* fix(hidpp): write each 0x0007 name chunk at its own offset by @AprilNEA in [#1767](https://github.com/AprilNEA/OpenLogi/pull/1767)
* fix(hidpp): preserve device state OpenLogi does not own on writes by @AprilNEA in [#1753](https://github.com/AprilNEA/OpenLogi/pull/1753)
* fix(device): encode 0x8080 tail frames and 0x8070 fixed colour per device by @AprilNEA in [#1758](https://github.com/AprilNEA/OpenLogi/pull/1758)
* fix(device): write SmartShift torque and DPI only as the device advertises by @AprilNEA in [#1759](https://github.com/AprilNEA/OpenLogi/pull/1759)
* fix(device): give the Litra Beam its own brightness range by @AprilNEA in [#1763](https://github.com/AprilNEA/OpenLogi/pull/1763)
* fix(device): stop misreading 0x1000 and 0x1001 battery states by @AprilNEA in [#1768](https://github.com/AprilNEA/OpenLogi/pull/1768)
* fix(device): stop committing 0x8080 frames the keyboard rejected by @AprilNEA in [#1764](https://github.com/AprilNEA/OpenLogi/pull/1764)
* fix(device): own RGB software control for the agent session by @AprilNEA in [#1769](https://github.com/AprilNEA/OpenLogi/pull/1769)
* fix(hidpp): require the 0x8081 zone echo before reporting a write by @AprilNEA in [#1771](https://github.com/AprilNEA/OpenLogi/pull/1771)
* fix(hidpp): refuse short 0x1990 info and level replies by @AprilNEA in [#1772](https://github.com/AprilNEA/OpenLogi/pull/1772)
* fix(device): confirm the haptic repair from the device's reply by @AprilNEA in [#1773](https://github.com/AprilNEA/OpenLogi/pull/1773)
* fix(device): confirm SmartShift writes from the device's reply by @AprilNEA in [#1775](https://github.com/AprilNEA/OpenLogi/pull/1775)
* fix(agent-core): close the RGB release races left by #1769 by @AprilNEA in [#1770](https://github.com/AprilNEA/OpenLogi/pull/1770)
* fix(hidpp): require the sensor echo in DPI replies by @AprilNEA in [#1776](https://github.com/AprilNEA/OpenLogi/pull/1776)
* fix(device): end a thumb-wheel roll whose Stop never arrives by @AprilNEA in [#1777](https://github.com/AprilNEA/OpenLogi/pull/1777)
* docs(hidpp): say that a None remap keeps the current mapping by @AprilNEA in [#1778](https://github.com/AprilNEA/OpenLogi/pull/1778)
* fix(hidpp): hold 0x8060 and 0x8061 rates to what the features define by @AprilNEA in [#1779](https://github.com/AprilNEA/OpenLogi/pull/1779)
* fix(hidpp): decode every 0x2121 capability flag and the horizontal delta by @AprilNEA in [#1780](https://github.com/AprilNEA/OpenLogi/pull/1780)
* fix(hidpp): decode 0x1c00 empty actions and status, and 0x4531 OS bits by @AprilNEA in [#1781](https://github.com/AprilNEA/OpenLogi/pull/1781)
* fix(hidpp): keep 0x0003 firmware entity types without a name by @AprilNEA in [#1783](https://github.com/AprilNEA/OpenLogi/pull/1783)
* fix(hidpp): refuse a short 0x40A3 reply instead of zero-filling it by @AprilNEA in [#1782](https://github.com/AprilNEA/OpenLogi/pull/1782)
* fix(hidpp): treat a Bolt protocol byte in 0x41 as an encrypted link by @AprilNEA in [#1784](https://github.com/AprilNEA/OpenLogi/pull/1784)
* fix(device): reconcile inventory on 0x1000/0x1001 battery broadcasts by @AprilNEA in [#1787](https://github.com/AprilNEA/OpenLogi/pull/1787)
* fix(device): wait for the Unifying lock to open before searching by @AprilNEA in [#1785](https://github.com/AprilNEA/OpenLogi/pull/1785)
* fix(device): report no percentage for a 0x1004 battery without a gauge by @AprilNEA in [#1788](https://github.com/AprilNEA/OpenLogi/pull/1788)
* fix(hidpp): check the echoed index in 0x8070/0x8071 info and 0x0007 name reads by @AprilNEA in [#1789](https://github.com/AprilNEA/OpenLogi/pull/1789)
* fix(hidpp): read a non-BCD 0x0003 firmware version as binary by @AprilNEA in [#1790](https://github.com/AprilNEA/OpenLogi/pull/1790)
* fix(device): re-read settings the device changes itself by @AprilNEA in [#1791](https://github.com/AprilNEA/OpenLogi/pull/1791)
* fix(device): drive Fn-lock on keyboards that only have 0x40a0 by @AprilNEA in [#1792](https://github.com/AprilNEA/OpenLogi/pull/1792)
* fix(device): play only the haptic waveforms the device advertises by @AprilNEA in [#1796](https://github.com/AprilNEA/OpenLogi/pull/1796)
* feat(camera): name webcams that report no USB product string by @DonovanMontoya in [#1793](https://github.com/AprilNEA/OpenLogi/pull/1793)
* chore(deps): bump rustls to 0.23.46 by @AprilNEA in [#1805](https://github.com/AprilNEA/OpenLogi/pull/1805)
* feat(device): keep multi-platform keyboards on the host OS layout by @AprilNEA in [#1806](https://github.com/AprilNEA/OpenLogi/pull/1806)
* feat(device): adjust the report rate of mice with 0x8060 or 0x8061 by @AprilNEA in [#1808](https://github.com/AprilNEA/OpenLogi/pull/1808)
* feat(device): set keyboard backlight automatic brightness, level and battery saving by @AprilNEA in [#1811](https://github.com/AprilNEA/OpenLogi/pull/1811)
* feat(desktop): control the keyboard backlight from the Device tab by @AprilNEA in [#1812](https://github.com/AprilNEA/OpenLogi/pull/1812)
* fix(device): refuse haptic plays while 0x19b0 reports a non-zero status by @AprilNEA in [#1817](https://github.com/AprilNEA/OpenLogi/pull/1817)
* fix(linux): settle the uinput action device before its first emit by @4ni1ak in [#1810](https://github.com/AprilNEA/OpenLogi/pull/1810)
* fix(device): don't let a stuck Empty host-slot status refuse a real pairing by @4ni1ak in [#1813](https://github.com/AprilNEA/OpenLogi/pull/1813)
* fix(agent): divert a keyboard key explicitly bound to Action::None by @4ni1ak in [#1819](https://github.com/AprilNEA/OpenLogi/pull/1819)
* fix(device): discard the stale first raw-XY report of every gesture hold by @DonovanMontoya in [#1786](https://github.com/AprilNEA/OpenLogi/pull/1786)
* fix(scroll): post phaseless continuous scroll events on macOS by @isleofgreg in [#1157](https://github.com/AprilNEA/OpenLogi/pull/1157)
* fix(gui): gate debug diagnostics imports in release builds by @gitsacha in [#1815](https://github.com/AprilNEA/OpenLogi/pull/1815)
* fix(agent): anchor the recovery-scan timer to scan start, not scan end by @4ni1ak in [#1809](https://github.com/AprilNEA/OpenLogi/pull/1809)
* fix(agent): queue HID requests before response timeout by @sh3ll3x3c in [#936](https://github.com/AprilNEA/OpenLogi/pull/936)
* fix(linux): open the Actions Ring at the cursor on Wayland by @AalmanSadath in [#1004](https://github.com/AprilNEA/OpenLogi/pull/1004)
* fix(agent): hand off capture across receiver routes by @justinechang39 in [#1198](https://github.com/AprilNEA/OpenLogi/pull/1198)
* Horizontal Scroll Reversed #1120 by @romanr in [#1140](https://github.com/AprilNEA/OpenLogi/pull/1140)
* feat(device): read Easy-Switch host names and OS, and rename hosts (0x1815) by @AprilNEA in [#1824](https://github.com/AprilNEA/OpenLogi/pull/1824)
* fix(overlay): configure accessory policy after GPUI initialization by @gitsacha in [#1814](https://github.com/AprilNEA/OpenLogi/pull/1814)
* fix(hook): keep software scroll inversion applied when the agent intercepts the wheel by @4ni1ak in [#1749](https://github.com/AprilNEA/OpenLogi/pull/1749)
* feat(desktop): show Easy-Switch hosts on the Device tab and rename them by @AprilNEA in [#1825](https://github.com/AprilNEA/OpenLogi/pull/1825)
* fix(hook): keep the pixel delta when inverting a macOS wheel event by @AprilNEA in [#1827](https://github.com/AprilNEA/OpenLogi/pull/1827)
* chore: release v0.8.14 by @aprilnea\[bot] in [#1718](https://github.com/AprilNEA/OpenLogi/pull/1718)

#### New Contributors [#new-contributors-2]

* @nathan-fontenele made their first contribution in [#1693](https://github.com/AprilNEA/OpenLogi/pull/1693)
* @obrenoalvim made their first contribution in [#1371](https://github.com/AprilNEA/OpenLogi/pull/1371)
* @c1m-Chenym made their first contribution in [#1635](https://github.com/AprilNEA/OpenLogi/pull/1635)
* @xvilo made their first contribution in [#1040](https://github.com/AprilNEA/OpenLogi/pull/1040)
* @4dux made their first contribution in [#1561](https://github.com/AprilNEA/OpenLogi/pull/1561)
* @vinayaksodar made their first contribution in [#1047](https://github.com/AprilNEA/OpenLogi/pull/1047)
* @henius98 made their first contribution in [#1726](https://github.com/AprilNEA/OpenLogi/pull/1726)
* @Fede2782 made their first contribution in [#1719](https://github.com/AprilNEA/OpenLogi/pull/1719)
* @felipecadal made their first contribution in [#1295](https://github.com/AprilNEA/OpenLogi/pull/1295)
* @MattijsE made their first contribution in [#1265](https://github.com/AprilNEA/OpenLogi/pull/1265)
* @fahadjibransheikh made their first contribution in [#1396](https://github.com/AprilNEA/OpenLogi/pull/1396)
* @SMRehan made their first contribution in [#1740](https://github.com/AprilNEA/OpenLogi/pull/1740)
* @Rick0525 made their first contribution in [#1161](https://github.com/AprilNEA/OpenLogi/pull/1161)
* @DonovanMontoya made their first contribution in [#1793](https://github.com/AprilNEA/OpenLogi/pull/1793)
* @gitsacha made their first contribution in [#1815](https://github.com/AprilNEA/OpenLogi/pull/1815)
* @justinechang39 made their first contribution in [#1198](https://github.com/AprilNEA/OpenLogi/pull/1198)
* @romanr made their first contribution in [#1140](https://github.com/AprilNEA/OpenLogi/pull/1140)

**Full Changelog**: [v0.8.13...v0.8.14](https://github.com/AprilNEA/OpenLogi/compare/v0.8.13...v0.8.14)

## v0.8.13 [#v0813]

*Released October 9, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.8.13)

#### What's Changed [#whats-changed-3]

* fix(linux): add a Spanish comment to the desktop entry by @eibidia in [#1468](https://github.com/AprilNEA/OpenLogi/pull/1468)
* fix(agent): let a thumb-wheel swipe fire Volume Up/Down more than once by @4ni1ak in [#1455](https://github.com/AprilNEA/OpenLogi/pull/1455)
* feat(hid): recognise Powerplay receiver 046d:c53a by @FelipeIzui in [#1267](https://github.com/AprilNEA/OpenLogi/pull/1267)
* feat(hid): add G733 battery and power-state support by @d-tatianin in [#1601](https://github.com/AprilNEA/OpenLogi/pull/1601)
* fix(i18n): improve Turkish catalog wording by @volkanncicek in [#1415](https://github.com/AprilNEA/OpenLogi/pull/1415)
* docs(core): clarify persisted device identifier scope by @yuzi-co in [#670](https://github.com/AprilNEA/OpenLogi/pull/670)
* fix(macos): respect the selected Apple developer directory by @4ni1ak in [#1457](https://github.com/AprilNEA/OpenLogi/pull/1457)
* fix(gui): restore the config-error folder action by @4ni1ak in [#1458](https://github.com/AprilNEA/OpenLogi/pull/1458)
* fix(core): resolve connection transport from live routes by @4ni1ak in [#1367](https://github.com/AprilNEA/OpenLogi/pull/1367)
* feat(gui): add custom actions to the mouse picker by @4ni1ak in [#1452](https://github.com/AprilNEA/OpenLogi/pull/1452)
* fix(gui): show friendly names for Windows app profiles by @4ni1ak in [#1463](https://github.com/AprilNEA/OpenLogi/pull/1463)
* feat(hidpp): implement pointer motion scaling by @Chernicharo in [#1347](https://github.com/AprilNEA/OpenLogi/pull/1347)
* fix(inject): restore macos 27 space swipes by @4ni1ak in [#1703](https://github.com/AprilNEA/OpenLogi/pull/1703)
* fix(hook): identify unbundled macos applications by @4ni1ak in [#1469](https://github.com/AprilNEA/OpenLogi/pull/1469)
* chore: release v0.8.13 by @aprilnea\[bot] in [#1716](https://github.com/AprilNEA/OpenLogi/pull/1716)

#### New Contributors [#new-contributors-3]

* @FelipeIzui made their first contribution in [#1267](https://github.com/AprilNEA/OpenLogi/pull/1267)
* @d-tatianin made their first contribution in [#1601](https://github.com/AprilNEA/OpenLogi/pull/1601)
* @volkanncicek made their first contribution in [#1415](https://github.com/AprilNEA/OpenLogi/pull/1415)

**Full Changelog**: [v0.8.12...v0.8.13](https://github.com/AprilNEA/OpenLogi/compare/v0.8.12...v0.8.13)

## v0.8.12 [#v0812]

*Released October 9, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.8.12)

#### What's Changed [#whats-changed-4]

* test(agent): cover a BLE mouse that wakes as a new node by @kohjunhao in [#1649](https://github.com/AprilNEA/OpenLogi/pull/1649)
* fix(gui): seed the DPI editor from config while the live read is pending by @4ni1ak in [#1628](https://github.com/AprilNEA/OpenLogi/pull/1628)
* fix(hid): recognize Lightspeed receiver 046d:c541 by @hamfield-eu in [#1679](https://github.com/AprilNEA/OpenLogi/pull/1679)
* fix(gui): truncate the function-row key callout's name instead of overflowing by @4ni1ak in [#1691](https://github.com/AprilNEA/OpenLogi/pull/1691)
* fix(gui): wrap the device-enabled caption so the switch stays in the card by @Imbecile6197 in [#1670](https://github.com/AprilNEA/OpenLogi/pull/1670)
* feat(fixture): add an MX Ergo Unifying fixture by @Chernicharo in [#1374](https://github.com/AprilNEA/OpenLogi/pull/1374)
* fix(gui): set text color on mouse pairing passkey click icons by @ZOZiBoi in [#1640](https://github.com/AprilNEA/OpenLogi/pull/1640)
* fix(hidpp): tell another protocol's reports apart from malformed HID++ by @Chernicharo in [#1373](https://github.com/AprilNEA/OpenLogi/pull/1373)
* fix(macos): log the candidate displays when Space state has no match by @4ni1ak in [#1694](https://github.com/AprilNEA/OpenLogi/pull/1694)
* fix(agent): phase horizontal thumb-wheel scroll when smoothing is off by @nredd in [#1689](https://github.com/AprilNEA/OpenLogi/pull/1689)
* feat(i18n): add Czech locale by @Imbecile6197 in [#1669](https://github.com/AprilNEA/OpenLogi/pull/1669)
* fix(core): write config through symlinks instead of replacing them by @FunJim in [#1642](https://github.com/AprilNEA/OpenLogi/pull/1642)
* fix(hook): keep the short budget for tap teardown after a probe break by @TheMedpreneur in [#1600](https://github.com/AprilNEA/OpenLogi/pull/1600)
* fix(i18n): name the real update-check host in the consent dialog by @4ni1ak in [#1629](https://github.com/AprilNEA/OpenLogi/pull/1629)
* fix(agent): restore ring toggle and publish expired sessions by @isleofgreg in [#1665](https://github.com/AprilNEA/OpenLogi/pull/1665)
* fix(gui): scale configuration labels with interface text by @4ni1ak in [#1627](https://github.com/AprilNEA/OpenLogi/pull/1627)
* fix(agent): fall back to focus when the pointer target is unidentified by @kohjunhao in [#1648](https://github.com/AprilNEA/OpenLogi/pull/1648)
* fix(gui): anchor scrollbar overlays in action ring editor and app picker by @lynicis in [#1360](https://github.com/AprilNEA/OpenLogi/pull/1360)
* fix(i18n): track the locale catalogs as build inputs by @zmingxie in [#1327](https://github.com/AprilNEA/OpenLogi/pull/1327)
* fix(gui): wrap the About page's link row instead of clipping it by @crnst8 in [#885](https://github.com/AprilNEA/OpenLogi/pull/885)
* fix(cli): replace the stale receiver note with a Linux udev hint by @Chernicharo in [#1348](https://github.com/AprilNEA/OpenLogi/pull/1348)
* fix(gui): show wheel controls without adjustable dpi by @minatoaquaMK2 in [#1369](https://github.com/AprilNEA/OpenLogi/pull/1369)
* fix(gui): recognize MX Ergo's DPI slot name for the mouse diagram by @armandodollia in [#1391](https://github.com/AprilNEA/OpenLogi/pull/1391)
* fix(macos): write the scroll point delta last so pixel precision survives by @isleofgreg in [#1156](https://github.com/AprilNEA/OpenLogi/pull/1156)
* chore: release v0.8.12 by @aprilnea\[bot] in [#1646](https://github.com/AprilNEA/OpenLogi/pull/1646)

#### New Contributors [#new-contributors-4]

* @hamfield-eu made their first contribution in [#1679](https://github.com/AprilNEA/OpenLogi/pull/1679)
* @Imbecile6197 made their first contribution in [#1670](https://github.com/AprilNEA/OpenLogi/pull/1670)
* @Chernicharo made their first contribution in [#1374](https://github.com/AprilNEA/OpenLogi/pull/1374)
* @ZOZiBoi made their first contribution in [#1640](https://github.com/AprilNEA/OpenLogi/pull/1640)
* @nredd made their first contribution in [#1689](https://github.com/AprilNEA/OpenLogi/pull/1689)
* @FunJim made their first contribution in [#1642](https://github.com/AprilNEA/OpenLogi/pull/1642)
* @TheMedpreneur made their first contribution in [#1600](https://github.com/AprilNEA/OpenLogi/pull/1600)
* @lynicis made their first contribution in [#1360](https://github.com/AprilNEA/OpenLogi/pull/1360)
* @zmingxie made their first contribution in [#1327](https://github.com/AprilNEA/OpenLogi/pull/1327)
* @crnst8 made their first contribution in [#885](https://github.com/AprilNEA/OpenLogi/pull/885)
* @minatoaquaMK2 made their first contribution in [#1369](https://github.com/AprilNEA/OpenLogi/pull/1369)
* @armandodollia made their first contribution in [#1391](https://github.com/AprilNEA/OpenLogi/pull/1391)

**Full Changelog**: [v0.8.11...v0.8.12](https://github.com/AprilNEA/OpenLogi/compare/v0.8.11...v0.8.12)

## v0.8.11 [#v0811]

*Released October 2, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.8.11)

#### What's Changed [#whats-changed-5]

* fix(macos): press Back and Forward as the frontmost app's menu item by @AprilNEA in [#1622](https://github.com/AprilNEA/OpenLogi/pull/1622)
* chore(nix): update flake.lock by @aprilnea\[bot] in [#1217](https://github.com/AprilNEA/OpenLogi/pull/1217)
* fix(agent): recognize an overlay tenant by any image we ever shipped by @AprilNEA in [#871](https://github.com/AprilNEA/OpenLogi/pull/871)
* feat(linux): add verified release installer by @AprilNEA in [#1109](https://github.com/AprilNEA/OpenLogi/pull/1109)
* ci(nix): only run Nix CI on flake and packaging changes by @davidbudnick in [#1250](https://github.com/AprilNEA/OpenLogi/pull/1250)
* fix(macos): confirm desktop switches with private space APIs by @AprilNEA in [#1586](https://github.com/AprilNEA/OpenLogi/pull/1586)
* fix(macos): drive the device-I/O gate from powerd instead of workspace notifications by @AprilNEA in [#1323](https://github.com/AprilNEA/OpenLogi/pull/1323)
* fix(core): match Actions Ring layouts with the per-app selector by @AprilNEA in [#643](https://github.com/AprilNEA/OpenLogi/pull/643)
* chore: release v0.8.11 by @aprilnea\[bot] in [#1637](https://github.com/AprilNEA/OpenLogi/pull/1637)
* fix: clear the lints rustc 1.99 raises on untouched code by @AprilNEA in [#1638](https://github.com/AprilNEA/OpenLogi/pull/1638)
* chore: release v0.8.11 by @AprilNEA in [#1643](https://github.com/AprilNEA/OpenLogi/pull/1643)
* chore: release v0.8.11 by @AprilNEA in [#1644](https://github.com/AprilNEA/OpenLogi/pull/1644)

**Full Changelog**: [v0.8.10...v0.8.11](https://github.com/AprilNEA/OpenLogi/compare/v0.8.10...v0.8.11)

## v0.8.10 [#v0810]

*Released September 30, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.8.10)

#### What's Changed [#whats-changed-6]

* feat(keyboard): key controls by HID++ control ID, fix Keys tab and Fn-lock by @AprilNEA in [#1604](https://github.com/AprilNEA/OpenLogi/pull/1604)
* fix(macos): keep the hook from hearing the keys OpenLogi posts by @AprilNEA in [#1618](https://github.com/AprilNEA/OpenLogi/pull/1618)
* fix(gui): unpair a forgotten receiver device so it stays deleted by @AprilNEA in [#1617](https://github.com/AprilNEA/OpenLogi/pull/1617)
* fix(macos): press shortcut keys where the active layout types them by @guslipkin in [#948](https://github.com/AprilNEA/OpenLogi/pull/948)
* feat(core): add a Super modifier and make Win/Meta name it by @sungyongcho in [#1387](https://github.com/AprilNEA/OpenLogi/pull/1387)
* fix(agent): re-arm a crash respawn instead of going dormant by @AprilNEA in [#1619](https://github.com/AprilNEA/OpenLogi/pull/1619)
* fix(hook): give the macOS tap's capability probes their own watchdog budget by @hyspacex in [#1282](https://github.com/AprilNEA/OpenLogi/pull/1282)
* fix(hook): give the callback watchdog the same watched-time rule by @AprilNEA in [#1624](https://github.com/AprilNEA/OpenLogi/pull/1624)
* chore: release v0.8.10 by @aprilnea\[bot] in [#1616](https://github.com/AprilNEA/OpenLogi/pull/1616)

#### New Contributors [#new-contributors-5]

* @guslipkin made their first contribution in [#948](https://github.com/AprilNEA/OpenLogi/pull/948)
* @hyspacex made their first contribution in [#1282](https://github.com/AprilNEA/OpenLogi/pull/1282)

**Full Changelog**: [v0.8.9...v0.8.10](https://github.com/AprilNEA/OpenLogi/compare/v0.8.9...v0.8.10)

## v0.8.9 [#v089]

*Released September 27, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.8.9)

#### What's Changed [#whats-changed-7]

* fix(gui): swap the thumb-wheel volume preset labels onto the pairing they describe by @4ni1ak in [#1436](https://github.com/AprilNEA/OpenLogi/pull/1436)
* fix(hook): stop the GNOME frontmost observer leaking a socket per renewal by @sungyongcho in [#1418](https://github.com/AprilNEA/OpenLogi/pull/1418)
* fix(gui): rebuild the agent's launchd registration when its job is gone by @raphaeljostan in [#1303](https://github.com/AprilNEA/OpenLogi/pull/1303)
* fix(hook): hit-test the window under the pointer through AppKit by @pseudoseed in [#1568](https://github.com/AprilNEA/OpenLogi/pull/1568)
* chore: release v0.8.9 by @aprilnea\[bot] in [#1599](https://github.com/AprilNEA/OpenLogi/pull/1599)

#### New Contributors [#new-contributors-6]

* @sungyongcho made their first contribution in [#1418](https://github.com/AprilNEA/OpenLogi/pull/1418)
* @raphaeljostan made their first contribution in [#1303](https://github.com/AprilNEA/OpenLogi/pull/1303)

**Full Changelog**: [v0.8.8...v0.8.9](https://github.com/AprilNEA/OpenLogi/compare/v0.8.8...v0.8.9)

## v0.8.8 [#v088]

*Released September 25, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.8.8)

#### What's Changed [#whats-changed-8]

* feat(hid): recognise Nano receiver 046d:c534 (MK270, MK295) by @4ni1ak in [#1456](https://github.com/AprilNEA/OpenLogi/pull/1456)
* fix(agent): divert a single-bound gesture button by @pseudoseed in [#1569](https://github.com/AprilNEA/OpenLogi/pull/1569)
* fix(gui): restore per-app profile removal and add reset controls by @AprilNEA in [#1580](https://github.com/AprilNEA/OpenLogi/pull/1580)
* fix(gui): prefer firmware codename over catalog name for M650 variants by @4ni1ak in [#1366](https://github.com/AprilNEA/OpenLogi/pull/1366)
* refactor(gui): simplify variant name correction ownership by @AprilNEA in [#1585](https://github.com/AprilNEA/OpenLogi/pull/1585)
* chore: release v0.8.8 by @aprilnea\[bot] in [#1573](https://github.com/AprilNEA/OpenLogi/pull/1573)

#### New Contributors [#new-contributors-7]

* @pseudoseed made their first contribution in [#1569](https://github.com/AprilNEA/OpenLogi/pull/1569)

**Full Changelog**: [v0.8.7...v0.8.8](https://github.com/AprilNEA/OpenLogi/compare/v0.8.7...v0.8.8)

## v0.8.7 [#v087]

*Released September 23, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.8.7)

#### What's Changed [#whats-changed-9]

* feat(agent): follow pointer for mouse button profiles by default by @AprilNEA in [#1566](https://github.com/AprilNEA/OpenLogi/pull/1566)
* chore: release v0.8.7 by @aprilnea\[bot] in [#1567](https://github.com/AprilNEA/OpenLogi/pull/1567)

**Full Changelog**: [v0.8.6...v0.8.7](https://github.com/AprilNEA/OpenLogi/compare/v0.8.6...v0.8.7)

## v0.8.6 [#v086]

*Released September 19, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.8.6)

#### What's Changed [#whats-changed-10]

* refactor(gui): split app\_icon around an AppIconExt trait by @AprilNEA in [#1422](https://github.com/AprilNEA/OpenLogi/pull/1422)
* fix(gui): hold a config reload until the agent connects by @AprilNEA in [#1416](https://github.com/AprilNEA/OpenLogi/pull/1416)
* refactor(ipc): own the client handshake once and guard it with ast-grep by @AprilNEA in [#1424](https://github.com/AprilNEA/OpenLogi/pull/1424)
* refactor(core): name the process roles, host profiles, and environment knobs once by @AprilNEA in [#1426](https://github.com/AprilNEA/OpenLogi/pull/1426)
* refactor(ipc,cli): bound the handshake once and give the CLI one agent door by @AprilNEA in [#1427](https://github.com/AprilNEA/OpenLogi/pull/1427)
* refactor(core): build every worker runtime in one place by @AprilNEA in [#1428](https://github.com/AprilNEA/OpenLogi/pull/1428)
* refactor(core): spell the app bundle's layout once by @AprilNEA in [#1429](https://github.com/AprilNEA/OpenLogi/pull/1429)
* docs(infra): add openlogi workflow skills by @AprilNEA in [#1409](https://github.com/AprilNEA/OpenLogi/pull/1409)
* docs(infra): refine agent guidance and diagnostic workflows by @AprilNEA in [#1485](https://github.com/AprilNEA/OpenLogi/pull/1485)
* refactor(cli): give the command modules that carry code a file of their own by @AprilNEA in [#1494](https://github.com/AprilNEA/OpenLogi/pull/1494)
* refactor(hidpp): split the pending-request queue and the mock channel out of channel.rs by @AprilNEA in [#1495](https://github.com/AprilNEA/OpenLogi/pull/1495)
* refactor(hook): split the macOS backend by responsibility by @AprilNEA in [#1496](https://github.com/AprilNEA/OpenLogi/pull/1496)
* refactor(hook): split the Linux backend into the evdev hook and the foreground watcher by @AprilNEA in [#1497](https://github.com/AprilNEA/OpenLogi/pull/1497)
* chore: drop an unused dependency, a dead parameter, and stale docs by @AprilNEA in [#1487](https://github.com/AprilNEA/OpenLogi/pull/1487)
* refactor(gui): let state mutators own their events and views share one relevance filter by @AprilNEA in [#1505](https://github.com/AprilNEA/OpenLogi/pull/1505)
* refactor(gui): give write-on-release sliders one component by @AprilNEA in [#1506](https://github.com/AprilNEA/OpenLogi/pull/1506)
* refactor(ipc): own the observe loop's connection state in one Observer by @AprilNEA in [#1507](https://github.com/AprilNEA/OpenLogi/pull/1507)
* perf(gui): resolve each device's assets once per asset generation by @AprilNEA in [#1508](https://github.com/AprilNEA/OpenLogi/pull/1508)
* refactor(gui): say what the state types are by @AprilNEA in [#1509](https://github.com/AprilNEA/OpenLogi/pull/1509)
* refactor(core): give the key vocabulary one owner by @AprilNEA in [#1488](https://github.com/AprilNEA/OpenLogi/pull/1488)
* refactor(core): give seven copied decisions one owner each by @AprilNEA in [#1489](https://github.com/AprilNEA/OpenLogi/pull/1489)
* refactor(xtask): spell xtask's waits and its bundle builder in the workspace's vocabulary by @AprilNEA in [#1490](https://github.com/AprilNEA/OpenLogi/pull/1490)
* ci(windows): run the tests on Windows by @AprilNEA in [#1491](https://github.com/AprilNEA/OpenLogi/pull/1491)
* refactor(device): drop the capture paths nothing calls and type the session errors by @AprilNEA in [#1500](https://github.com/AprilNEA/OpenLogi/pull/1500)
* refactor(core): split the config module by concern by @AprilNEA in [#1511](https://github.com/AprilNEA/OpenLogi/pull/1511)
* refactor(device): split the inventory and probe modules by responsibility by @AprilNEA in [#1512](https://github.com/AprilNEA/OpenLogi/pull/1512)
* refactor(device): call durations that bound a wait timeouts by @AprilNEA in [#1513](https://github.com/AprilNEA/OpenLogi/pull/1513)
* refactor(device): run gesture and keyboard capture through one session skeleton by @AprilNEA in [#1501](https://github.com/AprilNEA/OpenLogi/pull/1501)
* refactor(device): share one pending-restore token between capture and host switch by @AprilNEA in [#1502](https://github.com/AprilNEA/OpenLogi/pull/1502)
* refactor(agent): run the capture watchers on one harness and name the device-access handles by @AprilNEA in [#1503](https://github.com/AprilNEA/OpenLogi/pull/1503)
* refactor: say what eight identifiers are by @AprilNEA in [#1517](https://github.com/AprilNEA/OpenLogi/pull/1517)
* refactor(camera): suffix the macOS backends like the other platforms' by @AprilNEA in [#1518](https://github.com/AprilNEA/OpenLogi/pull/1518)
* refactor(gui): move the F-key geometry into function\_row/key\_points.rs by @AprilNEA in [#1525](https://github.com/AprilNEA/OpenLogi/pull/1525)
* refactor(agent): move the device list and re-apply plan into orchestrator/devices.rs by @AprilNEA in [#1526](https://github.com/AprilNEA/OpenLogi/pull/1526)
* refactor(gui): split the AppState tests by what they exercise by @AprilNEA in [#1527](https://github.com/AprilNEA/OpenLogi/pull/1527)
* refactor(inject): run scripts and workflows once, over four platform hooks by @AprilNEA in [#1529](https://github.com/AprilNEA/OpenLogi/pull/1529)
* refactor(core): split the config tests by what they exercise by @AprilNEA in [#1530](https://github.com/AprilNEA/OpenLogi/pull/1530)
* refactor(device): split the inventory and gesture capture tests by what they exercise by @AprilNEA in [#1531](https://github.com/AprilNEA/OpenLogi/pull/1531)
* refactor(agent): split the orchestrator tests by what they exercise by @AprilNEA in [#1532](https://github.com/AprilNEA/OpenLogi/pull/1532)
* refactor(hidpp): split the channel tests by what they exercise by @AprilNEA in [#1533](https://github.com/AprilNEA/OpenLogi/pull/1533)
* refactor(device): move the host-switch session's tests to host\_switch/tests.rs by @AprilNEA in [#1535](https://github.com/AprilNEA/OpenLogi/pull/1535)
* refactor(agent): move the button runtime's worker loop into button/worker.rs by @AprilNEA in [#1536](https://github.com/AprilNEA/OpenLogi/pull/1536)
* refactor(inject): break the macOS injector into scroll, browser and SPI modules by @AprilNEA in [#1537](https://github.com/AprilNEA/OpenLogi/pull/1537)
* refactor(gui): move the mouse view's binding labels and tests out of view\.rs by @AprilNEA in [#1538](https://github.com/AprilNEA/OpenLogi/pull/1538)
* refactor(gui): move the camera panel's row builders into controls/rows.rs by @AprilNEA in [#1539](https://github.com/AprilNEA/OpenLogi/pull/1539)
* feat(gui): detect and display installation sources by @AprilNEA in [#1515](https://github.com/AprilNEA/OpenLogi/pull/1515)
* docs(gui): mend the desktop crate's intra-doc links by @AprilNEA in [#1541](https://github.com/AprilNEA/OpenLogi/pull/1541)
* chore: release v0.8.6 by @aprilnea\[bot] in [#1481](https://github.com/AprilNEA/OpenLogi/pull/1481)

**Full Changelog**: [v0.8.5...v0.8.6](https://github.com/AprilNEA/OpenLogi/compare/v0.8.5...v0.8.6)

## v0.8.5 [#v085]

*Released September 16, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.8.5)

#### What's Changed [#whats-changed-11]

* fix(gui): resolve depot metadata named only by the manifest by @4ni1ak in [#836](https://github.com/AprilNEA/OpenLogi/pull/836)
* docs(gui): adopt upstream gpui kit skills by @AprilNEA in [#1400](https://github.com/AprilNEA/OpenLogi/pull/1400)
* fix(linux): keep the generated systemd unit out of the user's tier by @AalmanSadath in [#796](https://github.com/AprilNEA/OpenLogi/pull/796)
* fix(hidpp): stop HID++ replies landing on the wrong request by @isleofgreg in [#1286](https://github.com/AprilNEA/OpenLogi/pull/1286)
* feat(gui): enable supported button gestures by @juan-apa in [#1167](https://github.com/AprilNEA/OpenLogi/pull/1167)
* fix(gui): restore stacked card layout under gpui-kit 0.6 by @AprilNEA in [#1408](https://github.com/AprilNEA/OpenLogi/pull/1408)
* chore: release v0.8.5 by @aprilnea\[bot] in [#1399](https://github.com/AprilNEA/OpenLogi/pull/1399)

**Full Changelog**: [v0.8.4...v0.8.5](https://github.com/AprilNEA/OpenLogi/compare/v0.8.4...v0.8.5)

## v0.8.4 [#v084]

*Released September 15, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.8.4)

#### What's Changed [#whats-changed-12]

* feat(i18n): migrate catalogs to semantic TOML keys by @AprilNEA in [#1169](https://github.com/AprilNEA/OpenLogi/pull/1169)
* perf(i18n): avoid copying borrowed translations by @AprilNEA in [#1184](https://github.com/AprilNEA/OpenLogi/pull/1184)
* fix(i18n): polish supported locale copy by @AprilNEA in [#1180](https://github.com/AprilNEA/OpenLogi/pull/1180)
* fix(i18n): localize DPI steps and correct count labels by @AprilNEA in [#1292](https://github.com/AprilNEA/OpenLogi/pull/1292)
* fix(i18n): polish Brazilian Portuguese profile and settings copy by @uricholiveira in [#1276](https://github.com/AprilNEA/OpenLogi/pull/1276)
* fix(i18n): refine French asset and control descriptions by @didlawowo in [#1278](https://github.com/AprilNEA/OpenLogi/pull/1278)
* fix(hid): keep pairing phase after device selection by @AprilNEA in [#1181](https://github.com/AprilNEA/OpenLogi/pull/1181)
* refactor(gui): model camera preview lifecycle states by @AprilNEA in [#1182](https://github.com/AprilNEA/OpenLogi/pull/1182)
* refactor(agent): consolidate capture manager slots by @AprilNEA in [#1183](https://github.com/AprilNEA/OpenLogi/pull/1183)
* docs(linux): fix the Debian install command and the bug form's platform note by @AalmanSadath in [#791](https://github.com/AprilNEA/OpenLogi/pull/791)
* docs: add Russian README translation by @MonteNegroX in [#1039](https://github.com/AprilNEA/OpenLogi/pull/1039)
* Fix typo in installation guide by @mikevankuik in [#1189](https://github.com/AprilNEA/OpenLogi/pull/1189)
* fix(i18n): correct mistranslations in the Spanish catalog by @eibidia in [#1315](https://github.com/AprilNEA/OpenLogi/pull/1315)
* feat(i18n): Updated French README.md file by @DimitriDR in [#1271](https://github.com/AprilNEA/OpenLogi/pull/1271)
* fix: add Debian runtime dependencies by @MauricioSilv in [#1249](https://github.com/AprilNEA/OpenLogi/pull/1249)
* fix(hook): normalize Windows cursor position to DIP scale by @fly530 in [#1246](https://github.com/AprilNEA/OpenLogi/pull/1246)
* fix(agent): restore diverted HID++ controls on a clean shutdown by @4ni1ak in [#1106](https://github.com/AprilNEA/OpenLogi/pull/1106)
* refactor(hook): type windows cursor dpi normalization by @AprilNEA in [#1318](https://github.com/AprilNEA/OpenLogi/pull/1318)
* fix(linux): declare F13-F20 in uinput key capabilities by @costantinoai in [#1224](https://github.com/AprilNEA/OpenLogi/pull/1224)
* fix(core): stop seeding Back/Forward with a divertable default by @litityum in [#1225](https://github.com/AprilNEA/OpenLogi/pull/1225)
* fix(agent): fence queued pairing discovery after selection by @AprilNEA in [#1320](https://github.com/AprilNEA/OpenLogi/pull/1320)
* fix(agent): avoid capture recovery deadline busy loops by @AprilNEA in [#1321](https://github.com/AprilNEA/OpenLogi/pull/1321)
* test(gui): cover camera preview lifecycle by @AprilNEA in [#1322](https://github.com/AprilNEA/OpenLogi/pull/1322)
* fix(gui): place Windows actions ring correctly across mixed-DPI monitors by @AprilNEA in [#1319](https://github.com/AprilNEA/OpenLogi/pull/1319)
* fix(gui): stop linking SMAppServiceErrorDomain so macOS 13 and 14 can launch by @AprilNEA in [#1324](https://github.com/AprilNEA/OpenLogi/pull/1324)
* feat(hid): add hardware-aware mock device record and replay by @AprilNEA in [#1165](https://github.com/AprilNEA/OpenLogi/pull/1165)
* test(agent): cover native and browser side-button profiles by @AprilNEA in [#1325](https://github.com/AprilNEA/OpenLogi/pull/1325)
* fix(macos): bind Safari navigation to press-time process by @foleykyle01 in [#1082](https://github.com/AprilNEA/OpenLogi/pull/1082)
* fix(camera): surface startup failures and retry preview by @yuzi-co in [#1069](https://github.com/AprilNEA/OpenLogi/pull/1069)
* chore(gui): upgrade to gpui kit 0.6.1 and published pre adapters by @AprilNEA in [#1338](https://github.com/AprilNEA/OpenLogi/pull/1338)
* feat(hid): add rgb-effects lighting and receiver routing by @LuisUrrutia in [#351](https://github.com/AprilNEA/OpenLogi/pull/351)
* fix(gui): keep update consent buttons visible by @AprilNEA in [#1398](https://github.com/AprilNEA/OpenLogi/pull/1398)
* fix(linux): preserve gesture capture across receiver probes by @yvvlee in [#1043](https://github.com/AprilNEA/OpenLogi/pull/1043)
* chore: release v0.8.4 by @aprilnea\[bot] in [#1179](https://github.com/AprilNEA/OpenLogi/pull/1179)

#### New Contributors [#new-contributors-8]

* @uricholiveira made their first contribution in [#1276](https://github.com/AprilNEA/OpenLogi/pull/1276)
* @didlawowo made their first contribution in [#1278](https://github.com/AprilNEA/OpenLogi/pull/1278)
* @AalmanSadath made their first contribution in [#791](https://github.com/AprilNEA/OpenLogi/pull/791)
* @MonteNegroX made their first contribution in [#1039](https://github.com/AprilNEA/OpenLogi/pull/1039)
* @mikevankuik made their first contribution in [#1189](https://github.com/AprilNEA/OpenLogi/pull/1189)
* @eibidia made their first contribution in [#1315](https://github.com/AprilNEA/OpenLogi/pull/1315)
* @DimitriDR made their first contribution in [#1271](https://github.com/AprilNEA/OpenLogi/pull/1271)
* @MauricioSilv made their first contribution in [#1249](https://github.com/AprilNEA/OpenLogi/pull/1249)
* @fly530 made their first contribution in [#1246](https://github.com/AprilNEA/OpenLogi/pull/1246)
* @costantinoai made their first contribution in [#1224](https://github.com/AprilNEA/OpenLogi/pull/1224)
* @litityum made their first contribution in [#1225](https://github.com/AprilNEA/OpenLogi/pull/1225)
* @foleykyle01 made their first contribution in [#1082](https://github.com/AprilNEA/OpenLogi/pull/1082)
* @LuisUrrutia made their first contribution in [#351](https://github.com/AprilNEA/OpenLogi/pull/351)
* @yvvlee made their first contribution in [#1043](https://github.com/AprilNEA/OpenLogi/pull/1043)

**Full Changelog**: [v0.8.3...v0.8.4](https://github.com/AprilNEA/OpenLogi/compare/v0.8.3...v0.8.4)

## v0.8.3 [#v083]

*Released August 30, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.8.3)

#### What's Changed [#whats-changed-13]

* fix(ci): align agent guidance with current contracts by @AprilNEA in [#1139](https://github.com/AprilNEA/OpenLogi/pull/1139)
* fix: make Actions Ring selectable in action pickers by @the-long-ride in [#958](https://github.com/AprilNEA/OpenLogi/pull/958)
* fix(ci): repair focused i18n gate and agent guidance by @AprilNEA in [#1138](https://github.com/AprilNEA/OpenLogi/pull/1138)
* chore: release v0.8.3 by @aprilnea\[bot] in [#1163](https://github.com/AprilNEA/OpenLogi/pull/1163)

**Full Changelog**: [v0.8.2...v0.8.3](https://github.com/AprilNEA/OpenLogi/compare/v0.8.2...v0.8.3)

## v0.8.2 [#v082]

*Released August 30, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.8.2)

#### What's Changed [#whats-changed-14]

* fix(camera): read WhiteBalance, not ColorEnable, on Windows by @yuzi-co in [#662](https://github.com/AprilNEA/OpenLogi/pull/662)
* refactor(hid): name the divert intents instead of passing flag bools by @AprilNEA in [#1071](https://github.com/AprilNEA/OpenLogi/pull/1071)
* refactor(linux): make the hidraw probe outcome an explicit enum by @AprilNEA in [#1073](https://github.com/AprilNEA/OpenLogi/pull/1073)
* feat(macos): let launchd own the agent lifecycle via SMAppService by @AprilNEA in [#1031](https://github.com/AprilNEA/OpenLogi/pull/1031)
* docs: character stand-in icon rule + crate-homed agent guides by @AprilNEA in [#1026](https://github.com/AprilNEA/OpenLogi/pull/1026)
* Map SLOT\_NAME\_DPI\_BUTTON to DpiToggle and add verification tests by @ediaz2 in [#703](https://github.com/AprilNEA/OpenLogi/pull/703)
* fix(agent): harden capture sessions, arming, and observe staleness by @AprilNEA in [#1077](https://github.com/AprilNEA/OpenLogi/pull/1077)
* refactor(hid): fold the one-shot retry state into a OneShotScan type by @AprilNEA in [#1070](https://github.com/AprilNEA/OpenLogi/pull/1070)
* refactor(gui): name the scrolling card's facts instead of a five-tuple by @AprilNEA in [#1072](https://github.com/AprilNEA/OpenLogi/pull/1072)
* feat(agent): localize the menu-bar tray by @AprilNEA in [#1038](https://github.com/AprilNEA/OpenLogi/pull/1038)
* refactor: make the audited implicit state machines typed by @AprilNEA in [#1080](https://github.com/AprilNEA/OpenLogi/pull/1080)
* fix: resolve remaining state-machine audit findings by @AprilNEA in [#1083](https://github.com/AprilNEA/OpenLogi/pull/1083)
* feat(gui): add per-app Actions Ring editor by @AprilNEA in [#1085](https://github.com/AprilNEA/OpenLogi/pull/1085)
* feat(i18n): add Belarusian (be) locale by @aliaksandr-trush in [#912](https://github.com/AprilNEA/OpenLogi/pull/912)
* fix(macos): capture side-button gestures over HID++ by @juan-apa in [#722](https://github.com/AprilNEA/OpenLogi/pull/722)
* perf(agent): replace capture polling with events by @AprilNEA in [#1136](https://github.com/AprilNEA/OpenLogi/pull/1136)
* perf(agent): replace lifecycle polling with native events by @AprilNEA in [#1137](https://github.com/AprilNEA/OpenLogi/pull/1137)
* fix(macos): prevent HID activity from promoting DarkWake by @AprilNEA in [#1142](https://github.com/AprilNEA/OpenLogi/pull/1142)
* fix(hid): normalize thumbwheel direction per device by @AprilNEA in [#1158](https://github.com/AprilNEA/OpenLogi/pull/1158)
* chore: release v0.8.2 by @aprilnea\[bot] in [#1056](https://github.com/AprilNEA/OpenLogi/pull/1056)

#### New Contributors [#new-contributors-9]

* @ediaz2 made their first contribution in [#703](https://github.com/AprilNEA/OpenLogi/pull/703)
* @aliaksandr-trush made their first contribution in [#912](https://github.com/AprilNEA/OpenLogi/pull/912)
* @juan-apa made their first contribution in [#722](https://github.com/AprilNEA/OpenLogi/pull/722)

**Full Changelog**: [v0.8.1...v0.8.2](https://github.com/AprilNEA/OpenLogi/compare/v0.8.1...v0.8.2)

## v0.8.1 [#v081]

*Released August 26, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.8.1)

#### What's Changed [#whats-changed-15]

* ci: upgrade GitHub Actions by @AprilNEA in [#996](https://github.com/AprilNEA/OpenLogi/pull/996)
* refactor(gui): draw the character stand-in icons as real icons by @AprilNEA in [#1022](https://github.com/AprilNEA/OpenLogi/pull/1022)
* perf(release): accelerate macOS packaging by @AprilNEA in [#1023](https://github.com/AprilNEA/OpenLogi/pull/1023)
* fix(gui): survive and complete the live language switch by @AprilNEA in [#1033](https://github.com/AprilNEA/OpenLogi/pull/1033)
* fix(gui): keep Back and Forward labels together by @AprilNEA in [#1030](https://github.com/AprilNEA/OpenLogi/pull/1030)
* chore: release v0.8.1 by @aprilnea\[bot] in [#1025](https://github.com/AprilNEA/OpenLogi/pull/1025)

**Full Changelog**: [v0.8.0...v0.8.1](https://github.com/AprilNEA/OpenLogi/compare/v0.8.0...v0.8.1)

## v0.8.0 [#v080]

*Released August 25, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.8.0)

#### Highlights [#highlights]

* **One device, however you connect it.** The same mouse now keeps one device card and one set of settings across receiver and USB connections. Existing configurations migrate automatically and are backed up before the first save. ([#876](https://github.com/AprilNEA/OpenLogi/pull/876))
* **A redesigned device workspace makes larger setups easier to manage.** A responsive grid, compact cards, clearer battery status, custom device names, scalable interface presets, and a dedicated action menu make devices faster to scan and configure.
* **Per-app profiles are now editable in the GUI.** Pick an installed application from the new catalog, see which profile is active, and customize its button bindings without editing TOML. ([#976](https://github.com/AprilNEA/OpenLogi/pull/976))
* **Scrolling is smoother and more configurable.** Built-in smooth scrolling, vertical sensitivity controls, preserved fractional motion, rebindable wheel tilt, and fixes for diverted high-resolution wheels deliver more consistent scrolling across main and thumb wheels. ([#902](https://github.com/AprilNEA/OpenLogi/pull/902), [#925](https://github.com/AprilNEA/OpenLogi/pull/925), [#992](https://github.com/AprilNEA/OpenLogi/pull/992))
* **Buttons can now distinguish a tap from a hold.** TOML bindings can assign independent short- and long-press actions with a 500 ms threshold, while held shortcuts remain active until the button is released. ([#961](https://github.com/AprilNEA/OpenLogi/pull/961))

#### What's Changed [#whats-changed-16]

* chore(gui): bump gpui-component to da4f9369 by @AprilNEA in [#655](https://github.com/AprilNEA/OpenLogi/pull/655)
* refactor(hid): centralize device identities by @AprilNEA in [#831](https://github.com/AprilNEA/OpenLogi/pull/831)
* docs(agents): scale local gate to affected packages by @AprilNEA in [#696](https://github.com/AprilNEA/OpenLogi/pull/696)
* feat(core,gui): make the main wheel's tilt rebindable (MX anywhere 2s,...) by @6lv1-blr in [#902](https://github.com/AprilNEA/OpenLogi/pull/902)
* fix(linux): install the app icon at every indexed hicolor size by @4ni1ak in [#837](https://github.com/AprilNEA/OpenLogi/pull/837)
* fix(gui): recognize mx ergo wheel tilt metadata by @AprilNEA in [#905](https://github.com/AprilNEA/OpenLogi/pull/905)
* ci: automate typo checks and consolidate configs by @AprilNEA in [#906](https://github.com/AprilNEA/OpenLogi/pull/906)
* refactor(gui): convert app state to entity events by @AprilNEA in [#904](https://github.com/AprilNEA/OpenLogi/pull/904)
* fix(mouse): compose thumb-wheel preset labels from translated action names by @NatsUIJM in [#910](https://github.com/AprilNEA/OpenLogi/pull/910)
* fix(linux): restore glibc 2.35 package baseline by @AprilNEA in [#911](https://github.com/AprilNEA/OpenLogi/pull/911)
* fix(gui): restore full-phrase thumb-wheel preset labels by @AprilNEA in [#913](https://github.com/AprilNEA/OpenLogi/pull/913)
* refactor(gui): componentize free-function builders by @AprilNEA in [#914](https://github.com/AprilNEA/OpenLogi/pull/914)
* fix(macos): honor XDG state dir in permission diagnostics by @AprilNEA in [#915](https://github.com/AprilNEA/OpenLogi/pull/915)
* fix(hid): improve Unifying receiver discovery reliability by @v14 in [#853](https://github.com/AprilNEA/OpenLogi/pull/853)
* feat(gui): add per-app profiles and redesign device workspace by @AprilNEA in [#907](https://github.com/AprilNEA/OpenLogi/pull/907)
* fix(macos): use reliable async-hid report writes by @sh3ll3x3c in [#889](https://github.com/AprilNEA/OpenLogi/pull/889)
* fix(hid): preserve usage pairs in native handle cache by @sh3ll3x3c in [#882](https://github.com/AprilNEA/OpenLogi/pull/882)
* fix: show Windows MSI completion and failure state by @mvanhorn in [#819](https://github.com/AprilNEA/OpenLogi/pull/819)
* feat(gui): make custom controls keyboard-operable by @AprilNEA in [#916](https://github.com/AprilNEA/OpenLogi/pull/916)
* style(gui): refine profile toolbar layout by @AprilNEA in [#921](https://github.com/AprilNEA/OpenLogi/pull/921)
* refactor(gui): clean up render paths and element ids by @AprilNEA in [#926](https://github.com/AprilNEA/OpenLogi/pull/926)
* fix(hidpp): read Unifying link encryption from bit 5, not the software-present bit by @AprilNEA in [#924](https://github.com/AprilNEA/OpenLogi/pull/924)
* refactor(gui): tighten typed rendering and async ownership by @AprilNEA in [#931](https://github.com/AprilNEA/OpenLogi/pull/931)
* refactor(gui): move device reads onto swr by @AprilNEA in [#929](https://github.com/AprilNEA/OpenLogi/pull/929)
* feat(gui): add scalable interface presets by @AprilNEA in [#934](https://github.com/AprilNEA/OpenLogi/pull/934)
* feat(agent): unify button Down/Up/Cancel lifecycle by @AprilNEA in [#933](https://github.com/AprilNEA/OpenLogi/pull/933)
* feat(i18n): add Turkish (tr) interface locale by @4ni1ak in [#835](https://github.com/AprilNEA/OpenLogi/pull/835)
* feat(gui): add switchable device gallery views by @AprilNEA in [#927](https://github.com/AprilNEA/OpenLogi/pull/927)
* refactor(gui): improve shared component quality by @AprilNEA in [#966](https://github.com/AprilNEA/OpenLogi/pull/966)
* feat(agent): hold shortcut output until button release by @AprilNEA in [#960](https://github.com/AprilNEA/OpenLogi/pull/960)
* fix(thumbwheel): scale remapped vertical scrolling by @hsearcy in [#925](https://github.com/AprilNEA/OpenLogi/pull/925)
* refactor(gui): structure app state by domain by @AprilNEA in [#974](https://github.com/AprilNEA/OpenLogi/pull/974)
* feat(gui): add installed application catalog picker by @AprilNEA in [#976](https://github.com/AprilNEA/OpenLogi/pull/976)
* feat(gui): add polished battery indicator by @AprilNEA in [#983](https://github.com/AprilNEA/OpenLogi/pull/983)
* feat(scroll): preserve precise scroll distances by @AprilNEA in [#973](https://github.com/AprilNEA/OpenLogi/pull/973)
* ci: replace target caches with sccache by @AprilNEA in [#984](https://github.com/AprilNEA/OpenLogi/pull/984)
* feat(scroll): add finite smooth scroll runtime by @AprilNEA in [#975](https://github.com/AprilNEA/OpenLogi/pull/975)
* feat(scroll): smooth diverted thumbwheel input by @AprilNEA in [#977](https://github.com/AprilNEA/OpenLogi/pull/977)
* feat(gui): polish the app catalog picker and device cards by @AprilNEA in [#986](https://github.com/AprilNEA/OpenLogi/pull/986)
* feat(scroll): add vertical scroll sensitivity by @AprilNEA in [#978](https://github.com/AprilNEA/OpenLogi/pull/978)
* feat(scroll): expose smooth scrolling setting by @AprilNEA in [#979](https://github.com/AprilNEA/OpenLogi/pull/979)
* refactor(gui): strengthen typed render boundaries by @AprilNEA in [#981](https://github.com/AprilNEA/OpenLogi/pull/981)
* fix(core,gui,agent): key device settings by identity, not by transport by @yuzi-co in [#876](https://github.com/AprilNEA/OpenLogi/pull/876)
* feat(agent): dispatch independent long-press actions by @AprilNEA in [#961](https://github.com/AprilNEA/OpenLogi/pull/961)
* refactor(agent): complete thumbwheel state machine by @AprilNEA in [#968](https://github.com/AprilNEA/OpenLogi/pull/968)
* fix(hid): preserve diverted wheel reporting by @AprilNEA in [#992](https://github.com/AprilNEA/OpenLogi/pull/992)
* chore: release v0.8.0 by @aprilnea\[bot] in [#991](https://github.com/AprilNEA/OpenLogi/pull/991)

#### New Contributors [#new-contributors-10]

* @6lv1-blr made their first contribution in [#902](https://github.com/AprilNEA/OpenLogi/pull/902)
* @4ni1ak made their first contribution in [#837](https://github.com/AprilNEA/OpenLogi/pull/837)
* @NatsUIJM made their first contribution in [#910](https://github.com/AprilNEA/OpenLogi/pull/910)
* @sh3ll3x3c made their first contribution in [#889](https://github.com/AprilNEA/OpenLogi/pull/889)
* @hsearcy made their first contribution in [#925](https://github.com/AprilNEA/OpenLogi/pull/925)

**Full Changelog**: [v0.7.10...v0.8.0](https://github.com/AprilNEA/OpenLogi/compare/v0.7.10...v0.8.0)

## v0.7.10 [#v0710]

*Released August 23, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.7.10)

#### What's Changed [#whats-changed-17]

* fix(gui): check the open helper's exit status when launching the agent by @AprilNEA in [#820](https://github.com/AprilNEA/OpenLogi/pull/820)
* fix(nix): provide git to the sandboxed test phase by @AprilNEA in [#833](https://github.com/AprilNEA/OpenLogi/pull/833)
* feat(ipc): surface HID open failures in agent status by @AprilNEA in [#822](https://github.com/AprilNEA/OpenLogi/pull/822)
* fix(agent): follow the picked app icon in the menu bar by @AprilNEA in [#846](https://github.com/AprilNEA/OpenLogi/pull/846)
* feat(cli): read the running agent's inventory in openlogi list by @AprilNEA in [#823](https://github.com/AprilNEA/OpenLogi/pull/823)
* fix(thumbwheel): stop the tap firing App Exposé, and scroll by the wheel's native amount by @AprilNEA in [#857](https://github.com/AprilNEA/OpenLogi/pull/857)
* chore: release v0.7.8 by @aprilnea\[bot] in [#834](https://github.com/AprilNEA/OpenLogi/pull/834)
* fix(gui): stop discarding correctly rebuilt device records by @AprilNEA in [#868](https://github.com/AprilNEA/OpenLogi/pull/868)
* fix(overlay,agent): stop the anonymous overlay tenant from wedging the Actions Ring by @AprilNEA in [#848](https://github.com/AprilNEA/OpenLogi/pull/848)
* refactor(gui): put the asset tier on an swr cache by @AprilNEA in [#869](https://github.com/AprilNEA/OpenLogi/pull/869)
* chore: release v0.7.9 by @aprilnea\[bot] in [#870](https://github.com/AprilNEA/OpenLogi/pull/870)
* chore: release v0.7.10 by @AprilNEA in [#874](https://github.com/AprilNEA/OpenLogi/pull/874)

**Full Changelog**: [v0.7.7...v0.7.10](https://github.com/AprilNEA/OpenLogi/compare/v0.7.7...v0.7.10)

## v0.7.7 [#v077]

*Released August 23, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.7.7)

> **v0.7.5 and v0.7.6 were tagged but never published** — their macOS packaging failed before a single artifact was uploaded, so no release exists for either. Everything both of them contained ships here, and the changes below cover everything since v0.7.4.

#### What's Changed [#whats-changed-18]

* docs(ci): map every CI job to a local command by @davidbudnick in [#732](https://github.com/AprilNEA/OpenLogi/pull/732)
* feat(i18n): add Ukrainian (uk) locale by @dima-karpenko in [#715](https://github.com/AprilNEA/OpenLogi/pull/715)
* ci(shell): gate every shell script with shellcheck and shfmt by @AprilNEA in [#745](https://github.com/AprilNEA/OpenLogi/pull/745)
* docs(core): correct the model\_ids transport ordering, and fix the MX Keys mock by @yuzi-co in [#668](https://github.com/AprilNEA/OpenLogi/pull/668)
* refactor(xtask): move ci-local.sh and write-changelog.sh into xtask by @AprilNEA in [#746](https://github.com/AprilNEA/OpenLogi/pull/746)
* refactor(xtask): split bundle.rs along what it actually does by @AprilNEA in [#753](https://github.com/AprilNEA/OpenLogi/pull/753)
* feat(cli): report feature flags and firmware entities in diag features by @yuzi-co in [#690](https://github.com/AprilNEA/OpenLogi/pull/690)
* refactor(cli): render diagnostics with display adapters by @AprilNEA in [#758](https://github.com/AprilNEA/OpenLogi/pull/758)
* refactor: stream display-only formatting by @AprilNEA in [#768](https://github.com/AprilNEA/OpenLogi/pull/768)
* feat(camera): add anti-flicker and low-light controls by @awesamarth in [#793](https://github.com/AprilNEA/OpenLogi/pull/793)
* refactor(hid): split the HID++ device layer from the host backend by @AprilNEA in [#772](https://github.com/AprilNEA/OpenLogi/pull/772)
* chore: cut manifest comments that describe instead of constrain by @AprilNEA in [#809](https://github.com/AprilNEA/OpenLogi/pull/809)
* fix(hook): stop trusting AXIsProcessTrusted as a revocation signal by @AprilNEA in [#801](https://github.com/AprilNEA/OpenLogi/pull/801)
* fix(hook): keep macOS tap lifecycle instance-owned by @AprilNEA in [#808](https://github.com/AprilNEA/OpenLogi/pull/808)
* feat(gui): pick the app icon in Settings by @AprilNEA in [#812](https://github.com/AprilNEA/OpenLogi/pull/812)
* build(macos): name the helper bundles after their display names by @AprilNEA in [#814](https://github.com/AprilNEA/OpenLogi/pull/814)
* docs(agents): add a macOS permissions task skill by @AprilNEA in [#813](https://github.com/AprilNEA/OpenLogi/pull/813)
* fix(agent): shut down when the app is uninstalled by @AprilNEA in [#810](https://github.com/AprilNEA/OpenLogi/pull/810)
* fix(ipc,agent,desktop): surface Input Monitoring via agent status and fix registry bincode by @rekcilyssup in [#760](https://github.com/AprilNEA/OpenLogi/pull/760)
* fix(hid): take async-hid 0.5.3 so a denied HID open stops leaking by @AprilNEA in [#804](https://github.com/AprilNEA/OpenLogi/pull/804)
* fix(hid): park the Windows HID read on a permanently dead handle by @yuzi-co in [#779](https://github.com/AprilNEA/OpenLogi/pull/779)
* chore: release v0.7.5 by @aprilnea\[bot] in [#750](https://github.com/AprilNEA/OpenLogi/pull/750)
* fix(release): use changelog as release PR body by @AprilNEA in [#821](https://github.com/AprilNEA/OpenLogi/pull/821)
* refactor(agent): split the binary watcher from how it relaunches by @AprilNEA in [#816](https://github.com/AprilNEA/OpenLogi/pull/816)
* refactor(agent-core): give the polling watchers one loop by @AprilNEA in [#818](https://github.com/AprilNEA/OpenLogi/pull/818)
* feat(hid): recognise Lightspeed receiver 046d:c54d (PRO X SUPERLIGHT 2 DEX) by @v14 in [#811](https://github.com/AprilNEA/OpenLogi/pull/811)
* chore: release v0.7.6 by @aprilnea\[bot] in [#824](https://github.com/AprilNEA/OpenLogi/pull/824)
* fix(agent): make macOS permission failures diagnosable by @AprilNEA in [#817](https://github.com/AprilNEA/OpenLogi/pull/817)
* fix(ci): build both macOS legs with an Icon Composer-capable Xcode by @AprilNEA in [#815](https://github.com/AprilNEA/OpenLogi/pull/815)
* chore: release v0.7.7 by @aprilnea\[bot] in [#830](https://github.com/AprilNEA/OpenLogi/pull/830)

#### New Contributors [#new-contributors-11]

* @dima-karpenko made their first contribution in [#715](https://github.com/AprilNEA/OpenLogi/pull/715)
* @awesamarth made their first contribution in [#793](https://github.com/AprilNEA/OpenLogi/pull/793)
* @rekcilyssup made their first contribution in [#760](https://github.com/AprilNEA/OpenLogi/pull/760)
* @v14 made their first contribution in [#811](https://github.com/AprilNEA/OpenLogi/pull/811)

**Full Changelog**: [v0.7.4...v0.7.7](https://github.com/AprilNEA/OpenLogi/compare/v0.7.4...v0.7.7)

## v0.7.4 [#v074]

*Released August 21, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.7.4)

#### What's Changed [#whats-changed-19]

* feat(hid): support G602 nano receiver by @Stanley5249 in [#684](https://github.com/AprilNEA/OpenLogi/pull/684)
* chore(infra): prune the tree's lint suppressions and make the survivors expect by @AprilNEA in [#720](https://github.com/AprilNEA/OpenLogi/pull/720)
* fix(hid): surface the 0xc539 dongle as a Lightspeed receiver, not Unifying by @yuzi-co in [#665](https://github.com/AprilNEA/OpenLogi/pull/665)
* chore: release v0.7.4 by @aprilnea\[bot] in [#728](https://github.com/AprilNEA/OpenLogi/pull/728)

**Full Changelog**: [v0.7.3...v0.7.4](https://github.com/AprilNEA/OpenLogi/compare/v0.7.3...v0.7.4)

## v0.7.3 [#v073]

*Released August 20, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.7.3)

#### What's Changed [#whats-changed-20]

* chore: release v0.7.3 by @aprilnea\[bot] in [#725](https://github.com/AprilNEA/OpenLogi/pull/725)

**Full Changelog**: [v0.7.2...v0.7.3](https://github.com/AprilNEA/OpenLogi/compare/v0.7.2...v0.7.3)

## v0.7.2 [#v072]

*Released August 20, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.7.2)

#### What's Changed [#whats-changed-21]

* fix(agent): reapply volatile settings after Windows resume by @davidbudnick in [#639](https://github.com/AprilNEA/OpenLogi/pull/639)
* build(nix): make Linux packaging first-class by @AprilNEA in [#632](https://github.com/AprilNEA/OpenLogi/pull/632)
* chore(infra): add CODEOWNERS by @AprilNEA in [#646](https://github.com/AprilNEA/OpenLogi/pull/646)
* fix: bind the Actions Ring overlay to one agent run by @AprilNEA in [#645](https://github.com/AprilNEA/OpenLogi/pull/645)
* chore: converge the repository top level by @AprilNEA in [#647](https://github.com/AprilNEA/OpenLogi/pull/647)
* refactor(gui): split the GPUI side into app, ui, and overlay crates by @AprilNEA in [#672](https://github.com/AprilNEA/OpenLogi/pull/672)
* refactor(ipc): replace the GUI's poll loop with a level-triggered state channel by @AprilNEA in [#675](https://github.com/AprilNEA/OpenLogi/pull/675)
* build(windows): ship the openlogi CLI in the zip and MSI by @yuzi-co in [#659](https://github.com/AprilNEA/OpenLogi/pull/659)
* build(infra): drop dependency debuginfo in dev builds by @AprilNEA in [#695](https://github.com/AprilNEA/OpenLogi/pull/695)
* refactor(core): extract openlogi-permissions and give shared constants one definition by @AprilNEA in [#697](https://github.com/AprilNEA/OpenLogi/pull/697)
* fix(gui): use the GPUI executor timer, not tokio::time::interval, for camera scans by @charliecai01 in [#686](https://github.com/AprilNEA/OpenLogi/pull/686)
* fix(overlay): prevent GPUI from exiting on window close by @clifinger in [#700](https://github.com/AprilNEA/OpenLogi/pull/700)
* fix(agent): relaunch after granting Input Monitoring by @AprilNEA in [#713](https://github.com/AprilNEA/OpenLogi/pull/713)
* chore: release v0.7.2 by @aprilnea\[bot] in [#642](https://github.com/AprilNEA/OpenLogi/pull/642)

#### New Contributors [#new-contributors-12]

* @yuzi-co made their first contribution in [#659](https://github.com/AprilNEA/OpenLogi/pull/659)
* @charliecai01 made their first contribution in [#686](https://github.com/AprilNEA/OpenLogi/pull/686)
* @clifinger made their first contribution in [#700](https://github.com/AprilNEA/OpenLogi/pull/700)

**Full Changelog**: [v0.7.1...v0.7.2](https://github.com/AprilNEA/OpenLogi/compare/v0.7.1...v0.7.2)

## v0.7.1 [#v071]

*Released August 15, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.7.1)

#### What's Changed [#whats-changed-22]

* fix(hid): never switch a device to a host slot it is not paired on by @AprilNEA in [#631](https://github.com/AprilNEA/OpenLogi/pull/631)
* fix(assets): trust the OS certificate store, not bundled Mozilla roots by @davidbudnick in [#634](https://github.com/AprilNEA/OpenLogi/pull/634)
* feat(gui): add a reversed Volume preset for the thumb wheel by @tonysurfly in [#608](https://github.com/AprilNEA/OpenLogi/pull/608)
* ci(release): embed the updater key in Windows release builds by @davidbudnick in [#633](https://github.com/AprilNEA/OpenLogi/pull/633)
* fix(infra): stop ignoring the committed Amp orb lifecycle scripts by @davidbudnick in [#637](https://github.com/AprilNEA/OpenLogi/pull/637)
* chore(gui): bump gpui-updater to v0.0.7 for the OS-trust-store fix by @davidbudnick in [#636](https://github.com/AprilNEA/OpenLogi/pull/636)
* fix(agent): request Input Monitoring access at startup on macOS by @tonysurfly in [#607](https://github.com/AprilNEA/OpenLogi/pull/607)
* chore: release v0.7.1 by @aprilnea\[bot] in [#638](https://github.com/AprilNEA/OpenLogi/pull/638)

#### New Contributors [#new-contributors-13]

* @tonysurfly made their first contribution in [#608](https://github.com/AprilNEA/OpenLogi/pull/608)

**Full Changelog**: [v0.7.0...v0.7.1](https://github.com/AprilNEA/OpenLogi/compare/v0.7.0...v0.7.1)

## v0.7.0 [#v070]

*Released August 15, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.7.0)

#### What's Changed [#whats-changed-23]

* fix(xtask): stamp and verify the macOS bundle identity per channel by @AprilNEA in [#593](https://github.com/AprilNEA/OpenLogi/pull/593)
* refactor(macos): read TCC permissions through typed objc2 bindings by @AprilNEA in [#595](https://github.com/AprilNEA/OpenLogi/pull/595)
* fix(hid): four defects from the Actions Ring review by @AprilNEA in [#596](https://github.com/AprilNEA/OpenLogi/pull/596)
* fix(hid): never cache a haptic feature for a retired channel by @AprilNEA in [#597](https://github.com/AprilNEA/OpenLogi/pull/597)
* fix(agent): bound the Actions Ring haptic worker to the session it serves by @AprilNEA in [#598](https://github.com/AprilNEA/OpenLogi/pull/598)
* fix: keep the ring session alive as long as the ring is clickable by @AprilNEA in [#599](https://github.com/AprilNEA/OpenLogi/pull/599)
* build(nix): build dependencies once with crane, and stop gating source-only PRs on it by @AprilNEA in [#600](https://github.com/AprilNEA/OpenLogi/pull/600)
* ci(nix): run the Linux build weekly, and revert crane by @AprilNEA in [#603](https://github.com/AprilNEA/OpenLogi/pull/603)
* chore(lints): adopt the shared clippy lint set, and fix what it surfaced by @AprilNEA in [#609](https://github.com/AprilNEA/OpenLogi/pull/609)
* refactor(core,inject): collapse inject backends onto a platform-neutral Effect IR by @AprilNEA in [#610](https://github.com/AprilNEA/OpenLogi/pull/610)
* refactor(agent): collapse hardware.rs's duplicated write skeletons into DeviceOp by @AprilNEA in [#612](https://github.com/AprilNEA/OpenLogi/pull/612)
* refactor(gui): fold AppState's per-device side tables and dpi/smartshift twins by @AprilNEA in [#613](https://github.com/AprilNEA/OpenLogi/pull/613)
* refactor(core): move pure HID++ wire types into openlogi-core, drop openlogi-hid from the GUI by @AprilNEA in [#611](https://github.com/AprilNEA/OpenLogi/pull/611)
* chore(agent): drop three dead hardware.rs entry points by @AprilNEA in [#616](https://github.com/AprilNEA/OpenLogi/pull/616)
* refactor(ipc): extract the IPC contract into a leaf crate so the GUI stops building the HID stack by @AprilNEA in [#617](https://github.com/AprilNEA/OpenLogi/pull/617)
* fix(release): put openlogi-camera in the workspace version group by @AprilNEA in [#618](https://github.com/AprilNEA/OpenLogi/pull/618)
* refactor(camera): fold per-control UVC attributes into one spec table by @AprilNEA in [#622](https://github.com/AprilNEA/OpenLogi/pull/622)
* refactor(core): drive Action label/category/icon/catalog from one table by @AprilNEA in [#623](https://github.com/AprilNEA/OpenLogi/pull/623)
* chore(deps): bump tarpc to 0.38 and drop the vendored patch by @AprilNEA in [#624](https://github.com/AprilNEA/OpenLogi/pull/624)
* refactor(hidpp): replace the per-feature boilerplate with a generic and a derive macro by @AprilNEA in [#625](https://github.com/AprilNEA/OpenLogi/pull/625)
* docs(ci): add rustdoc to the local gate and cover the derive crate by @AprilNEA in [#626](https://github.com/AprilNEA/OpenLogi/pull/626)
* chore(hidpp): inherit the workspace lint table by @AprilNEA in [#627](https://github.com/AprilNEA/OpenLogi/pull/627)
* refactor(hidpp): test the receiver decoders, split channel.rs, finish the hard-fork cleanup by @AprilNEA in [#628](https://github.com/AprilNEA/OpenLogi/pull/628)
* fix(hid): drive the DPI and lighting features whose predecessors we hardcoded by @AprilNEA in [#629](https://github.com/AprilNEA/OpenLogi/pull/629)
* fix(config): harden schema and persistence by @AprilNEA in [#604](https://github.com/AprilNEA/OpenLogi/pull/604)
* chore: release v0.7.0 by @aprilnea\[bot] in [#594](https://github.com/AprilNEA/OpenLogi/pull/594)

**Full Changelog**: [v0.6.27...v0.7.0](https://github.com/AprilNEA/OpenLogi/compare/v0.6.27...v0.7.0)

## v0.6.27 [#v0627]

*Released August 13, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.27)

#### What's Changed [#whats-changed-24]

* fix(ui): prevent middle and thumb wheel popover flicker by @the-long-ride in [#559](https://github.com/AprilNEA/OpenLogi/pull/559)
* fix(hook): release macOS tap after accessibility revocation by @yuki-yano in [#578](https://github.com/AprilNEA/OpenLogi/pull/578)
* feat(hid,hidpp): read battery over BatteryVoltage (0x1001) by @bobaoapae in [#575](https://github.com/AprilNEA/OpenLogi/pull/575)
* feat(hid): recognise Lightspeed receiver 046d:c547 (G915, G502 X) by @bobaoapae in [#574](https://github.com/AprilNEA/OpenLogi/pull/574)
* feat: capture the MX Master 4 haptic panel as a first-class control by @tagawa0525 in [#565](https://github.com/AprilNEA/OpenLogi/pull/565)
* fix(macos): prevent corrupted small app icons by @LJAYi in [#570](https://github.com/AprilNEA/OpenLogi/pull/570)
* fix(hid): widen the Bolt per-slot probe budget for high-latency USB paths by @tagawa0525 in [#562](https://github.com/AprilNEA/OpenLogi/pull/562)
* feat(gui): back navigation via the mouse's back button and Alt+Left by @tagawa0525 in [#563](https://github.com/AprilNEA/OpenLogi/pull/563)
* Revive the Nix flake for Linux, without the cargoHash churn that led to #262 by @tagawa0525 in [#491](https://github.com/AprilNEA/OpenLogi/pull/491)
* feat(hid): persist the immutable probe cache across restarts by @tagawa0525 in [#564](https://github.com/AprilNEA/OpenLogi/pull/564)
* feat(gui): add capability-driven actions ring by @jericho0521 in [#528](https://github.com/AprilNEA/OpenLogi/pull/528)
* feat(core): support stable Windows app selectors by @markus41 in [#572](https://github.com/AprilNEA/OpenLogi/pull/572)
* ci(nix): cache store paths with magic-nix-cache by @davidbudnick in [#580](https://github.com/AprilNEA/OpenLogi/pull/580)
* feat: per-slot custom labels for the Actions Ring by @isleofgreg in [#584](https://github.com/AprilNEA/OpenLogi/pull/584)
* fix(gui): redraw the Actions Ring on hover changes by @isleofgreg in [#585](https://github.com/AprilNEA/OpenLogi/pull/585)
* feat(agent): add a hardware-free mock agent for GUI development by @AprilNEA in [#568](https://github.com/AprilNEA/OpenLogi/pull/568)
* refactor: per-button gesture mode, replacing the one-owner lock by @tagawa0525 in [#566](https://github.com/AprilNEA/OpenLogi/pull/566)
* fix(agent): implement the Actions Ring IPC surface in the mock agent by @isleofgreg in [#587](https://github.com/AprilNEA/OpenLogi/pull/587)
* fix: Actions Ring haptic reliability — coalescing, feature cache, firmware arming, deadlock guards by @isleofgreg in [#590](https://github.com/AprilNEA/OpenLogi/pull/590)
* feat(agent): pressing the ring trigger again dismisses the Actions Ring by @isleofgreg in [#592](https://github.com/AprilNEA/OpenLogi/pull/592)
* fix(hid): detect and recover dead-delivery HID channels by @isleofgreg in [#589](https://github.com/AprilNEA/OpenLogi/pull/589)
* feat(gui): dismiss the Actions Ring on a click outside it by @isleofgreg in [#591](https://github.com/AprilNEA/OpenLogi/pull/591)
* fix(gui): open the Actions Ring on the display containing the cursor by @isleofgreg in [#588](https://github.com/AprilNEA/OpenLogi/pull/588)
* chore: release v0.6.27 by @aprilnea\[bot] in [#579](https://github.com/AprilNEA/OpenLogi/pull/579)

#### New Contributors [#new-contributors-14]

* @bobaoapae made their first contribution in [#575](https://github.com/AprilNEA/OpenLogi/pull/575)
* @LJAYi made their first contribution in [#570](https://github.com/AprilNEA/OpenLogi/pull/570)
* @jericho0521 made their first contribution in [#528](https://github.com/AprilNEA/OpenLogi/pull/528)
* @markus41 made their first contribution in [#572](https://github.com/AprilNEA/OpenLogi/pull/572)
* @isleofgreg made their first contribution in [#584](https://github.com/AprilNEA/OpenLogi/pull/584)

**Full Changelog**: [v0.6.26...v0.6.27](https://github.com/AprilNEA/OpenLogi/compare/v0.6.26...v0.6.27)

## v0.6.26 [#v0626]

*Released August 10, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.26)

#### What's Changed [#whats-changed-25]

* fix(macos): add camera hardened-runtime entitlement by @davidbudnick in [#557](https://github.com/AprilNEA/OpenLogi/pull/557)
* chore: release v0.6.26 by @aprilnea\[bot] in [#558](https://github.com/AprilNEA/OpenLogi/pull/558)

**Full Changelog**: [v0.6.25...v0.6.26](https://github.com/AprilNEA/OpenLogi/compare/v0.6.25...v0.6.26)

## v0.6.25 [#v0625]

*Released August 10, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.25)

#### What's Changed [#whats-changed-26]

* ci(release): keep release job on a branch by @davidbudnick in [#543](https://github.com/AprilNEA/OpenLogi/pull/543)
* fix(gui,assets): fit the Keys tab to legacy keyboard assets (G513) by @davidbudnick in [#544](https://github.com/AprilNEA/OpenLogi/pull/544)
* fix(hidpp): retry lost feature-table reads during enumeration by @kohjunhao in [#469](https://github.com/AprilNEA/OpenLogi/pull/469)
* feat: add MX Master 2S (3S) thumb wheel bindings by @the-long-ride in [#525](https://github.com/AprilNEA/OpenLogi/pull/525)
* feat(hid): recognise Lightspeed nano receivers (G-series, e.g. G305) by @kiwimaker in [#388](https://github.com/AprilNEA/OpenLogi/pull/388)
* fix(i18n): skip Crowdin English fill-in and restore locale parity by @davidbudnick in [#551](https://github.com/AprilNEA/OpenLogi/pull/551)
* feat: per-device capture — every online device gets its own session, bindings, and settings by @tagawa0525 in [#419](https://github.com/AprilNEA/OpenLogi/pull/419)
* fix(ci): merge Crowdin downloads into locale catalogs by @davidbudnick in [#553](https://github.com/AprilNEA/OpenLogi/pull/553)
* fix(gui,camera,xtask): make Camera permission grantable on macOS by @davidbudnick in [#550](https://github.com/AprilNEA/OpenLogi/pull/550)
* fix(hook): capture keyboard events on Windows by @davidbudnick in [#548](https://github.com/AprilNEA/OpenLogi/pull/548)
* fix(i18n): add camera permission locale keys by @davidbudnick in [#554](https://github.com/AprilNEA/OpenLogi/pull/554)
* chore: release v0.6.25 by @aprilnea\[bot] in [#545](https://github.com/AprilNEA/OpenLogi/pull/545)

#### New Contributors [#new-contributors-15]

* @kohjunhao made their first contribution in [#469](https://github.com/AprilNEA/OpenLogi/pull/469)
* @the-long-ride made their first contribution in [#525](https://github.com/AprilNEA/OpenLogi/pull/525)
* @kiwimaker made their first contribution in [#388](https://github.com/AprilNEA/OpenLogi/pull/388)

**Full Changelog**: [v0.6.24...v0.6.25](https://github.com/AprilNEA/OpenLogi/compare/v0.6.24...v0.6.25)

## v0.6.24 [#v0624]

*Released August 10, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.24)

#### What's Changed [#whats-changed-27]

* fix(i18n): complete Crowdin synchronization by @AprilNEA in [#508](https://github.com/AprilNEA/OpenLogi/pull/508)
* fix(agent): reuse inventory channels for input capture by @safonin in [#522](https://github.com/AprilNEA/OpenLogi/pull/522)
* ci(release): pin tags and fill changelog by @davidbudnick in [#533](https://github.com/AprilNEA/OpenLogi/pull/533)
* Back and Forward mouse buttons now work in Safari and Chrome by @b0x42 in [#363](https://github.com/AprilNEA/OpenLogi/pull/363)
* fix(agent): route hardware operations through inventory channels by @safonin in [#532](https://github.com/AprilNEA/OpenLogi/pull/532)
* feat(battery): support legacy 0x1000 BatteryStatus and its charging quirk by @laofun in [#312](https://github.com/AprilNEA/OpenLogi/pull/312)
* feat(backlight): support HID++ 0x1982 by @kirgene in [#470](https://github.com/AprilNEA/OpenLogi/pull/470)
* feat(camera): add Logitech webcam support by @davidbudnick in [#531](https://github.com/AprilNEA/OpenLogi/pull/531)
* Feat/linked host switching by @LCozatl in [#479](https://github.com/AprilNEA/OpenLogi/pull/479)
* fix(hook): never wedge system pointer input by @davidbudnick in [#534](https://github.com/AprilNEA/OpenLogi/pull/534)
* feat(hook): Wayland frontmost-window backends (wlroots + GNOME Shell) by @recchia in [#191](https://github.com/AprilNEA/OpenLogi/pull/191)
* feat: keyboard F-row key remapping and fn-lock over HID++ by @Sankew in [#395](https://github.com/AprilNEA/OpenLogi/pull/395)
* feat(hid): add standalone Litra light support by @tiaringhio in [#513](https://github.com/AprilNEA/OpenLogi/pull/513)
* feat: add function key remapper by @MichaelDanCurtis in [#344](https://github.com/AprilNEA/OpenLogi/pull/344)
* ci(i18n): fix Crowdin translation branch push by @davidbudnick in [#535](https://github.com/AprilNEA/OpenLogi/pull/535)
* docs: harden agent local-gate and push rules by @davidbudnick in [#536](https://github.com/AprilNEA/OpenLogi/pull/536)
* feat(hid): recognize Lightspeed receiver (046d:c539) as Unifying-compatible by @Abnersouza7 in [#510](https://github.com/AprilNEA/OpenLogi/pull/510)
* ci(i18n): Crowdin sync for non-English only by @davidbudnick in [#538](https://github.com/AprilNEA/OpenLogi/pull/538)
* ci(i18n): seed Crowdin per language before download by @davidbudnick in [#540](https://github.com/AprilNEA/OpenLogi/pull/540)
* fix(agent): reapply volatile settings after macOS resume by @LuciusChen in [#506](https://github.com/AprilNEA/OpenLogi/pull/506)
* fix(linux): grant uaccess on Logitech input event nodes by @Xabierland in [#530](https://github.com/AprilNEA/OpenLogi/pull/530)
* fix(agent): rearm control capture after device reconnect by @Phecda in [#450](https://github.com/AprilNEA/OpenLogi/pull/450)
* fix(hidpp): keep events when a field carries an unknown enum value by @bugprone in [#432](https://github.com/AprilNEA/OpenLogi/pull/432)
* refactor: split hub modules to cut merge conflicts by @davidbudnick in [#542](https://github.com/AprilNEA/OpenLogi/pull/542)
* fix(agent): prefer online device for input capture by @Phecda in [#453](https://github.com/AprilNEA/OpenLogi/pull/453)
* fix(agent-core): retry volatile DPI re-apply on cold boot by @iamshakibali in [#449](https://github.com/AprilNEA/OpenLogi/pull/449)
* chore: release v0.6.24 by @aprilnea\[bot] in [#509](https://github.com/AprilNEA/OpenLogi/pull/509)

#### New Contributors [#new-contributors-16]

* @safonin made their first contribution in [#522](https://github.com/AprilNEA/OpenLogi/pull/522)
* @b0x42 made their first contribution in [#363](https://github.com/AprilNEA/OpenLogi/pull/363)
* @kirgene made their first contribution in [#470](https://github.com/AprilNEA/OpenLogi/pull/470)
* @LCozatl made their first contribution in [#479](https://github.com/AprilNEA/OpenLogi/pull/479)
* @Sankew made their first contribution in [#395](https://github.com/AprilNEA/OpenLogi/pull/395)
* @tiaringhio made their first contribution in [#513](https://github.com/AprilNEA/OpenLogi/pull/513)
* @MichaelDanCurtis made their first contribution in [#344](https://github.com/AprilNEA/OpenLogi/pull/344)
* @Abnersouza7 made their first contribution in [#510](https://github.com/AprilNEA/OpenLogi/pull/510)
* @LuciusChen made their first contribution in [#506](https://github.com/AprilNEA/OpenLogi/pull/506)
* @Xabierland made their first contribution in [#530](https://github.com/AprilNEA/OpenLogi/pull/530)
* @bugprone made their first contribution in [#432](https://github.com/AprilNEA/OpenLogi/pull/432)
* @iamshakibali made their first contribution in [#449](https://github.com/AprilNEA/OpenLogi/pull/449)

**Full Changelog**: [v0.6.23...v0.6.24](https://github.com/AprilNEA/OpenLogi/compare/v0.6.23...v0.6.24)

## v0.6.23 [#v0623]

*Released August 3, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.23)

#### What's Changed [#whats-changed-28]

* ci(build): run PR installer builds only with needs: build label by @AprilNEA in [#443](https://github.com/AprilNEA/OpenLogi/pull/443)
* fix(hid): declare Win32\_System\_IO feature for windows-sys WriteFile by @Stanley5249 in [#458](https://github.com/AprilNEA/OpenLogi/pull/458)
* fix(windows): resolve the update check and fix the unreadable Update-failed badge by @davidbudnick in [#437](https://github.com/AprilNEA/OpenLogi/pull/437)
* chore(deps): bump quinn-proto from 0.11.14 to 0.11.16 by @dependabot\[bot] in [#476](https://github.com/AprilNEA/OpenLogi/pull/476)
* fix: allow local dev without devenv by @davidbudnick in [#486](https://github.com/AprilNEA/OpenLogi/pull/486)
* fix(deps): drop vulnerable opentelemetry\_sdk by @davidbudnick in [#487](https://github.com/AprilNEA/OpenLogi/pull/487)
* fix(gui): refresh the macOS device menu when inventory changes by @Phecda in [#456](https://github.com/AprilNEA/OpenLogi/pull/456)
* fix(gui): reject transient direct device identities by @Phecda in [#451](https://github.com/AprilNEA/OpenLogi/pull/451)
* ci: speed up pipelines with shared rust-cache by @davidbudnick in [#488](https://github.com/AprilNEA/OpenLogi/pull/488)
* fix(hook): report Windows pointer motion by differencing the cursor point by @Stanley5249 in [#473](https://github.com/AprilNEA/OpenLogi/pull/473)
* fix(tray): stop the Windows tray launching the CLI instead of the GUI by @Stanley5249 in [#460](https://github.com/AprilNEA/OpenLogi/pull/460)
* fix(hid): stop intermittent SmartShift InvalidArgument by @davidbudnick in [#489](https://github.com/AprilNEA/OpenLogi/pull/489)
* fix(hook): grab only relative pointer devices, never touchpads or pointing sticks by @kesleyfort in [#401](https://github.com/AprilNEA/OpenLogi/pull/401)
* fix: divert thumbwheel for rotation rebinds even without single-tap capability by @tagawa0525 in [#415](https://github.com/AprilNEA/OpenLogi/pull/415)
* chore: release v0.6.23 by @aprilnea\[bot] in [#442](https://github.com/AprilNEA/OpenLogi/pull/442)
* feat(gui): polish device and pointer visuals by @AprilNEA in [#495](https://github.com/AprilNEA/OpenLogi/pull/495)
* refactor(gui): use semantic pointer controls by @AprilNEA in [#496](https://github.com/AprilNEA/OpenLogi/pull/496)
* feat(windows): enable in-app MSI updates via gpui-updater v0.0.6 by @davidbudnick in [#504](https://github.com/AprilNEA/OpenLogi/pull/504)
* fix(hid,gui): stop the device list flapping on transient probe failures by @davidbudnick in [#490](https://github.com/AprilNEA/OpenLogi/pull/490)
* feat(gui): show SmartShift write feedback by @AprilNEA in [#498](https://github.com/AprilNEA/OpenLogi/pull/498)
* fix(i18n): localize offline and pairing states by @AprilNEA in [#499](https://github.com/AprilNEA/OpenLogi/pull/499)
* fix(gui): expose device controls to accessibility by @AprilNEA in [#500](https://github.com/AprilNEA/OpenLogi/pull/500)
* feat(gui): clarify pairing failures and offline editing by @AprilNEA in [#501](https://github.com/AprilNEA/OpenLogi/pull/501)
* fix(gui): keep selected gallery device in view by @AprilNEA in [#502](https://github.com/AprilNEA/OpenLogi/pull/502)
* fix(gui): improve card contrast and depth by @AprilNEA in [#507](https://github.com/AprilNEA/OpenLogi/pull/507)
* ci(i18n): harden Crowdin translation sync by @davidbudnick in [#505](https://github.com/AprilNEA/OpenLogi/pull/505)

#### New Contributors [#new-contributors-17]

* @Stanley5249 made their first contribution in [#458](https://github.com/AprilNEA/OpenLogi/pull/458)
* @dependabot\[bot] made their first contribution in [#476](https://github.com/AprilNEA/OpenLogi/pull/476)
* @Phecda made their first contribution in [#456](https://github.com/AprilNEA/OpenLogi/pull/456)
* @tagawa0525 made their first contribution in [#415](https://github.com/AprilNEA/OpenLogi/pull/415)

**Full Changelog**: [v0.6.22...v0.6.23](https://github.com/AprilNEA/OpenLogi/compare/v0.6.22...v0.6.23)

## v0.6.22 [#v0622]

*Released July 21, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.22)

#### What's Changed [#whats-changed-29]

* fix(gui): keep the post-sync asset repaint until the inventory is Ready by @AprilNEA in [#427](https://github.com/AprilNEA/OpenLogi/pull/427)
* feat(hidpp): add level-gated wire tracing for HID++ requests by @AprilNEA in [#439](https://github.com/AprilNEA/OpenLogi/pull/439)
* refactor(gui): introduce a design-token layer and unify chrome by @AprilNEA in [#440](https://github.com/AprilNEA/OpenLogi/pull/440)
* fix(hid): probe Bolt slots concurrently so slow devices finish enumerating by @AprilNEA in [#438](https://github.com/AprilNEA/OpenLogi/pull/438)
* refactor(gui): introduce a HIG-based typography scale by @AprilNEA in [#441](https://github.com/AprilNEA/OpenLogi/pull/441)
* chore: release v0.6.22 by @aprilnea\[bot] in [#425](https://github.com/AprilNEA/OpenLogi/pull/425)

**Full Changelog**: [v0.6.21...v0.6.22](https://github.com/AprilNEA/OpenLogi/compare/v0.6.21...v0.6.22)

## v0.6.21 [#v0621]

*Released July 19, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.21)

#### What's Changed [#whats-changed-30]

* feat(packaging): add Arch Linux release packages by @cserby in [#265](https://github.com/AprilNEA/OpenLogi/pull/265)
* fix: hotplug-driven inventory and phantom device-card suppression by @davidbudnick in [#346](https://github.com/AprilNEA/OpenLogi/pull/346)
* I18n(gui): Update pl localize by @mariuszkrzaczkowski in [#372](https://github.com/AprilNEA/OpenLogi/pull/372)
* fix(hid): bound the Bolt per-slot probe so one hung device can't drop the whole receiver (#218) by @buliwyf42 in [#251](https://github.com/AprilNEA/OpenLogi/pull/251)
* fix(hid): persist wheel resolution across reconnects by @yuki-yano in [#416](https://github.com/AprilNEA/OpenLogi/pull/416)
* fix: make one-shot enumerate() retry transport-agnostic so Unifying partial drains recover by @mvanhorn in [#287](https://github.com/AprilNEA/OpenLogi/pull/287)
* test(core): add hires\_wheel to the inventory equality test helper by @AprilNEA in [#417](https://github.com/AprilNEA/OpenLogi/pull/417)
* fix(unifying): enable wireless notifications so paired devices enumerate by @laofun in [#309](https://github.com/AprilNEA/OpenLogi/pull/309)
* fix(gui): draw a client-side titlebar on Linux for window controls by @stefan-siebert in [#338](https://github.com/AprilNEA/OpenLogi/pull/338)
* docs: sync READMEs with current platform support by @AprilNEA in [#418](https://github.com/AprilNEA/OpenLogi/pull/418)
* ci(release): build installers on PRs via a reusable build.yml by @AprilNEA in [#423](https://github.com/AprilNEA/OpenLogi/pull/423)
* Windows follow-ups: app icon, working update checks, settings fixes, docs by @davidbudnick in [#358](https://github.com/AprilNEA/OpenLogi/pull/358)
* feat(assets): race Cloudflare and npm mirrors by @AprilNEA in [#424](https://github.com/AprilNEA/OpenLogi/pull/424)
* chore: release v0.6.21 by @aprilnea\[bot] in [#414](https://github.com/AprilNEA/OpenLogi/pull/414)

#### New Contributors [#new-contributors-18]

* @mariuszkrzaczkowski made their first contribution in [#372](https://github.com/AprilNEA/OpenLogi/pull/372)
* @yuki-yano made their first contribution in [#416](https://github.com/AprilNEA/OpenLogi/pull/416)

**Full Changelog**: [v0.6.20...v0.6.21](https://github.com/AprilNEA/OpenLogi/compare/v0.6.20...v0.6.21)

## v0.6.20 [#v0620]

*Released July 18, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.20)

#### What's Changed [#whats-changed-31]

* fix(smartshift): stop runaway free-spin scroll and SmartShift control snap-back by @AprilNEA in [#333](https://github.com/AprilNEA/OpenLogi/pull/333)
* docs: add agent guides (AGENTS.md, CLAUDE.md, path-scoped rules) by @AprilNEA in [#379](https://github.com/AprilNEA/OpenLogi/pull/379)
* fix(gui): avoid RefCell panic when applying theme on OS appearance change by @stefan-siebert in [#337](https://github.com/AprilNEA/OpenLogi/pull/337)
* docs: fold completed FIXDRY audit into a decision log by @AprilNEA in [#352](https://github.com/AprilNEA/OpenLogi/pull/352)
* ci: add cargo-deny (advisories, licenses, bans, sources) by @AprilNEA in [#353](https://github.com/AprilNEA/OpenLogi/pull/353)
* fix(gui): bump gpui/gpui-component pins past the Wayland startup activation rework by @kesleyfort in [#400](https://github.com/AprilNEA/OpenLogi/pull/400)
* fix: embed and sign the openlogi CLI in the macOS app bundle by @mvanhorn in [#362](https://github.com/AprilNEA/OpenLogi/pull/362)
* chore: raise MSRV to 1.96, drop fs4, consolidate workspace deps by @AprilNEA in [#407](https://github.com/AprilNEA/OpenLogi/pull/407)
* test: characterize the hidpp v20 matcher and cover the CLI by @AprilNEA in [#406](https://github.com/AprilNEA/OpenLogi/pull/406)
* refactor(core): split config.rs into settings and device submodules by @AprilNEA in [#408](https://github.com/AprilNEA/OpenLogi/pull/408)
* refactor(gui): split app.rs and drop the stale dead\_code allow by @AprilNEA in [#409](https://github.com/AprilNEA/OpenLogi/pull/409)
* refactor(inject): split inject.rs into per-platform modules by @AprilNEA in [#410](https://github.com/AprilNEA/OpenLogi/pull/410)
* refactor: adopt newly stabilized std APIs across the workspace by @AprilNEA in [#411](https://github.com/AprilNEA/OpenLogi/pull/411)
* fix(hook): break the macOS run loop on a stop flag, not CFRunLoopStop alone by @AprilNEA in [#263](https://github.com/AprilNEA/OpenLogi/pull/263)
* refactor: land the standards-audit findings in batches by @AprilNEA in [#381](https://github.com/AprilNEA/OpenLogi/pull/381)
* feat: CGEvent tap diagnostics — conflict detection + live event monitor by @AprilNEA in [#276](https://github.com/AprilNEA/OpenLogi/pull/276)
* chore: release v0.6.20 by @aprilnea\[bot] in [#378](https://github.com/AprilNEA/OpenLogi/pull/378)

#### New Contributors [#new-contributors-19]

* @stefan-siebert made their first contribution in [#337](https://github.com/AprilNEA/OpenLogi/pull/337)
* @kesleyfort made their first contribution in [#400](https://github.com/AprilNEA/OpenLogi/pull/400)

**Full Changelog**: [v0.6.19...v0.6.20](https://github.com/AprilNEA/OpenLogi/compare/v0.6.19...v0.6.20)

## v0.6.19 [#v0619]

*Released July 5, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.19)

#### What's Changed [#whats-changed-32]

* feat(windows): bundle the background agent and add a tray icon (#347) by @davidbudnick in [#350](https://github.com/AprilNEA/OpenLogi/pull/350)
* chore: release v0.6.19 by @aprilnea\[bot] in [#332](https://github.com/AprilNEA/OpenLogi/pull/332)

**Full Changelog**: [v0.6.18...v0.6.19](https://github.com/AprilNEA/OpenLogi/compare/v0.6.18...v0.6.19)

## v0.6.18 [#v0618]

*Released June 29, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.18)

#### What's Changed [#whats-changed-33]

* fix: set app\_id on GUI windows so GNOME Wayland matches the .desktop icon and decorations by @mvanhorn in [#320](https://github.com/AprilNEA/OpenLogi/pull/320)
* Clarify MX Master 4 gesture control semantics by @AprilNEA in [#325](https://github.com/AprilNEA/OpenLogi/pull/325)
* Complete typed HID++ feature wrappers by @AprilNEA in [#326](https://github.com/AprilNEA/OpenLogi/pull/326)
* fix(hidpp): correct UnifiedBattery (0x1004) charging status codes by @PeronGH in [#330](https://github.com/AprilNEA/OpenLogi/pull/330)
* feat(gui): consolidate Settings and add theme switching by @AprilNEA in [#331](https://github.com/AprilNEA/OpenLogi/pull/331)
* chore: release v0.6.18 by @aprilnea\[bot] in [#324](https://github.com/AprilNEA/OpenLogi/pull/324)

#### New Contributors [#new-contributors-20]

* @PeronGH made their first contribution in [#330](https://github.com/AprilNEA/OpenLogi/pull/330)

**Full Changelog**: [v0.6.17...v0.6.18](https://github.com/AprilNEA/OpenLogi/compare/v0.6.17...v0.6.18)

## v0.6.17 [#v0617]

*Released June 27, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.17)

#### What's Changed [#whats-changed-34]

* feat: add Capture Region to Clipboard button action by @mvanhorn in [#296](https://github.com/AprilNEA/OpenLogi/pull/296)
* feat(gui): show connection-type icon on device cards by @laofun in [#310](https://github.com/AprilNEA/OpenLogi/pull/310)
* chore: release v0.6.17 by @aprilnea\[bot] in [#311](https://github.com/AprilNEA/OpenLogi/pull/311)

**Full Changelog**: [v0.6.16...v0.6.17](https://github.com/AprilNEA/OpenLogi/compare/v0.6.16...v0.6.17)

## v0.6.16 [#v0616]

*Released June 22, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.16)

#### What's Changed [#whats-changed-35]

* fix(hidpp): prune abandoned event receivers by @AprilNEA in [#297](https://github.com/AprilNEA/OpenLogi/pull/297)
* fix(pairing): keep session pause balanced by @AprilNEA in [#298](https://github.com/AprilNEA/OpenLogi/pull/298)
* fix(hidpp): guard message listener lifetimes by @AprilNEA in [#299](https://github.com/AprilNEA/OpenLogi/pull/299)
* feat(ipc): poll agent snapshots by @AprilNEA in [#300](https://github.com/AprilNEA/OpenLogi/pull/300)
* refactor(agent): arbitrate receiver access with leases by @AprilNEA in [#302](https://github.com/AprilNEA/OpenLogi/pull/302)
* refactor(ipc): acknowledge pairing commands by @AprilNEA in [#304](https://github.com/AprilNEA/OpenLogi/pull/304)
* chore: release v0.6.16 by @aprilnea\[bot] in [#301](https://github.com/AprilNEA/OpenLogi/pull/301)

**Full Changelog**: [v0.6.15...v0.6.16](https://github.com/AprilNEA/OpenLogi/compare/v0.6.15...v0.6.16)

## v0.6.15 [#v0615]

*Released June 21, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.15)

#### What's Changed [#whats-changed-36]

* fix(release): re-register PSGallery before Windows Artifact Signing by @AprilNEA in [#264](https://github.com/AprilNEA/OpenLogi/pull/264)
* i18n: localize the About window update section + Crowdin sync by @AprilNEA in [#275](https://github.com/AprilNEA/OpenLogi/pull/275)
* fix(gui): fit keyboard image and render glow live by @davidbudnick in [#283](https://github.com/AprilNEA/OpenLogi/pull/283)
* feat(scroll): per-device inverted scrolling by @AprilNEA in [#294](https://github.com/AprilNEA/OpenLogi/pull/294)
* chore: release v0.6.15 by @aprilnea\[bot] in [#273](https://github.com/AprilNEA/OpenLogi/pull/273)

**Full Changelog**: [v0.6.14...v0.6.15](https://github.com/AprilNEA/OpenLogi/compare/v0.6.14...v0.6.15)

## v0.6.14 [#v0614]

*Released June 15, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.14)

#### What's Changed [#whats-changed-37]

* refactor(core): extract the OS input-injection layer into openlogi-inject by @AprilNEA in [#240](https://github.com/AprilNEA/OpenLogi/pull/240)
* fix(hid): solid keyboard colour via 0x8070 effect by @davidbudnick in [#205](https://github.com/AprilNEA/OpenLogi/pull/205)
* chore(nix): remove the self-hosted flake package by @AprilNEA in [#262](https://github.com/AprilNEA/OpenLogi/pull/262)
* chore: release v0.6.14 by @aprilnea\[bot] in [#261](https://github.com/AprilNEA/OpenLogi/pull/261)

**Full Changelog**: [v0.6.13...v0.6.14](https://github.com/AprilNEA/OpenLogi/compare/v0.6.13...v0.6.14)

## v0.6.13 [#v0613]

*Released June 15, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.13)

#### What's Changed [#whats-changed-38]

* fix(agent): respawn the capture session after a setup failure by @tfohlmeister in [#225](https://github.com/AprilNEA/OpenLogi/pull/225)
* chore: release v0.6.12 by @aprilnea\[bot] in [#250](https://github.com/AprilNEA/OpenLogi/pull/250)
* fix(hid): retry the one-shot enumerate() through a transient probe miss (#218) by @buliwyf42 in [#237](https://github.com/AprilNEA/OpenLogi/pull/237)
* fix(assets): verify a downloaded asset before it reaches the cache by @AprilNEA in [#243](https://github.com/AprilNEA/OpenLogi/pull/243)
* refactor(hook,hid): document the Windows FFI unsafe blocks by @AprilNEA in [#242](https://github.com/AprilNEA/OpenLogi/pull/242)
* fix(gui): localize the DPI panel's status strings by @AprilNEA in [#241](https://github.com/AprilNEA/OpenLogi/pull/241)
* refactor(hidpp): dedup the feature layer (registry data-macro + FeatureEndpoint) by @AprilNEA in [#238](https://github.com/AprilNEA/OpenLogi/pull/238)
* fix(gui): launch the agent helper via LaunchServices for a stable TCC identity by @davidbudnick in [#207](https://github.com/AprilNEA/OpenLogi/pull/207)
* refactor(gui): fold duplicated DPI/SmartShift code into shared abstractions by @AprilNEA in [#239](https://github.com/AprilNEA/OpenLogi/pull/239)
* fix(gui): give the bundled agent the OpenLogi app icon by @AprilNEA in [#260](https://github.com/AprilNEA/OpenLogi/pull/260)
* chore: release v0.6.13 by @aprilnea\[bot] in [#259](https://github.com/AprilNEA/OpenLogi/pull/259)

#### New Contributors [#new-contributors-21]

* @tfohlmeister made their first contribution in [#225](https://github.com/AprilNEA/OpenLogi/pull/225)
* @buliwyf42 made their first contribution in [#237](https://github.com/AprilNEA/OpenLogi/pull/237)

**Full Changelog**: [v0.6.12...v0.6.13](https://github.com/AprilNEA/OpenLogi/compare/v0.6.12...v0.6.13)

## v0.6.12 [#v0612]

*Released June 13, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.12)

#### What's Changed [#whats-changed-39]

* fix(agent): persist DPI/SmartShift per device and reapply volatile settings on reconnect by @AprilNEA in [#223](https://github.com/AprilNEA/OpenLogi/pull/223)
* fix(gui): keep asleep devices and their panels in the device list by @AprilNEA in [#224](https://github.com/AprilNEA/OpenLogi/pull/224)
* fix(updater): bump gpui-updater to v0.0.5 (relaunch as GUI, not in Terminal) by @AprilNEA in [#249](https://github.com/AprilNEA/OpenLogi/pull/249)
* chore: release v0.6.12 by @aprilnea\[bot] in [#246](https://github.com/AprilNEA/OpenLogi/pull/246)

**Full Changelog**: [v0.6.11...v0.6.12](https://github.com/AprilNEA/OpenLogi/compare/v0.6.11...v0.6.12)

## v0.6.11 [#v0611]

*Released June 13, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.11)

#### What's Changed [#whats-changed-40]

* fix(hid): replay a node's last inventory through transient probe failures by @AprilNEA in [#222](https://github.com/AprilNEA/OpenLogi/pull/222)
* fix(release): install the GUI's x11/wayland deps so Linux packaging links by @AprilNEA in [#244](https://github.com/AprilNEA/OpenLogi/pull/244)
* chore: release v0.6.11 by @aprilnea\[bot] in [#236](https://github.com/AprilNEA/OpenLogi/pull/236)

**Full Changelog**: [v0.6.10...v0.6.11](https://github.com/AprilNEA/OpenLogi/compare/v0.6.10...v0.6.11)

## v0.6.7 [#v067]

*Released June 12, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.7)

#### What's Changed [#whats-changed-41]

* feat(release): Windows arm64 build + static CRT by @AprilNEA in [#208](https://github.com/AprilNEA/OpenLogi/pull/208)
* fix: stop flashing the permission gate and empty state at startup by @AprilNEA in [#212](https://github.com/AprilNEA/OpenLogi/pull/212)
* feat(release): per-arch MSI installers + drop the GUI qualifier from Windows jobs by @AprilNEA in [#211](https://github.com/AprilNEA/OpenLogi/pull/211)
* fix: don't flash the empty-state screen while the agent is still scanning by @AprilNEA in [#213](https://github.com/AprilNEA/OpenLogi/pull/213)
* fix(ipc): harden the agent-GUI link end to end (review follow-ups for #212/#213) by @AprilNEA in [#215](https://github.com/AprilNEA/OpenLogi/pull/215)
* Fix macOS volume and media key posting by @jonstuebe in [#184](https://github.com/AprilNEA/OpenLogi/pull/184)
* fix(release): derive macOS bundle versions from the workspace by @AprilNEA in [#217](https://github.com/AprilNEA/OpenLogi/pull/217)
* chore: release v0.6.7 by @aprilnea\[bot] in [#210](https://github.com/AprilNEA/OpenLogi/pull/210)

#### New Contributors [#new-contributors-22]

* @jonstuebe made their first contribution in [#184](https://github.com/AprilNEA/OpenLogi/pull/184)

**Full Changelog**: [v0.6.6...v0.6.7](https://github.com/AprilNEA/OpenLogi/compare/v0.6.6...v0.6.7)

## v0.6.6 [#v066]

*Released June 10, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.6)

#### What's Changed [#whats-changed-42]

* ci: fix SignTool timestamp URL and add a signing dry-run workflow by @AprilNEA in [#202](https://github.com/AprilNEA/OpenLogi/pull/202)
* fix(hidpp): bound device-controlled name lengths in Bolt parsing by @AprilNEA in [#200](https://github.com/AprilNEA/OpenLogi/pull/200)
* fix(assets): keep sync writes inside the cache root and verified by @AprilNEA in [#201](https://github.com/AprilNEA/OpenLogi/pull/201)
* feat(windows): ship the signed GUI as the Windows release artifact by @AprilNEA in [#204](https://github.com/AprilNEA/OpenLogi/pull/204)
* chore: release v0.6.6 by @aprilnea\[bot] in [#203](https://github.com/AprilNEA/OpenLogi/pull/203)

**Full Changelog**: [v0.6.5...v0.6.6](https://github.com/AprilNEA/OpenLogi/compare/v0.6.5...v0.6.6)

## v0.6.5 [#v065]

*Released June 10, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.5)

#### What's Changed [#whats-changed-43]

* ci(release): harden the Windows signing and publish pipeline by @AprilNEA in [#198](https://github.com/AprilNEA/OpenLogi/pull/198)
* style: collapse nested ifs flagged by current stable clippy by @AprilNEA in [#197](https://github.com/AprilNEA/OpenLogi/pull/197)
* chore: release v0.6.5 by @aprilnea\[bot] in [#199](https://github.com/AprilNEA/OpenLogi/pull/199)

**Full Changelog**: [v0.6.4...v0.6.5](https://github.com/AprilNEA/OpenLogi/compare/v0.6.4...v0.6.5)

## v0.6.4 [#v064]

*Released June 10, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.4)

#### What's Changed [#whats-changed-44]

* fix(gui): stop sensitivity Default label clipping by @davidbudnick in [#186](https://github.com/AprilNEA/OpenLogi/pull/186)
* Minor: glow keyboard card in lighting colour by @davidbudnick in [#185](https://github.com/AprilNEA/OpenLogi/pull/185)
* feat(openlogi-gui): expand UI to 19 fully-translated locales by @davidbudnick in [#24](https://github.com/AprilNEA/OpenLogi/pull/24)
* refactor: unify device-kind precedence docs + drop the redundant caps carry-forward by @AprilNEA in [#194](https://github.com/AprilNEA/OpenLogi/pull/194)
* fix(hid): refresh the volatile battery every tick without the feature-table walk by @AprilNEA in [#193](https://github.com/AprilNEA/OpenLogi/pull/193)
* feat(windows): port the headless agent to Windows by @AprilNEA in [#167](https://github.com/AprilNEA/OpenLogi/pull/167)
* \[codex] Update time to fix RustSec advisory by @Nicolas0315 in [#128](https://github.com/AprilNEA/OpenLogi/pull/128)
* ci(release): sign Windows CLI build with Azure Artifact Signing by @AprilNEA in [#196](https://github.com/AprilNEA/OpenLogi/pull/196)
* chore: release v0.6.4 by @aprilnea\[bot] in [#190](https://github.com/AprilNEA/OpenLogi/pull/190)

#### New Contributors [#new-contributors-23]

* @Nicolas0315 made their first contribution in [#128](https://github.com/AprilNEA/OpenLogi/pull/128)

**Full Changelog**: [v0.6.3...v0.6.4](https://github.com/AprilNEA/OpenLogi/compare/v0.6.3...v0.6.4)

## v0.6.3 [#v063]

*Released June 9, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.3)

#### What's Changed [#whats-changed-45]

* feat: unified Binding model + gesture-button menu (gesture uplift Phase A) by @AprilNEA in [#187](https://github.com/AprilNEA/OpenLogi/pull/187)
* feat: gesture buttons on Middle/Back/Forward via the OS hook (Phase B) by @AprilNEA in [#188](https://github.com/AprilNEA/OpenLogi/pull/188)
* chore: release v0.6.3 by @aprilnea\[bot] in [#183](https://github.com/AprilNEA/OpenLogi/pull/183)

**Full Changelog**: [v0.6.2...v0.6.3](https://github.com/AprilNEA/OpenLogi/compare/v0.6.2...v0.6.3)

## v0.6.2 [#v062]

*Released June 8, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.2)

#### What's Changed [#whats-changed-46]

* chore: release v0.6.1 by @aprilnea\[bot] in [#175](https://github.com/AprilNEA/OpenLogi/pull/175)
* Integrate Crowdin localization workflow by @AprilNEA in [#174](https://github.com/AprilNEA/OpenLogi/pull/174)
* ci: switch release notes generation to Codex by @AprilNEA in [#177](https://github.com/AprilNEA/OpenLogi/pull/177)
* feat(tray): route menu actions to GUI via openlogi:// URL scheme by @AprilNEA in [#176](https://github.com/AprilNEA/OpenLogi/pull/176)
* chore: release v0.6.2 by @aprilnea\[bot] in [#178](https://github.com/AprilNEA/OpenLogi/pull/178)

**Full Changelog**: [v0.6.1...v0.6.2](https://github.com/AprilNEA/OpenLogi/compare/v0.6.1...v0.6.2)

## v0.6.1 [#v061]

*Released June 8, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.1)

#### What's Changed [#whats-changed-47]

* docs: Linux device permissions (udev rule for Bolt + Bluetooth-direct) by @recchia in [#149](https://github.com/AprilNEA/OpenLogi/pull/149)
* Fix Cmd+W window close handling by @AprilNEA in [#168](https://github.com/AprilNEA/OpenLogi/pull/168)
* fix(cli): diag selects a device that exposes the feature under test by @recchia in [#150](https://github.com/AprilNEA/OpenLogi/pull/150)
* Fix auxiliary window actions by @AprilNEA in [#170](https://github.com/AprilNEA/OpenLogi/pull/170)
* ci: generate release notes with Amp by @AprilNEA in [#171](https://github.com/AprilNEA/OpenLogi/pull/171)
* chore: release v0.6.1 by @aprilnea\[bot] in [#169](https://github.com/AprilNEA/OpenLogi/pull/169)

#### New Contributors [#new-contributors-24]

* @recchia made their first contribution in [#149](https://github.com/AprilNEA/OpenLogi/pull/149)

**Full Changelog**: [v0.6.0...v0.6.1](https://github.com/AprilNEA/OpenLogi/compare/v0.6.0...v0.6.1)

## v0.6.0 [#v060]

*Released June 8, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.6.0)

#### Highlights [#highlights-1]

OpenLogi 0.6.0 splits the app into a headless, always-on agent daemon and an on-demand GUI configuration editor. This makes remapping and device I/O keep running after the GUI window is closed, while the GUI becomes a normal app that opens only when you need to change settings.

#### What's new [#whats-new]

* Added `openlogi-agent`, a headless daemon that owns HID++ device I/O, the CGEventTap hook, foreground-app tracking, pairing, and the menu-bar status item.
* Reworked `openlogi-gui` into an on-demand configuration app that talks to the agent over IPC instead of running device I/O itself.
* Added a tarpc-based agent IPC protocol for inventory/status polling, DPI, lighting, SmartShift, config reloads, Accessibility prompts, and pairing.
* Moved device pairing into the agent so receiver pairing no longer competes with the live gesture/capture session from another process.
* The GUI can auto-launch the embedded agent helper when the IPC socket is unavailable.

#### macOS packaging and startup [#macos-packaging-and-startup]

* The packaged app now embeds `OpenLogiAgent.app` as a nested login-item helper under `OpenLogi.app/Contents/Library/LoginItems/`.
* Launch-at-login is now handled by the agent instead of the GUI.
* The menu-bar item is hosted by the agent, so remapping and tray access can stay alive independently of the GUI window.
* Release signing now signs the nested agent helper inside-out before signing the outer app, helping the agent keep a stable Accessibility/TCC identity across updates.
* Homebrew tap update dispatch for `openlogi@latest` has been re-enabled.

#### Fixes and hardening [#fixes-and-hardening]

* Fixed an agent single-instance race that could start two agents and install duplicate hooks.
* Fixed Accessibility prompting so macOS authorizes the agent binary, not the GUI.
* Fixed transient inventory errors wiping live runtime state or resetting DPI-cycle state.
* Fixed agent recovery when the GUI sees a dead or quit agent.
* Fixed LaunchAgent plist escaping and made tray **Quit** stay quit after a clean exit.
* Preserved typed device errors across IPC so unsupported DPI/SmartShift features latch correctly instead of retrying forever.
* Kept the agent and GUI device ordering consistent so the default selected device matches in both processes.
* Confirmed SmartShift writes with a re-read so rejected writes self-correct in the UI.
* Honored the Show-in-menu-bar preference at agent startup.
* Added IPC protocol handshake checks and pairing-session race fixes from review.

#### Upgrade notes [#upgrade-notes]

* On first launch after upgrading, macOS may ask you to grant Accessibility permission to `OpenLogiAgent`. This is expected: the agent is now the process that owns the input hook.
* If remapping does not work after upgrading, check System Settings → Privacy & Security → Accessibility and ensure the agent/helper is allowed, then restart OpenLogi.

#### Included PRs [#included-prs]

* \#165 — Split into a headless agent daemon + on-demand GUI
* \#161 — Dispatch `openlogi@latest` Homebrew tap updates
* \#164 — Release v0.6.0

**Full changelog**: [v0.5.3...v0.6.0](https://github.com/AprilNEA/OpenLogi/compare/v0.5.3...v0.6.0)

## v0.5.3 [#v053]

*Released June 6, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.5.3)

#### What's Changed [#whats-changed-48]

* fix: gate device config panels on HID++ capabilities (closes #127) by @AprilNEA in [#147](https://github.com/AprilNEA/OpenLogi/pull/147)
* chore: release v0.5.3 by @aprilnea\[bot] in [#157](https://github.com/AprilNEA/OpenLogi/pull/157)

**Full Changelog**: [v0.5.2...v0.5.3](https://github.com/AprilNEA/OpenLogi/compare/v0.5.2...v0.5.3)

## v0.5.2 [#v052]

*Released June 5, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.5.2)

#### What's Changed [#whats-changed-49]

* fix(gui): retry asset sync on failure with backoff instead of latching once by @AprilNEA in [#140](https://github.com/AprilNEA/OpenLogi/pull/140)
* refactor: replace hand-rolled code with std/num\_enum equivalents by @AprilNEA in [#141](https://github.com/AprilNEA/OpenLogi/pull/141)
* feat: enable Thumb Wheel Up/Down mapping, "Do Nothing" action, and native scroll sensitivity  by @Miguel-sdj in [#125](https://github.com/AprilNEA/OpenLogi/pull/125)
* feat(hook): implement Linux evdev+uinput mouse hook by @cserby in [#119](https://github.com/AprilNEA/OpenLogi/pull/119)
* feat(core): implement Action::execute on Linux via uinput by @cserby in [#120](https://github.com/AprilNEA/OpenLogi/pull/120)
* feat: SmartShift 0x2110 support (MX Master 2S) + diag smartshift --sensitivity by @laofun in [#77](https://github.com/AprilNEA/OpenLogi/pull/77)
* feat(hook): implement frontmost\_bundle\_id on Linux via X11 by @cserby in [#122](https://github.com/AprilNEA/OpenLogi/pull/122)
* feat(i18n): add italian language by @elax46 in [#63](https://github.com/AprilNEA/OpenLogi/pull/63)
* feat(core): LockScreen and media actions via D-Bus on Linux by @cserby in [#124](https://github.com/AprilNEA/OpenLogi/pull/124)
* ci: run clippy on Windows instead of bare cargo check by @AprilNEA in [#146](https://github.com/AprilNEA/OpenLogi/pull/146)
* chore: release v0.5.2 by @aprilnea\[bot] in [#139](https://github.com/AprilNEA/OpenLogi/pull/139)

#### New Contributors [#new-contributors-25]

* @Miguel-sdj made their first contribution in [#125](https://github.com/AprilNEA/OpenLogi/pull/125)
* @cserby made their first contribution in [#119](https://github.com/AprilNEA/OpenLogi/pull/119)
* @laofun made their first contribution in [#77](https://github.com/AprilNEA/OpenLogi/pull/77)
* @elax46 made their first contribution in [#63](https://github.com/AprilNEA/OpenLogi/pull/63)

**Full Changelog**: [v0.5.1...v0.5.2](https://github.com/AprilNEA/OpenLogi/compare/v0.5.1...v0.5.2)

## v0.5.1 [#v051]

*Released June 5, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.5.1)

#### What's Changed [#whats-changed-50]

* ci(release-plz): drop the unneeded stale-branch prune step by @AprilNEA in [#135](https://github.com/AprilNEA/OpenLogi/pull/135)
* fix(assets): match devices against every model id a depot lists by @AprilNEA in [#137](https://github.com/AprilNEA/OpenLogi/pull/137)
* feat(gui): SmartShift settings panel (wheel mode, sensitivity, permanent ratchet) by @AprilNEA in [#138](https://github.com/AprilNEA/OpenLogi/pull/138)
* chore: release v0.5.1 by @aprilnea\[bot] in [#136](https://github.com/AprilNEA/OpenLogi/pull/136)

**Full Changelog**: [v0.5.0...v0.5.1](https://github.com/AprilNEA/OpenLogi/compare/v0.5.0...v0.5.1)

## v0.5.0 [#v050]

*Released June 5, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.5.0)

#### What's Changed [#whats-changed-51]

* feat: add wired G-series keyboard RGB control by @davidbudnick in [#29](https://github.com/AprilNEA/OpenLogi/pull/29)
* fix(release-plz): restore unified-versioning config by @AprilNEA in [#110](https://github.com/AprilNEA/OpenLogi/pull/110)
* fix(gui): stop idle CPU drain from the connected status dot by @davidbudnick in [#97](https://github.com/AprilNEA/OpenLogi/pull/97)
* chore(release-plz): disable cargo-semver-checks by @AprilNEA in [#113](https://github.com/AprilNEA/OpenLogi/pull/113)
* docs: add ja/de/fr/ko READMEs and fix the relocated zh-CN one by @AprilNEA in [#112](https://github.com/AprilNEA/OpenLogi/pull/112)
* feat(gui): swap in the new app icon by @AprilNEA in [#129](https://github.com/AprilNEA/OpenLogi/pull/129)
* docs: use the new app icon as the README logo by @AprilNEA in [#132](https://github.com/AprilNEA/OpenLogi/pull/132)
* Migrate macOS ObjC FFI to objc2, fixing the menu-bar leak (#99) by @AprilNEA in [#131](https://github.com/AprilNEA/OpenLogi/pull/131)
* feat(updater): signed auto-update with fail-closed verification by @AprilNEA in [#130](https://github.com/AprilNEA/OpenLogi/pull/130)
* chore: release v0.5.0 by @aprilnea\[bot] in [#111](https://github.com/AprilNEA/OpenLogi/pull/111)

**Full Changelog**: [v0.4.1...v0.5.0](https://github.com/AprilNEA/OpenLogi/compare/v0.4.1...v0.5.0)

## v0.4.1 [#v041]

*Released June 3, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.4.1)

#### What's Changed [#whats-changed-52]

* chore: add GitHub issue form templates by @AprilNEA in [#102](https://github.com/AprilNEA/OpenLogi/pull/102)
* ci(release): disable homebrew-tap dispatch (openlogi moved to homebrew-cask) by @AprilNEA in [#105](https://github.com/AprilNEA/OpenLogi/pull/105)
* feat(nix): add nixpkgs package + flake; commit the prebuilt app icon by @AprilNEA in [#106](https://github.com/AprilNEA/OpenLogi/pull/106)
* feat(gui): add device gallery navigation by @AprilNEA in [#107](https://github.com/AprilNEA/OpenLogi/pull/107)
* chore: release v0.4.1 by @aprilnea\[bot] in [#91](https://github.com/AprilNEA/OpenLogi/pull/91)

**Full Changelog**: [v0.4.0...v0.4.1](https://github.com/AprilNEA/OpenLogi/compare/v0.4.0...v0.4.1)

## v0.4.0 [#v040]

*Released June 2, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.4.0)

#### What's Changed [#whats-changed-53]

* feat(i18n): add zh-TW (Traditional Chinese, Taiwan) locale by @PeterDaveHello in [#57](https://github.com/AprilNEA/OpenLogi/pull/57)
* feat(assets): accept both depot filename schemas in the GUI resolver by @AprilNEA in [#76](https://github.com/AprilNEA/OpenLogi/pull/76)
* fix(gui): keep Settings Language description on one line by @davidbudnick in [#72](https://github.com/AprilNEA/OpenLogi/pull/72)
* Polish empty state UI and improve user hints for UX/usability by @markosnarinian in [#88](https://github.com/AprilNEA/OpenLogi/pull/88)
* feat(core): add macos desktop switch actions by @LouisDISPA in [#67](https://github.com/AprilNEA/OpenLogi/pull/67)
* chore: release v0.4.0 by @aprilnea\[bot] in [#75](https://github.com/AprilNEA/OpenLogi/pull/75)

#### New Contributors [#new-contributors-26]

* @PeterDaveHello made their first contribution in [#57](https://github.com/AprilNEA/OpenLogi/pull/57)
* @markosnarinian made their first contribution in [#88](https://github.com/AprilNEA/OpenLogi/pull/88)
* @LouisDISPA made their first contribution in [#67](https://github.com/AprilNEA/OpenLogi/pull/67)

**Full Changelog**: [v0.3.4...v0.4.0](https://github.com/AprilNEA/OpenLogi/compare/v0.3.4...v0.4.0)

## v0.3.4 [#v034]

*Released June 1, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.3.4)

#### What's Changed [#whats-changed-54]

* chore(release): stop publishing the redundant generic -macos.dmg by @AprilNEA in [#58](https://github.com/AprilNEA/OpenLogi/pull/58)
* refactor(xtask): move macOS packaging tasks by @AprilNEA in [#47](https://github.com/AprilNEA/OpenLogi/pull/47)
* Vendor hidpp fork; BLE-direct + startup robustness; Settings permissions by @AprilNEA in [#45](https://github.com/AprilNEA/OpenLogi/pull/45)
* chore: release v0.3.4 by @aprilnea\[bot] in [#59](https://github.com/AprilNEA/OpenLogi/pull/59)

**Full Changelog**: [v0.3.3...v0.3.4](https://github.com/AprilNEA/OpenLogi/compare/v0.3.3...v0.3.4)

## v0.3.3 [#v033]

*Released June 1, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.3.3)

#### What's Changed [#whats-changed-55]

* chore: release v0.3.3 by @aprilnea\[bot] in [#56](https://github.com/AprilNEA/OpenLogi/pull/56)

**Full Changelog**: [v0.3.2...v0.3.3](https://github.com/AprilNEA/OpenLogi/compare/v0.3.2...v0.3.3)

## v0.3.2 [#v032]

*Released June 1, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.3.2)

#### What's Changed [#whats-changed-56]

* fix(release): let softprops own the draft-first GitHub Release by @AprilNEA in [#55](https://github.com/AprilNEA/OpenLogi/pull/55)
* chore: release v0.3.2 by @aprilnea\[bot] in [#49](https://github.com/AprilNEA/OpenLogi/pull/49)

**Full Changelog**: [v0.3.1...v0.3.2](https://github.com/AprilNEA/OpenLogi/compare/v0.3.1...v0.3.2)

## v0.3.1 [#v031]

*Released June 1, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.3.1)

##### Added [#added]

* *(updater)* use static R2 manifest ([#43](https://github.com/AprilNEA/OpenLogi/pull/43))

## v0.3.0 [#v030]

*Released June 1, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.3.0)

##### Added [#added-1]

* *(openlogi-gui)* add Russian localization and language select ([#38](https://github.com/AprilNEA/OpenLogi/pull/38))

##### Fixed [#fixed]

* *(gui)* stabilize device tab ordering ([#37](https://github.com/AprilNEA/OpenLogi/pull/37))

##### Known issue [#known-issue]

The macOS DMG assets for this release are unavailable. The release workflow published `v0.3.0` before uploading the generated assets, and GitHub marked the release immutable, so the DMG and checksum files cannot be attached afterward. This is a release packaging issue, not an application code issue; the workflow has been changed to keep future releases as drafts until all assets are uploaded.

## v0.2.0 [#v020]

*Released May 31, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.2.0)

OpenLogi **0.2.0** rolls up everything since `0.1.0`: directly-attached (wired & Bluetooth) mice are now first-class, plus a macOS menu-bar app, in-app updates, and DMG / Intel distribution. macOS only for now — Linux and Windows are on the roadmap.

#### ✨ Highlights [#-highlights]

##### Wired & Bluetooth-direct devices are now first-class [#wired--bluetooth-direct-devices-are-now-first-class]

Every control path used to assume a mouse hanging off a **Logi Bolt receiver**. Devices connected by **USB cable** or **Bluetooth-direct** (no receiver) are now fully supported — DPI, SmartShift, and button / gesture capture all route to them over HID++. Bluetooth devices are no longer read-only, and wired mice with no battery (e.g. a corded **G502**) are no longer dropped. ([#5](https://github.com/AprilNEA/OpenLogi/pull/5))

##### macOS menu-bar app [#macos-menu-bar-app]

* OpenLogi now runs as a **menu-bar (tray) accessory** — click the 🖱️ icon for live device status and quick **Open** / **Quit**. ([#4](https://github.com/AprilNEA/OpenLogi/pull/4))
* **Dynamic activation policy**: opening a window brings back the Dock icon and app menu; closing the last window drops back to tray-only. ([#7](https://github.com/AprilNEA/OpenLogi/pull/7))
* **Start-minimized autostart** — a login-launched instance comes up in the tray with no window. ([#7](https://github.com/AprilNEA/OpenLogi/pull/7))
* A &#x2A;*"Show in menu bar"*&#x2A; setting, and **⌘W** now closes the focused window (Settings / About too, not just the main one).

##### Gesture button [#gesture-button]

* The gesture button is now a **mappable hotspot** in the mouse diagram, with a hold-time gate so a quick tap registers as a click and a deliberate hold as a swipe. ([#4](https://github.com/AprilNEA/OpenLogi/pull/4))

##### In-app updates (opt-in, off by default) [#in-app-updates-opt-in-off-by-default]

* **Check for Updates** with a first-run prompt, an on-launch check, a clickable version, and live **download progress** — powered by `gpui-updater`.

##### Packaging & distribution [#packaging--distribution]

* Ships as a **DMG installer** with a hosted background image.
* CI now also builds **Intel (x86\_64) macOS** binaries alongside Apple Silicon.

##### UI polish [#ui-polish]

* Demo device cards replaced with a proper **empty state**, the app logo embedded as a runtime asset, and Settings language-picker layout fixes.

#### 🔧 Under the hood [#-under-the-hood]

* HID++ device addressing unified behind a single `DeviceRoute` — Bolt `\{receiver, slot\}` vs. `Direct \{vid, pid\}` resolved in one place; `DpiTarget` / `GestureTarget` collapse into it, and the CLI `diag` commands work on direct devices too. ([#5](https://github.com/AprilNEA/OpenLogi/pull/5))
* macOS status-item wrapper extracted and the menu bar gated to macOS. ([#6](https://github.com/AprilNEA/OpenLogi/pull/6))
* Release / CI hardening: a single root changelog, 1Password-sourced tokens, DMG build inside the devenv, and a real-Xcode Metal env for GUI builds.

**Full changelog**: [v0.1.0...v0.2.0](https://github.com/AprilNEA/OpenLogi/compare/v0.1.0...v0.2.0)

## v0.1.4 [#v014]

*Released May 31, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.1.4)

##### Other [#other]

* update workflow actions for Node 24
* *(release-plz)* fail loudly when a release silently stalls

## v0.1.3 [#v013]

*Released May 31, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.1.3)

*No release notes.*

## v0.1.2 [#v012]

*Released May 31, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.1.2)

##### Added [#added-2]

* Check for Updates in the About window, backed by the gpui-updater crate
* One opt-in update check on launch, with a first-run prompt to enable it
* Live download progress, and a clickable version that links to its GitHub release

## v0.1.1 [#v011]

*Released May 30, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.1.1)

##### Other [#other-1]

* *(release-plz)* write a single root changelog, not one per crate
* *(release-plz)* load CARGO\_REGISTRY\_TOKEN from 1Password

## v0.1.0 [#v010]

*Released May 30, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.1.0)

> ⚠️ &#x2A;*Only the MX Master 4 has been tested on real hardware so far.** Other Logitech devices expose different HID++ controls and are unverified — please report what works and what doesn't.

**OpenLogi 0.1.0** — A native, local-first alternative to Logitech Options+, written in Rust.

##### Remapping [#remapping]

* Remap the **Back / Forward / Middle** buttons (via a CGEventTap hook) to any action.
* Capture the MX **gesture button** over HID++ (`0x1b04` raw‑XY): directional swipes (up / down / left / right) fire **mid‑swipe**, plus a no‑swipe click — bound independently per direction.
* Capture the **DPI / ModeShift button** and the **thumb wheel** (`0x2150`) over HID++.

##### Actions [#actions]

* Keyboard shortcuts, mouse clicks, scrolling, media keys, tab navigation.
* **DPI** cycle / presets and **SmartShift** toggle, written to the device over HID++ (reusing the open capture channel for low latency).
* Window & space actions — **Mission Control, App Exposé, Show Desktop, Launchpad** — triggered directly through the Dock.

##### Devices (HID++ over a Bolt receiver) [#devices-hid-over-a-bolt-receiver]

* Receiver + paired‑device enumeration, live inventory, and on‑demand device renders.
* DPI (`0x2201`) and SmartShift (`0x2111`) read / write.
* Device pairing (add / remove) — **work in progress**.

##### Interface [#interface]

* Interactive mouse model with clickable hotspots and leader‑line labels.
* Flat‑menu binding picker with a cascading gesture submenu.
* Localized: English · 简体中文 · 繁體中文 · 日本語.
* Settings and About windows; follows the OS light / dark appearance.
* Guided macOS Accessibility‑permission onboarding.

##### Packaging [#packaging]

* Signed + notarized `.app` / DMG, a Homebrew tap, and an app icon.

##### Known limitations [#known-limitations]

* Verified only on the **MX Master 4**.
* **Launchpad** was removed in macOS 26, so that action is a no‑op there.
* Thumb‑wheel scroll re‑synthesis may need per‑device tuning.
* Device pairing is still being built out.

## v0.0.1 [#v001]

*Released May 30, 2026* · [View on GitHub ↗](https://github.com/AprilNEA/OpenLogi/releases/tag/v0.0.1)

*No release notes.*


# Command line (/docs/cli)



OpenLogi v0.8.15 provides human-readable hardware commands and a versioned JSON API. Hardware commands can access devices directly. The `openlogi api` commands use only a running OpenLogi agent.

## Run the command [#run-the-command]

Run `openlogi --help` to inspect the installed command tree. See [Installation](/docs/installation) for package and source-build instructions.

`openlogi` without a subcommand runs `openlogi list`. The command lists connected receivers, paired HID++ devices, and Logitech webcams. It uses the running agent's inventory when possible, then falls back to direct hardware enumeration. A completed scan with no devices or webcams exits with status `2`.

| Command                       | Result                                                      |
| ----------------------------- | ----------------------------------------------------------- |
| `openlogi` or `openlogi list` | List receivers, paired HID++ devices, and Logitech webcams. |
| `openlogi assets sync`        | Download assets for the local bundle.                       |
| `openlogi --help`             | Print command and option help.                              |
| `openlogi --version`          | Print the CLI version.                                      |

### Synchronize assets [#synchronize-assets]

`openlogi assets sync` races the automatic asset mirrors. The first mirror with a valid catalog supplies every file for that synchronization run. The command skips unchanged files by SHA-256 and removes asset depots absent from the catalog.

Use `--base URL` or `OPENLOGI_ASSETS` to use one asset origin instead. `--out DIR` changes the destination directory.

```sh
openlogi assets sync
openlogi assets sync --base https://assets.example.invalid
OPENLOGI_ASSETS=https://assets.example.invalid openlogi assets sync
```

### Set the log filter [#set-the-log-filter]

`OPENLOGI_LOG` sets the tracing filter for the CLI, desktop app, and agent. It accepts `EnvFilter` directives and defaults to `info` when unset. Logs go to stderr, so JSON API output stays on stdout.

```sh
OPENLOGI_LOG=debug openlogi list
OPENLOGI_LOG=hidpp=trace,openlogi_agent=debug openlogi api status
```

## Hardware commands [#hardware-commands]

The following commands access hardware outside the JSON API. A command selects the first compatible device when it can. Use its `--device` or `--camera` selector when more than one device matches.

| Command                                                                                                                   | Purpose                                                                         |
| ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `openlogi backlight [--device NAME]`                                                                                      | Read a keyboard's HID++ `0x1982` backlight state.                               |
| `openlogi backlight [--device NAME] [status\|off\|on\|auto on\|auto off\|level N\|battery-saving on\|battery-saving off]` | Persistently change a supported keyboard backlight control.                     |
| `openlogi snapshot [PATH] [--camera ID]`                                                                                  | Capture one Logitech webcam frame to a PNG. The default path is `snapshot.png`. |
| `openlogi camera [--camera ID] [get]`                                                                                     | Show UVC control ranges and current values for a Logitech webcam.               |
| `openlogi camera [--camera ID] set CONTROL VALUE`                                                                         | Write a listed UVC image control or an automatic-control toggle.                |
| `openlogi light list`                                                                                                     | List supported standalone lights and their controls.                            |
| `openlogi light status\|on\|off [--device NAME]`                                                                          | Read or change one standalone light.                                            |
| `openlogi light brightness --percent 0..100\|--lumens N [--device NAME]`                                                  | Set normalized brightness or native lumens.                                     |
| `openlogi light temperature --kelvin N [--device NAME]`                                                                   | Set colour temperature.                                                         |
| `openlogi fixture contribute`                                                                                             | Build a privacy-safe fixture through a resumable wizard.                        |
| `openlogi fixture record case\|profile`                                                                                   | Capture a named read or agent semantic state for a fixture.                     |
| `openlogi fixture verify`                                                                                                 | Strictly verify a complete fixture directory.                                   |

`camera set` accepts these control names: `zoom`, `pan`, `tilt`, `focus`, `exposure`, `power_line_frequency`, `low_light_compensation`, `brightness`, `contrast`, `saturation`, `sharpness`, `gain`, `backlight_compensation`, `white_balance`, `tint`, `focus_auto`, `exposure_auto`, and `white_balance_auto`.

<Callout type="warning">
  Hardware commands can change device state. The fixture commands are maintainer tooling. Direct fixture discovery can enable receiver notifications, and it does not restore those flags.
</Callout>

## Hardware diagnostics [#hardware-diagnostics]

`openlogi diag` runs direct HID++ diagnostics. It does not update `config.toml` or use the desktop app. On macOS, a diagnostic command requests Input Monitoring for the CLI binary before it opens a device.

| Command                                                                                | Behavior                                                                    |
| -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `diag features`                                                                        | Dump each online HID++ device's feature table and firmware entities.        |
| `diag controls [--device NAME]`                                                        | Dump HID++ `0x1b04` reprogrammable controls and capability flags.           |
| `diag battery [--device NAME]`                                                         | Print the raw battery report from `0x1004`, `0x1000`, or `0x1F20`.          |
| `diag dpi [--target DPI] [--device NAME]`                                              | Read DPI, write a test value, read it back, and restore the original value. |
| `diag pointer-speed [--target RAW] [--device NAME]`                                    | Test HID++ `0x2205` pointer motion scaling, then attempt restoration.       |
| `diag smartshift [--leave-flipped\|--sensitivity N\|--torque PERCENT] [--device NAME]` | Toggle SmartShift, set its release threshold, or set wheel torque.          |
| `diag lighting RRGGBB [--device NAME] [--method auto\|effects\|perkey\|perkeyv2]`      | Set an online RGB device to one solid colour.                               |
| `diag wheel [--resolution low\|high] [--device NAME]`                                  | Read or set HID++ `0x2121` wheel reporting resolution.                      |

The diagnostic commands are for verified write paths, not routine preference changes. `diag dpi` restores after a successful test. Plain `diag smartshift` restores its original mode unless `--leave-flipped` is present. Other diagnostic writes can remain on the device.

### Test pointer motion scaling [#test-pointer-motion-scaling]

`diag pointer-speed` tests [HID++ `0x2205` pointer motion scaling](/hidpp/features/x2205-pointer-motion-scaling). `--device NAME` selects a case-insensitive name substring. `--target RAW` accepts a non-zero raw 8.8 fixed-point value, where `256` is `1×`.

Without `--target`, the command halves the current raw value. If the current value is `1`, it doubles that value instead. Firmware can accept extreme values without clipping.

The diagnostic writes the test value, then attempts to restore and verify the original value before it judges the test write. It also attempts restoration if the test write fails. A restoration error means OpenLogi could not confirm the original scaling.

### Test SmartShift torque [#test-smartshift-torque]

`diag smartshift --torque PERCENT` writes a wheel-feedback intensity from `1` through `100`. It is available only when the device advertises HID++ `0x2111` `TUNABLE_TORQUE`. The command preserves the current SmartShift mode and automatic-release threshold.

`--sensitivity N` accepts `1` through `255`. Values `1` through `254` set the automatic-release threshold. Value `255` selects permanent ratchet mode when the wheel is already in ratchet mode. `--leave-flipped`, `--sensitivity`, and `--torque` are mutually exclusive where indicated by the command help.

## JSON automation API [#json-automation-api]

`openlogi api` is a local JSON interface for scripts and launchers. It requires a running, protocol-compatible OpenLogi agent. It never starts the agent, opens hardware directly, or falls back to diagnostic commands.

### Prerequisites [#prerequisites]

Start OpenLogi normally before calling the API. Use matching CLI and agent builds when the API returns `version_mismatch`.

Run `openlogi api devices` first. Copy one exact, non-null `id` and pass it as one quoted `--device` value. Device operations require `inventory: "ready"`; only a ready, empty `devices` array means no peripherals were found.

```sh
openlogi api status
openlogi api devices
openlogi api dpi --device "$DEVICE_ID"
```

### Commands and response fields [#commands-and-response-fields]

Every successful command puts the fields below in `data`. A null measured value means that the agent has no measured value. The API does not include cameras.

| Command                                                              | `data` fields                                                                                                                               |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `status`                                                             | `agent_version`, `inventory`, `accessibility_granted`, `input_monitoring_granted`, `hook_installed`, `hid_open_failures`, `launch_at_login` |
| `devices`                                                            | `inventory`; `devices`, an array of `id`, `name`, `kind`, `online`, `battery`, `capabilities`, and `light_capabilities`                     |
| `dpi --device ID [--set DPI]`                                        | `device`, `persistence`, `current`, `supported`                                                                                             |
| `smartshift --device ID [--mode free\|ratchet] [--auto-disengage N]` | `device`, `persistence`, `mode`, `auto_disengage`, `tunable_torque`                                                                         |
| `fn-lock --device ID [--set on\|off]`                                | `device`, `persistence`, `fn_lock`, `default_fn_lock`                                                                                       |
| `os-layout --device ID --set on\|off --save`                         | `device`, `persistence`, `keep_os_layout`                                                                                                   |
| `report-rate --device ID [--set HZ]`                                 | `device`, `persistence`, `current`, `supported`                                                                                             |
| `easy-switch --device ID [--channel N --name TEXT]`                  | `device`, `persistence`, `current_channel`, `can_rename`, `channels`                                                                        |

`smartshift.tunable_torque` is a `1`–`100` percentage of the wheel's maximum force, or `null`. `fn-lock.default_fn_lock` is a boolean or `null`. Report-rate values are hertz and apply to the current connection. See [Report rate](/docs/features/mouse/report-rate).

Each `easy-switch.channels` entry has `channel`, `paired`, `connection`, `name`, `name_max_len`, and `os`. Channels start at `1`. The connection value is `unknown`, `unifying`, `usb`, `bluetooth`, or `bolt`. See [Easy-Switch](/docs/features/easy-switch).

### Change settings [#change-settings]

Read commands do not write hardware. Add the setting flag to make an immediate change.

```sh
openlogi api dpi --device "$DEVICE_ID" --set 1200
openlogi api smartshift --device "$DEVICE_ID" --mode free
openlogi api smartshift --device "$DEVICE_ID" --mode ratchet --auto-disengage 255
openlogi api fn-lock --device "$DEVICE_ID" --set on
openlogi api os-layout --device "$DEVICE_ID" --set on --save
openlogi api report-rate --device "$DEVICE_ID" --set 1000 --save
openlogi api easy-switch --device "$DEVICE_ID" --channel 2 --name "Work laptop"
```

A DPI value must occur in `supported`. OpenLogi rejects other values without rounding. A report rate must occur in the rate list for the current connection.

SmartShift preserves fields that the command omits. `auto_disengage` accepts `1` through `254`, or `255` for permanent ratchet. Use `--mode ratchet --auto-disengage 255` to disable automatic release. With `--save`, the SmartShift threshold must be `8` through `255`.

`fn-lock --set on` makes bare function keys send F1 through F12. `off` uses their printed media functions. `os-layout` is an agent setting, so it requires both `--set` and `--save`. With `on`, the agent keeps a compatible keyboard on the host OS layout. With `off`, OpenLogi leaves the keyboard layout unchanged.

An Easy-Switch rename requires both `--channel` and `--name`. The name is trimmed before it is written. The device stores the name, so Easy-Switch rejects `--save` and always returns `persistence: "not_saved"`.

### Save preferences [#save-preferences]

Add `--save` to an explicit DPI, SmartShift, Fn-lock, or report-rate change to save the verified setting in `config.toml`. Saving requires a probed physical device identity. OpenLogi never uses the API route ID as a configuration key.

A save edits only the selected setting and preserves unrelated preferences and comments. A per-link DPI or SmartShift override causes `link_override` instead of saving a device default that the override would supersede. See [Configuration](/docs/configurations) for the complete file reference.

Hardware writes, configuration saves, and agent reloads are separate steps. A later failure does not undo a hardware write. On a save error, `error.persistence: "not_saved"` means that OpenLogi did not save the file. `error.persistence: "saved"` means that it saved the file, but reload failed or its result is unknown.

### JSON envelope and exit codes [#json-envelope-and-exit-codes]

After argument parsing, each API invocation writes one JSON object and one newline to stdout. Success and runtime errors use the same envelope.

```json
{"schema_version":1,"ok":true,"data":{"inventory":"ready","devices":[]}}
```

```json
{"schema_version":1,"ok":false,"error":{"code":"inventory_not_ready","message":"agent inventory is not ready; no device operation was attempted"}}
```

| Exit code | Meaning                                               |
| --------- | ----------------------------------------------------- |
| `0`       | Success. `ok` is `true`.                              |
| `1`       | Runtime failure. Inspect `error.code`.                |
| `2`       | Invalid arguments. Clap writes diagnostics to stderr. |

Scripts must check both `schema_version` and `ok`. Scripts must ignore unknown object fields and treat unknown error codes as failures. Parse `error.code`, not the explanatory `error.message`.

The stable error codes are `agent_unavailable`, `handshake_failed`, `version_mismatch`, `timeout`, `disconnected`, `inventory_not_ready`, `device_not_found`, `device_offline`, `ambiguous_device`, `unsupported_feature`, `invalid_value`, `readback_mismatch`, `device_error`, `identity_unavailable`, `link_override`, `config_error`, `config_conflict`, `config_busy`, `config_save_failed`, `config_reload_failed`, and `config_reload_unknown`.

`--help` and `--version` produce text rather than JSON. A stdout write failure cannot produce a JSON envelope.

### Device IDs and privacy [#device-ids-and-privacy]

API device IDs are opaque identifiers for the current connection route. Refresh an ID after reconnecting a device or updating OpenLogi. Do not construct or parse IDs, and do not use array positions or device names as addresses.

A missing route has a null ID. Duplicate IDs remain in `devices`, and an operation using one returns `ambiguous_device`. IDs can contain hardware identity information, so keep them local and redact them before sharing output.

The device response excludes pairing passkeys, application history, and model serials. A null battery or capability is not a zero charge or an unsupported feature.


# Connect a device (/docs/connect-device)





OpenLogi shows supported Logitech devices on the Home screen when it detects them.
This guide covers receiver pairing, direct Bluetooth and USB connections, and device records.

## Home screen [#home-screen]

Open the Home screen to view device cards.
The default layout is **Grid**.
Select **List** or **Carousel** to change the layout. OpenLogi saves this choice.

Each online card shows a connection icon, connection status, and battery information when the device reports it.
Receiver-paired cards also show their **Slot** number.
Offline cards retain their last known connection icon when OpenLogi recorded one.

<Figure caption="Device cards in the Carousel layout.">
    <img alt="OpenLogi device carousel listing two paired Logitech mice with battery and connection status" src="__img0" />
</Figure>

## Quit Logi Options+ first [#quit-logi-options-first]

<Callout type="warning" title="Quit Logi Options+">
  Quit &#x2A;*Logi Options+**, including its background service, before using OpenLogi. Each app needs exclusive HID++ access to a receiver.
</Callout>

## Pair through a receiver [#pair-through-a-receiver]

The GUI pairing workflow supports connected Logi Bolt and Unifying receivers.
Connect the receiver, then select &#x2A;*Add Device…** on the Home screen.

1. Put the device into pairing mode.
2. Select &#x2A;*Add Device…**.
3. Select **Search for devices**.

### Logi Bolt [#logi-bolt]

A Bolt receiver lists nearby unpaired devices.
Select a device to start its pairing flow.

For a keyboard, type the displayed passkey on the new keyboard and press Enter.
For a mouse, follow the shown left/right click sequence, then press both buttons together.

### Unifying [#unifying]

A Unifying receiver opens its pairing lock and waits for the next powered-on unpaired device in range.
This flow has no discovered-device list and no passkey.

After pairing, OpenLogi reports the receiver slot that it assigned.
For channel and host details, see [Easy-Switch](/docs/features/easy-switch).

## Respond to pairing errors [#respond-to-pairing-errors]

The pairing window reports the following messages.
Follow the action in the last column, then see [Troubleshooting](/docs/troubleshooting) if the failure continues.

| Message                                                                   | Meaning                                                      | What to do                                                                                            |
| ------------------------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| **No supported receiver capable of pairing was found.**                   | OpenLogi did not detect a pairing-capable receiver.          | Connect a Bolt or Unifying receiver, then open &#x2A;*Add Device…** again.                            |
| **Pairing timed out.**                                                    | The pairing session ended before a device paired.            | Put the device into pairing mode again, then retry.                                                   |
| **The receiver is busy. Try pairing again.**                              | The receiver refused the pairing attempt because it is busy. | Confirm that Logi Options+ is fully quit, then retry.                                                 |
| **Pairing is unavailable because the background service is not ready.**   | The background service is not ready to run pairing.          | Wait for OpenLogi to finish starting, then retry.                                                     |
| **Pairing is unavailable because receiver access could not be recorded.** | OpenLogi could not record access to the receiver.            | Reconnect the receiver and retry. See [Troubleshooting](/docs/troubleshooting) if it repeats.         |
| **That device is no longer available. Search again, then retry pairing.** | The selected Bolt device left the discovery results.         | Search again, select the device, then retry.                                                          |
| **HID transport error: *message***                                        | OpenLogi could not communicate with the HID connection.      | Reconnect the device or receiver. Then retry, or see [Troubleshooting](/docs/troubleshooting).        |
| **Receiver register access failed: *message***                            | OpenLogi could not access the receiver register.             | Retry after Logi Options+ has fully quit. See [Troubleshooting](/docs/troubleshooting) if it repeats. |
| **The receiver reported pairing error *code*.**                           | The receiver rejected the pairing flow.                      | Put the device into pairing mode again, then start a new pairing attempt.                             |
| **A pairing session is already active.**                                  | Another pairing session is still running.                    | Let the current session finish or cancel it, then retry.                                              |

## Connect directly with Bluetooth or USB [#connect-directly-with-bluetooth-or-usb]

For a direct Bluetooth connection, pair the device in your computer's Bluetooth settings.
OpenLogi discovers the device after the operating system exposes it.

For USB, connect the device with its cable.
OpenLogi probes direct Bluetooth and wired devices on their own HID++ channel.

Direct connections do not use a receiver pairing slot.
Use a receiver only when you need its wireless pairing flow.

## Use one device on more than one route [#use-one-device-on-more-than-one-route]

The same physical device can be reachable through a receiver and a cable.
It can also be observed through two receiver routes.

OpenLogi prefers a hardware `serial:` identity, then a HID++ `unit:` identity.
It uses that identity instead of a model ID for the device's saved settings.

When OpenLogi observes the same identity on multiple routes, it folds those routes into one device entry.
The saved settings then follow the device when you move between its receiver and cable. See [Configuration](/docs/configurations) for the complete key reference.

## Manage offline devices and old pairings [#manage-offline-devices-and-old-pairings]

You can open a saved offline device and edit its settings.
The detail screen shows &#x2A;*Device is offline. Changes will apply when it reconnects.**

In the **Device** tab, the **Configuration** card has the **Manage this device** switch.
When you turn it off, controls use their default behavior and OpenLogi stops applying settings.

For a saved offline device, open its card menu and select &#x2A;*Delete device…**.
This deletes the saved device record, custom name, and per-device settings. It does not unpair the hardware from its receiver.

A Bolt receiver can report duplicate pairing slots for the same physical unit.
OpenLogi suppresses duplicate slots and keeps the online slot when one exists.

A receiver can retain obsolete pairings for a device in slots that it no longer uses.
OpenLogi folds these old pairings into one device card. Select &#x2A;*Remove old pairings…** to free those slots without deleting the device or its settings.

If you switch the device to a channel that used a removed pairing, pair it again.
For connection and pairing failures, see [Troubleshooting](/docs/troubleshooting).


# Introduction (/docs)





**A local-first Logitech Options+ alternative written in Rust.**

Remap buttons, adjust DPI, report rate, and SmartShift, and switch profiles per
app without a Logitech account, telemetry, or the official Options+ install.

<Callout type="warning">
  OpenLogi is under active development and not yet stable. Features and
  configuration may still change.
</Callout>

## Introduction [#introduction]

OpenLogi talks to Logitech HID++ peripherals over Logi Bolt, Unifying, and
Lightspeed receivers, Bluetooth-direct connections, or USB cables, without
running Logi Options+. Everything stays on your machine: bindings live in a plain
TOML file, button presses are remapped through the OS input hook, and DPI,
SmartShift, scrolling, and lighting changes are written straight to the device
over HID++.

## What it controls [#what-it-controls]

* **Mice:** per-app button bindings, gestures, short and long presses, held
  shortcuts, DPI presets, report rate, SmartShift and scrolling force, scroll
  inversion, wheel resolution, **Smooth wheel**, hold to scroll sideways, the
  thumb wheel, and the MX Master 4 Haptic Sense Panel.
* **Keyboards:** HID++ key bindings, the function-key remapper, Fn Lock,
  **Keep OS layout**, the backlight, and static RGB color.
* **Easy-Switch:** the computer paired on each channel, renaming a channel's
  computer, and host switching that moves linked devices with a keyboard.
* **Litra lights:** power, brightness, color temperature, and auto-on with the
  camera on macOS.
* **Webcams:** UVC image controls, camera profiles, and a live preview.
* **Headsets:** G733 battery and link state.
* **On-screen notices:** low battery, lock-key changes, and the keyboard
  backlight level.

## Beyond Options+ [#beyond-options]

Things OpenLogi does that Options+ won't:

* **Run on Linux.** Options+ ships for macOS and Windows only. OpenLogi treats
  Linux as a first-class platform: an evdev/uinput hook, udev rules, a systemd
  user unit, `.deb` / `.rpm` / `.pkg.tar.zst` packages, a Flatpak bundle, and
  native desktop actions on GNOME, KDE, and Hyprland.
* **Choose gesture buttons.** Assign directional gestures to Middle Click, Back,
  Forward, the dedicated gesture button, the haptic panel, or a DPI/ModeShift
  button that reports diversion and raw-XY support. Several can be enabled at
  once. See [Remap buttons](/docs/features/mouse/remap-buttons).
* **Keep config in plain text.** Everything is one TOML file you can read,
  diff, version-control, and copy between machines. `[apps]` aliases let one
  app profile cover macOS, Windows, and Linux.
* **Script it.** Use the [CLI](/docs/cli) for device inventory, a versioned JSON
  API, DPI, report rate, SmartShift, Fn Lock, Easy-Switch, asset prefetch, light
  and camera control, and HID++ diagnostics.
* **Stay light.** Native Rust binaries: no Electron suite, no account, no
  telemetry. Update checks stay off until you turn them on; see
  [Settings and updates](/docs/settings#network-access).

## Documentation version [#documentation-version]

These guides describe the published **v0.8.15** release (October 20, 2026).
The latest documentation update is **October 21, 2026**. See the
[changelog](/docs/changelog) for individual releases,
[Configuration](/docs/configurations) for schema 9 and migration from older
files, and [Contributing](/docs/project/contributing) to record a privacy-safe
device fixture.

## Quick Start [#quick-start]

<Steps>
  <Step>
    ### Install [#install]

    [Install OpenLogi](/docs/installation) on macOS, Linux, or Windows.
  </Step>

  <Step>
    ### Connect [#connect]

    <OptionsPlusNotice />

    [Connect a device](/docs/connect-device) over a Bolt, Unifying, or Lightspeed receiver, Bluetooth, or USB.
  </Step>

  <Step>
    ### Configure [#configure]

    Open the device and work through its tabs. The [Features](/docs/features) guide
    covers each tab, and [Settings and updates](/docs/settings) covers the app-wide
    options.
  </Step>
</Steps>

## Next steps [#next-steps]

<Cards>
  <Card icon="<BadgeCheckIcon />" href="/docs/supported-devices" title="Supported devices">
    See what OpenLogi controls and how to check your own device.
  </Card>

  <Card icon="<BadgeCheckIcon />" href="/docs/features" title="Features">
    Configure buttons, pointers, keyboards, lights, and webcams.
  </Card>

  <Card icon="<BadgeCheckIcon />" href="/docs/troubleshooting" title="Troubleshooting">
    Fix permission, device access, and configuration problems.
  </Card>

  <Card icon="<BadgeCheckIcon />" href="/hidpp" title="HID++ reference">
    Explore the protocol OpenLogi speaks.
  </Card>
</Cards>

***

*Not affiliated with Logitech. "Logitech", "MX Master", and "Options+" are
trademarks of Logitech International S.A.*


# Installation (/docs/installation)





OpenLogi supports macOS 13 or later, Linux, and Windows. Choose one installation method for your platform, then complete the platform-specific access steps.

<OptionsPlusNotice />

## macOS [#macos]

macOS requires version 13 or later. The release DMG is signed and notarized.

<Tabs items="['Homebrew', 'DMG installer']">
  <Tab value="Homebrew">
    Install the official Homebrew cask:

    ```sh
    brew install --cask openlogi
    ```

    To follow the latest GitHub release from the OpenLogi tap instead, install:

    ```sh
    brew tap aprilnea/tap
    brew install --cask aprilnea/tap/openlogi@latest
    ```

    <Callout type="info">
      Install either `openlogi` or `openlogi@latest`, not both.
    </Callout>
  </Tab>

  <Tab value="DMG installer">
    Download the signed, notarized DMG from the [latest release](https://github.com/AprilNEA/OpenLogi/releases/latest). Open it and drag **OpenLogi.app** to `/Applications`.
  </Tab>
</Tabs>

Launch OpenLogi after installation and follow its permission prompts. Give **OpenLogi Agent** **Accessibility** and **Input Monitoring**; Accessibility enables gesture and button remapping, while Input Monitoring reads HID++ data, including Bluetooth-direct mice.

Grant **Camera** only if you want to preview a Logitech webcam. That permission belongs to the OpenLogi app, not the agent, and is separate from HID and input access.

## Linux [#linux]

Release packages are available as `.deb`, `.rpm`, and `.pkg.tar.zst` for `x86_64`/`amd64` and `arm64`/`aarch64`. Pre-built packages require GLIBC 2.35 or later, with Ubuntu 22.04 as the baseline.

### Verified release installer [#verified-release-installer]

Install `minisign` with your distribution package manager. Download the installer over HTTPS and inspect it. Run it as a normal user. Do not pipe it into a shell or run the whole script with `sudo`.

```sh
curl --proto '=https' --proto-redir '=https' --tlsv1.2 \
  --fail --location --silent --show-error \
  --retry 3 --retry-connrefused \
  --output openlogi-install.sh \
  https://raw.githubusercontent.com/AprilNEA/OpenLogi/master/packaging/linux/install.sh
less openlogi-install.sh
sh openlogi-install.sh
rm openlogi-install.sh
```

The installer selects the latest release by default. It detects the architecture and apt, dnf, yum, zypper, rpm, or pacman. It verifies the detached minisign signature and exact `SHA256SUMS` entry before it invokes the package manager with `sudo`.

| Option                     | Effect                                                                    |
| -------------------------- | ------------------------------------------------------------------------- |
| `--version 0.8.15`         | Install a fixed release. The installer also accepts a leading `v`.        |
| `--package-manager zypper` | Override package-manager detection.                                       |
| `--no-start`               | Install the package without enabling or starting the user agent.          |
| `--dry-run`                | Download and verify the package without installing or starting the agent. |

When systemd is available, the installer enables and starts `openlogi-agent.service` for the current user. A failed user-service start does not roll back a successful package installation.

### Manual package installation [#manual-package-installation]

Download the matching package from the [latest release](https://github.com/AprilNEA/OpenLogi/releases/latest), then use the matching package manager:

```sh
# Debian / Ubuntu
sudo dpkg -i openlogi-*.deb

# Fedora / RHEL
sudo rpm -i openlogi-*.rpm

# Arch Linux
sudo pacman -U openlogi-*.pkg.tar.zst
```

After a manual package installation, enable the agent for your user:

```sh
systemctl --user enable --now openlogi-agent.service
```

The release packages install udev rules. The rules grant write access to `/dev/uinput`, read/write access to `/dev/hidraw*`, and read access to the Logitech mouse `/dev/input/event*` node without `sudo`.

<Callout type="info">
  If access stays denied after installing rules, unplug and reconnect the receiver or mouse. The GUI's **Settings → Permissions** page shows the live access state.
</Callout>

### Flatpak bundle [#flatpak-bundle]

Each release includes a Flatpak bundle for `amd64` and `arm64`:

```sh
flatpak install --user ./openlogi-*.flatpak
```

A bundle has no remote, so `flatpak update` has nothing to check. Download the next bundle when a new version is released.

A Flatpak cannot write host udev rules. Install the bundled rule on the host before OpenLogi can detect devices:

```sh
flatpak run --command=cat org.openlogi.OpenLogi \
  /app/share/openlogi/udev/70-openlogi.rules | \
  sudo tee /etc/udev/rules.d/70-openlogi.rules >/dev/null

sudo udevadm control --reload-rules
sudo udevadm trigger
```

Restart the Flatpak after granting access:

```sh
flatpak kill org.openlogi.OpenLogi
```

The Flatpak launches its own agent, so it has no user service to enable. Its udev rule creates `/dev/uinput` at boot and loads the module when OpenLogi opens the node.

### NixOS [#nixos]

Use the repository NixOS module instead of only adding the package. The module installs OpenLogi and its udev rules, and starts the agent with the graphical session by default.

```nix
{
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  inputs.openlogi = {
    url = "github:AprilNEA/OpenLogi";
    inputs.nixpkgs.follows = "nixpkgs";
  };

  outputs = { nixpkgs, openlogi, ... }: {
    nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux"; # or aarch64-linux
      modules = [
        openlogi.nixosModules.default
        {
          programs.openlogi = {
            enable = true;
            launchAtLogin = true;
          };
        }
      ];
    };
  };
}
```

Set `programs.openlogi.launchAtLogin = false` to install the package and rules without starting the agent at login. You can then start it with `systemctl --user start openlogi-agent.service`.

### Other Linux systems [#other-linux-systems]

For a source build, use the stable Rust toolchain. Build the four production binaries. Then run the source installer from the repository checkout as a normal user:

```sh
cargo build --release \
  -p openlogi -p openlogi-desktop -p openlogi-overlay -p openlogi-agent
packaging/linux/install.sh --from-source
```

Use `--prefix=/usr` with `--from-source` only when you need a different binary prefix. On SysV init or OpenRC, replace `TAG+="uaccess"` in the bundled udev rule with `MODE="0660", GROUP="input"`, add the user to `input`, and sign in again:

```sh
sudo usermod -aG input "$USER"
```

On Wayland, automatic per-application profile switching requires XWayland. In Hyprland or Omarchy, native actions require `HYPRLAND_INSTANCE_SIGNATURE` and `XDG_RUNTIME_DIR` in the systemd user-manager environment.

## Windows [#windows]

Each release includes signed per-user `.msi` installers and portable `.zip` archives for `x86_64` and `arm64`. The MSI installs under the current user's local application folder and does not require elevation.

Both formats place `OpenLogi.exe` and `openlogi-agent.exe` together. Keep those files side by side when you use the portable ZIP, because the GUI connects to the background agent for device I/O.

The agent shows a notification-area icon after the main window closes. Hide it by turning off **Settings → General → Show in the notification area**, then restart the agent, or set `show_in_menu_bar = false` in `[app_settings]` and restart the agent.

## Updating [#updating]

**macOS.** Release app bundles use Sparkle for updates, a path added in v0.8.14. Use [**Settings**](/docs/settings) → **Updates** to check, download, and install; Homebrew installations update through `brew upgrade`.

**Linux.** Update a release package with the package manager that installed it. For Flatpak, download and install the next bundle because `flatpak update` has no remote to query.

**Windows.** The MSI supports in-app updates through [**Settings**](/docs/settings) → **Updates**. Choose **Download & Install**, then restart OpenLogi when the update is ready.

## Installation source [#installation-source]

**Settings → Updates → Installation source** identifies the running copy as a Homebrew cask, Linux package, Nix install, Windows MSI, portable ZIP, or macOS app bundle. It shows **Not identified** when OpenLogi has no ownership evidence for the running copy; the read-only result does not change update behavior.

## Uninstall [#uninstall]

For a Linux release package, remove OpenLogi through the same package manager that installed it. For a Linux source installation made with `--from-source`, run the removal script from the checkout; pass the same custom prefix when applicable:

```sh
packaging/linux/uninstall.sh
# Or, if the source installation used a custom prefix:
packaging/linux/uninstall.sh --prefix=/usr
```

## Next steps [#next-steps]

[Connect a device](/docs/connect-device) after OpenLogi starts. If installation or access does not work as expected, see [Troubleshooting](/docs/troubleshooting).


# Settings and updates (/docs/settings)



The **Settings** window holds application-wide preferences. It opens on **General**. Every stored preference in this page belongs to `[app_settings]` in `config.toml`; action buttons do not write a setting. See the [configuration reference](/docs/configurations) for the complete file.

## Open the window [#open-the-window]

Choose &#x2A;*Settings…** from the application menu. The macOS menu-bar agent also opens this window from &#x2A;*Settings…**. The Settings sidebar has **General**, **Updates**, **Permissions** where supported, **Appearance**, **Assets**, and **About**. macOS adds **Diagnostics**.

## General [#general]

**General** selects the application used for mouse profiles, scrolling behavior, startup, notification indicators, and the visible agent icon.

| Control                                    | What it does                                                                                                                        | Config key                    | Platforms                                                     |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ------------------------------------------------------------- |
| **Mouse button profiles**                  | Uses **Mouse pointer** or **Focused application** as the profile target. Keyboard profiles always follow focus.                     | `mouse_profile_target`        | macOS, Windows, Linux; unsupported pointer targets use focus. |
| **Smooth scrolling**                       | Replaces eligible physical mouse-wheel input with a finite smooth animation. Trackpad and other continuous pixel input stay native. | `smooth_scroll`               | macOS, Windows, Linux                                         |
| **Vertical scroll sensitivity**            | Sets traditional vertical-wheel distance from `1` to `100`. The default `14` is `1×`.                                               | `vertical_scroll_sensitivity` | macOS, Windows, Linux                                         |
| **Thumb Wheel sensitivity**                | Sets thumb-wheel scroll speed and the threshold for discrete thumb-wheel actions from `1` to `100`. The default `14` is `1×`.       | `thumbwheel_sensitivity`      | macOS, Windows, Linux                                         |
| **Launch at login**                        | Starts the background agent after login.                                                                                            | `launch_at_login`             | macOS, Windows, Linux                                         |
| **Login item disabled in System Settings** | Appears on macOS when System Settings has disabled the login item. **Open Login Items** opens the place where you re-enable it.     | No config key                 | macOS                                                         |
| **Show in menu bar**                       | Shows or hides the agent's menu-bar item after the agent restarts.                                                                  | `show_in_menu_bar`            | macOS                                                         |
| **Show in the notification area**          | Shows or hides the agent's notification-area icon after the agent restarts.                                                         | `show_in_menu_bar`            | Windows                                                       |
| **Low battery notifications**              | Shows a bottom-screen notice when a connected device has low battery.                                                               | `low_battery_notifications`   | macOS, Windows, Linux                                         |
| **Lock key notifications**                 | Shows an on-screen lock-key state. macOS shows Caps Lock only. Windows and Linux can also show Num Lock and Scroll Lock.            | `lock_key_notifications`      | macOS, Windows, Linux                                         |

`capture_mouse_events` exists in the configuration. The current **General** page has no control for it. When it is `false`, the agent does not install its OS mouse hook, so button remapping stops while HID++ features such as DPI and SmartShift remain available.

## Appearance [#appearance]

**Appearance** changes the interface language and presentation. Changes to mode, theme, radius, and scale apply in the running app.

| Control             | What it does                                                                                  | Config key                    | Platforms             |
| ------------------- | --------------------------------------------------------------------------------------------- | ----------------------------- | --------------------- |
| **Language**        | Chooses **Follow system** or a shipped interface locale.                                      | `language`                    | macOS, Windows, Linux |
| **Appearance mode** | Selects Light, Dark, or **Follow system**.                                                    | `appearance`                  | macOS, Windows, Linux |
| **Color theme**     | Selects a theme for its light or dark slot. A system-following mode remains system-following. | `theme_light` or `theme_dark` | macOS, Windows, Linux |
| **App icon**        | Chooses the OpenLogi or Prism icon for the Dock, Finder, and Launchpad.                       | `app_icon`                    | macOS                 |
| **Corner radius**   | Selects Sharp (`0`), Default (the theme value), or **Round** (`12`).                          | `ui_radius`                   | macOS, Windows, Linux |
| **Interface scale** | Scales text and rem-based interface spacing to `90%`, `100%`, `110%`, or `125%`.              | `ui_scale`                    | macOS, Windows, Linux |

The language picker ships these locale codes: `be`, `cs`, `da`, `de`, `en`, `es`, `fr`, `it`, `nl`, `nb`, `pl`, `pt-PT`, `pt-BR`, `fi`, `sv`, `tr`, `el`, `ru`, `uk`, `ja`, `zh-CN`, `zh-HK`, `zh-TW`, and `ko`.

## Updates [#updates]

**Updates** displays the running version, its update status, and the detected installation source. Update checks are off by default. The first-run &#x2A;*Check for updates?** dialog records either answer in `update_prompt_seen`. Choosing **Enable** also runs one check immediately.

| Control                                | What it does                                                                                                                                      | Config key             | Platforms             |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | --------------------- |
| **Check for updates**                  | Checks once per launch when enabled. It does not download an update by itself.                                                                    | `check_for_updates`    | macOS, Windows, Linux |
| **Automatically download and install** | After a check finds an update, downloads and stages it in the background. The update applies when OpenLogi next restarts.                         | `auto_install_updates` | macOS, Windows, Linux |
| **Check for Updates**                  | Runs a user-requested check, regardless of the automatic-check setting. The action can change to **Download & Install** or **Restart to Update**. | No config key          | macOS, Windows, Linux |
| **Installation source**                | Shows a detected macOS bundle, Windows MSI or portable ZIP, Homebrew cask, Linux package, Nix store, or an unrecognized source.                   | No config key          | macOS, Windows, Linux |
| **Update source**                      | Opens **GitHub Releases** through **View changelog**.                                                                                             | No config key          | macOS, Windows, Linux |

A macOS release bundle that embeds Sparkle uses Sparkle. Sparkle checks its appcast and installs on quit. This is the macOS update path added in v0.8.14. Windows uses the built-in updater with MSI release artifacts. The same built-in updater is also the fallback for Linux, unbundled builds, and macOS bundles without working Sparkle.

The updater detects Homebrew, DEB, RPM, Arch, Nix, and Windows installation sources for display. It does not select a package-manager-specific updater from that detection. For a Homebrew or Linux package installation, use the installation owner's update path. A release Flatpak is a one-time bundle with no remote, so `flatpak update` cannot find a newer release; install a newer `.flatpak` bundle instead.

## Assets [#assets]

**Assets** manages device-image mirrors and the per-user image cache. Automatic device-image download is enabled by default.

| Control                                  | What it does                                                                                                                                                                             | Config key             | Platforms             |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | --------------------- |
| **Asset source**                         | Selects &#x2A;*Automatic (recommended)**, OpenLogi, Cloudflare, or Fastly. Automatic races the built-in mirrors and uses the first healthy one. `OPENLOGI_ASSETS` overrides this choice. | `asset_source`         | macOS, Windows, Linux |
| **Automatically download device images** | Downloads images for connected devices from the selected source. Turning it back on refreshes immediately.                                                                               | `auto_download_assets` | macOS, Windows, Linux |
| **Refresh assets**                       | Re-downloads images for connected devices now.                                                                                                                                           | No config key          | macOS, Windows, Linux |
| **Clear cache**                          | Deletes the per-user cache, then re-fetches the current devices.                                                                                                                         | No config key          | macOS, Windows, Linux |
| **Cache location**                       | Opens the downloaded-images folder in the file manager.                                                                                                                                  | No config key          | macOS, Windows, Linux |

The cache root is OpenLogi's data directory plus `assets`. If no home directory resolves, it falls back to `./assets`.

## Permissions [#permissions]

**Permissions** appears on macOS and Linux. Windows does not show a Permissions page.

| Control                 | What it does                                                                                       | Config key    | Platforms |
| ----------------------- | -------------------------------------------------------------------------------------------------- | ------------- | --------- |
| **Accessibility**       | Grants the background agent access required for gesture and button remapping.                      | No config key | macOS     |
| **Input Monitoring**    | Allows HID++ data access, including Bluetooth-direct mice.                                         | No config key | macOS     |
| **Bluetooth**           | Allows CoreBluetooth use. HID access does not require this permission.                             | No config key | macOS     |
| **Camera**              | Allows a Logitech webcam preview. The page shows this row only while a Logitech webcam is present. | No config key | macOS     |
| **Input device access** | Reports access to input devices and describes the required udev rules when access is denied.       | No config key | Linux     |

## Diagnostics [#diagnostics]

**Diagnostics** appears on macOS. **Input interception** reports known applications that gate the mouse event stream. Such interception can cause pointer lag or duplicated button actions.

| Control                | What it does                                               | Config key    | Platforms |
| ---------------------- | ---------------------------------------------------------- | ------------- | --------- |
| **Input interception** | Checks current macOS event taps for known input conflicts. | No config key | macOS     |

## About [#about]

**About** identifies the running build and provides support destinations.

| Control                                                               | What it does                                                    | Config key    | Platforms             |
| --------------------------------------------------------------------- | --------------------------------------------------------------- | ------------- | --------------------- |
| **GitHub**, **Changelog**, **Documentation**, and **Report an issue** | Open the repository, releases, documentation, or issue tracker. | No config key | macOS, Windows, Linux |
| **Copy Diagnostics**                                                  | Copies a Markdown diagnostics report to the clipboard.          | No config key | macOS, Windows, Linux |
| **Show in file manager**                                              | Opens the directory that contains `config.toml`.                | No config key | macOS, Windows, Linux |

## Network access [#network-access]

OpenLogi has no continuous update poller. The update label states that update checks are the only optional outbound request, but the asset code also makes configurable asset requests. The following is the precise behavior of the current release.

| Network request                          | When OpenLogi makes it                                                                                                                                                                                                              | Default                      |
| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| Update manifest or Sparkle appcast check | Once per launch only when **Check for updates** is enabled, or when you choose **Check for Updates**.                                                                                                                               | Off                          |
| Update artifact download                 | After you choose **Download & Install**, or after an enabled automatic check finds an update and **Automatically download and install** is enabled.                                                                                 | Off                          |
| Asset index and device-image downloads   | For automatic sync only when automatic device images are enabled and the build permits automatic sync. Release builds with no populated app bundle and debug builds permit it. `OPENLOGI_SYNC` can force it on or off.              | `auto_download_assets` is on |
| Asset index and device-image downloads   | When you enable automatic device images, change the asset source while automatic images are enabled, choose **Refresh assets**, or choose **Clear cache**. Manual refresh and clear bypass the automatic-download and bundle gates. | User action                  |

The assets request fetches an asset index first. It then fetches only the files for connected device models that are absent or stale. Selecting an asset mirror pins that source, unless `OPENLOGI_ASSETS` overrides it.

## Menu bar and notification area [#menu-bar-and-notification-area]

The background agent owns these menus. The GUI can be closed while the agent continues to run.

| Host                      | Menu items                                                                                                   |
| ------------------------- | ------------------------------------------------------------------------------------------------------------ |
| macOS menu bar            | **Show Main Window**, &#x2A;*Settings…**, **About OpenLogi**, &#x2A;*Check for Updates…**, **Quit OpenLogi** |
| Windows notification area | **Show Main Window**, **Quit OpenLogi**                                                                      |

The Windows icon opens **Show Main Window** on left click. The current Windows menu has no &#x2A;*Settings…** or update item.


# Supported devices (/docs/supported-devices)





<DeviceCatalog />

OpenLogi probes the hardware feature table before it enables a control. A device model name identifies hardware, but it does not guarantee a feature.

## How OpenLogi decides support [#how-openlogi-decides-support]

After OpenLogi probes a device, it gates tabs and controls on the capabilities it measured. It does not select those panels from the marketing model or reported device kind.

A device that was offline at startup has no measured capability table. OpenLogi temporarily presumes capabilities from its device kind, so host-side bindings remain available.

Use these commands to inspect the active hardware:

```sh
openlogi list            # connected devices, receiver slots, and battery state
openlogi diag features   # HID++ feature IDs reported by the active device
openlogi diag controls   # reprogrammable controls and capability flags
```

## Capability map [#capability-map]

The table lists the feature gates OpenLogi uses. A row appears only when the connected device reports the required capability.

| Capability                                            | HID++ feature(s)                                                                               | GUI location                                                              | Documentation                                                                       |
| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| Button or key bindings and gestures                   | `0x1b00`–`0x1b04` ReprogControls                                                               | **Buttons** tab — selected-control editor; keyboards use the **Keys** tab | [Button remapping](/docs/features/mouse/remap-buttons)                              |
| DPI and report rate                                   | `0x2201` AdjustableDpi or `0x2202` ExtendedAdjustableDpi; `0x8060` or `0x8061` for report rate | **Pointer** tab — **DPI** card                                            | [DPI](/docs/features/mouse/dpi) and [Report rate](/docs/features/mouse/report-rate) |
| SmartShift                                            | `0x2111` SmartShiftWheelEnhanced, with `0x2110` SmartShiftWheel fallback                       | **Pointer** tab — **SmartShift** card                                     | [SmartShift](/docs/features/mouse/smartshift)                                       |
| Thumb wheel                                           | `0x2150` Thumbwheel, or gesture 46 from `0x6501` Gestures2                                     | **Pointer** tab — **SmartShift** card                                     | [Scrolling](/docs/features/mouse/scrolling)                                         |
| Main-wheel resolution, inversion, and smoothing       | `0x2121` HiResWheel; inversion also requires the device's `has_invert` flag                    | **Pointer** tab — **Scrolling** card                                      | [Scrolling](/docs/features/mouse/scrolling)                                         |
| Hold-to-scroll sideways                               | A divertible **Back** or **Forward** control with raw-wheel reporting in `0x1b04`              | **Buttons** tab — **Back** or **Forward** control editor                  | [Button remapping](/docs/features/mouse/remap-buttons)                              |
| Fn lock and host OS layout                            | `0x40a0`, `0x40a2`, or `0x40a3` FnInversion; `0x4531` MultiPlatform                            | **Device** tab — keyboard card with **Fn Lock** and **Keep OS layout**    | [Keyboard](/docs/features/keyboard)                                                 |
| Keyboard backlight                                    | `0x1982` Backlight2                                                                            | **Device** tab — **Backlight** card                                       | [Keyboard lighting](/docs/features/keyboard/lighting)                               |
| Easy-Switch host names and operating systems          | `0x1815` HostsInfo                                                                             | **Device** tab — **Easy-Switch** card                                     | [Easy-Switch](/docs/features/easy-switch)                                           |
| Static RGB keyboard lighting                          | `0x8070`, `0x8071`, `0x8081`, or `0x8080`                                                      | **Lighting** tab — **Lighting** card                                      | [Keyboard lighting](/docs/features/keyboard/lighting)                               |
| Litra light power, brightness, and colour temperature | `0x1990` Illumination when available; raw HID remains the fallback                             | **Lighting** tab for a light — **Lighting** card                          | [Litra lights](/docs/features/lights)                                               |
| Webcam preview and image controls                     | UVC; no HID++ feature gate                                                                     | **Camera** tab — preview and **Camera controls** cards                    | [Webcams](/docs/features/webcams)                                                   |
| Lock-key notices                                      | `0x4220` LockKeyState                                                                          | Settings window — **Lock key notifications**                              | [On-screen notices](/docs/features/notifications)                                   |

## Device coverage [#device-coverage]

### Receivers [#receivers]

OpenLogi recognises these exact Logitech USB vendor and product pairs. All IDs in this table use vendor ID `046d`.

| Receiver family | Product ID                                                 | Support                                                                                                                               |
| --------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| Logi Bolt       | `c548`                                                     | Discovery, enumeration, and GUI pairing                                                                                               |
| Unifying        | `c52b`, `c532`                                             | Enumeration and Unifying pairing flow                                                                                                 |
| Nano            | `c534`, `c537`                                             | Enumeration through the Unifying-compatible protocol; the GUI pairing flow lists them (not verified on Nano hardware)                 |
| Lightspeed      | `c539`, `c53a` (Powerplay), `c53f`, `c541`, `c547`, `c54d` | Enumeration and device control; the GUI pairing flow lists them and uses the Unifying procedure (not verified on Lightspeed hardware) |

Nano and Lightspeed receivers use the Unifying-compatible HID++ 1.0 protocol. Their product family remains distinct from Unifying in OpenLogi.

### Lights, cameras, and headsets [#lights-cameras-and-headsets]

OpenLogi recognises Litra **Glow** (`046d:c900`) and **Beam** (`046d:c901`) from their complete raw-HID identity. It first tries HID++ `0x1990` and reads the state back, then uses the proven raw-HID report path when that feature cannot be read.

Every Logitech UVC webcam is recognised by vendor ID. A registry supplies marketed names for cameras whose USB descriptor has no product string.

The G733 headset reports battery voltage, charge state, and link state through HID++ `0x1f20`. OpenLogi treats a switched-off G733 as unlinked and estimates battery percentage from the measured voltage.

The G735 dongle is recognised as a standalone device with a stable identity. It has no battery, mute, or sidetone control yet.

### Mouse and trackball controls [#mouse-and-trackball-controls]

MX Ergo and MX Ergo S precision-mode slots map to the **DPI Toggle** control. This lets the **Buttons** tab show and bind that control.

The M720 Triathlon uses a multiplatform gesture-button control, rather than the MX gesture-button control. OpenLogi recognises its raw-XY capability for bindings and gestures.

## Not supported yet [#not-supported-yet]

OpenLogi does not implement a headset control protocol for the G735. Audio controls, including mute and sidetone, remain unavailable.

## Check your device and report unknown hardware [#check-your-device-and-report-unknown-hardware]

The commands above show the receiver identity, feature table, and control flags that OpenLogi can read. If your Logitech device is missing or its feature table is incomplete, follow the [contribution guide](/docs/project/contributing) to report it.


# Troubleshooting (/docs/troubleshooting)



Use the symptom below to find the likely cause and the shortest verified fix. Collect diagnostics only after you have checked the relevant connection, permission, or configuration step.

## Device connection and service [#device-connection-and-service]

### A device does not appear [#a-device-does-not-appear]

**Cause:** Logi Options+ or another HID++ manager can hold exclusive access to a receiver or device.

**Fix:**

1. Quit &#x2A;*Logi Options+** first, including its background agent.
2. On Linux, also quit **Solaar** and other Logitech managers.
3. Open **Settings** > **Permissions** and check the device-access status.
4. If you installed udev rules while the device was connected, reconnect the receiver or power-cycle the wireless device.

### Can't reach the background service [#cant-reach-the-background-service]

**Cause:** The GUI cannot reach the agent that owns device I/O and input capture.

**Fix:**

1. Leave OpenLogi open while it retries the connection.

2. On Linux, check and start the user service:

   ```sh
   systemctl --user enable --now openlogi-agent.service
   ```

3. On macOS, check the Login Items fix below.

4. Reinstall OpenLogi if the problem continues.

### Device scanning is unavailable [#device-scanning-is-unavailable]

**Cause:** The background service could not scan for devices.

**Fix:**

1. Check the agent log in [Need logs](#need-logs).
2. Check the applicable macOS or Linux permission steps on this page.
3. Reconnect the receiver or device after you correct an access problem.

## macOS permissions [#macos-permissions]

### Buttons do not remap after granting Accessibility [#buttons-do-not-remap-after-granting-accessibility]

**Cause:** The agent needs **Accessibility** to capture OS-visible mouse buttons and run their bindings.

**Fix:**

1. In macOS System Settings, open the Accessibility list.
2. Enable **OpenLogi Agent**, not the OpenLogi GUI.
3. If the entry already exists, remove it and add **OpenLogi Agent** again.

### Input Monitoring is granted, but devices cannot open [#input-monitoring-is-granted-but-devices-cannot-open]

**Cause:** **Input Monitoring** is required to read HID++ data, including Bluetooth-direct mice.

**Fix:**

1. Quit Logi Options+ and other device managers.
2. Confirm that **OpenLogi Agent** has **Input Monitoring**.
3. Log out of macOS and log in again if access remains unavailable.

### Login item disabled in System Settings [#login-item-disabled-in-system-settings]

**Cause:** A disabled login item prevents the background agent from running.

**Fix:**

1. Open macOS System Settings.
2. Open Login Items.
3. Turn on the OpenLogi login item.

## Linux device access and Wayland [#linux-device-access-and-wayland]

### udev rules do not grant mouse-input access [#udev-rules-do-not-grant-mouse-input-access]

**Cause:** OpenLogi needs read/write access to `/dev/hidraw*`. It needs write access to `/dev/uinput` and read access to the mouse `/dev/input/event*` node. Rules from another manager often cover only the first two nodes.

**Fix:**

1. Install OpenLogi's supplied udev rules. See [Installation](/docs/installation#linux).

2. Reload the rules and trigger udev:

   ```sh
   sudo udevadm control --reload-rules
   sudo udevadm trigger
   ```

3. Unplug and reconnect the receiver or power-cycle the wireless device.

4. Check **Settings** > **Permissions** again.

### A Flatpak install shows no devices [#a-flatpak-install-shows-no-devices]

**Cause:** The Flatpak cannot install the required host udev rules itself.

**Fix:**

1. Install the bundled rules on the host:

   ```sh
   flatpak run --command=cat org.openlogi.OpenLogi \
     /app/share/openlogi/udev/70-openlogi.rules |
     sudo tee /etc/udev/rules.d/70-openlogi.rules >/dev/null

   sudo udevadm control --reload-rules
   sudo udevadm trigger
   ```

2. Restart the Flatpak:

   ```sh
   flatpak kill org.openlogi.OpenLogi
   ```

3. Launch OpenLogi again.

### Mouse profiles follow the wrong app on Wayland [#mouse-profiles-follow-the-wrong-app-on-wayland]

**Cause:** Wayland does not support pointer-targeted mouse profiles. OpenLogi follows the focused application instead.

**Fix:** Focus the application whose mouse profile you want before you use the binding. This limitation does not affect keyboard profiles, which always follow focus.

## Changes and input capture [#changes-and-input-capture]

### A device is offline and changes are pending [#a-device-is-offline-and-changes-are-pending]

**Cause:** OpenLogi cannot apply a device setting while the device is offline.

**Fix:** Reconnect the device. OpenLogi applies the pending changes when it reconnects.

### Buttons are not captured [#buttons-are-not-captured]

**Cause:** The agent reads `capture_mouse_events` when it starts. Setting it to `false` disables the OS mouse hook. HID++ settings such as DPI and SmartShift continue to work.

**Fix:**

1. In `config.toml`, confirm this setting is enabled:

   ```toml
   [app_settings]
   capture_mouse_events = true
   ```

2. Restart the OpenLogi Agent.

3. On macOS, grant **Accessibility** to **OpenLogi Agent**.

4. On Linux, install the OpenLogi udev rules for the mouse event node.

### Another app intercepts mouse input [#another-app-intercepts-mouse-input]

**Cause:** Another macOS event tap can cause pointer lag or duplicate button actions.

**Fix:** In **Settings** > **Diagnostics** > **Input interception**, identify the listed app. Quit that app, then check the status again.

### A device appears twice, or a receiver remembers old pairings [#a-device-appears-twice-or-a-receiver-remembers-old-pairings]

**Cause:** A receiver can retain pairings in slots that the device no longer uses.

**Fix:** Open the current device card's menu and select &#x2A;*Remove old pairings…**. This frees unused receiver slots without removing the device or its settings. Pair the device again if you later switch it to a removed channel.

## Configuration file errors [#configuration-file-errors]

### OpenLogi opens a read-only error screen [#openlogi-opens-a-read-only-error-screen]

**Cause:** The TOML schema rejects unknown, obsolete, malformed, and out-of-range values instead of silently changing them.

**Fix:**

1. Open the configuration folder from the error screen.
2. Correct the reported value in `config.toml`.
3. Relaunch OpenLogi after you save the file.
4. If needed, review `config.toml.backup.1` through `config.toml.backup.5` beside the configuration file. Restore only a backup you have reviewed.

### The GUI refuses to save after an external edit [#the-gui-refuses-to-save-after-an-external-edit]

**Cause:** OpenLogi refuses its next save when an editor changes the file after the GUI loaded it. This prevents the GUI from overwriting the external revision.

**Fix:** Keep the external edit, then relaunch OpenLogi to load that revision. Do not retry the rejected GUI save.

## Update requires a relaunch [#update-requires-a-relaunch]

### OpenLogi was updated [#openlogi-was-updated]

**Cause:** The open window still runs the previous version after an update.

**Fix:** Select **Relaunch OpenLogi**. A relaunch loads the updated GUI.

## Logs and bug reports [#logs-and-bug-reports]

### Need logs [#need-logs]

Set `OPENLOGI_LOG=debug` before starting the affected CLI, GUI, or agent process to enable verbose tracing. CLI logs go to stderr.

The agent writes daily rotated `agent.<date>.log` files. It keeps at most seven files.

| Platform      | Agent-log directory                                            |
| ------------- | -------------------------------------------------------------- |
| macOS / Linux | `$XDG_STATE_HOME/openlogi` (default `~/.local/state/openlogi`) |
| Windows       | `%USERPROFILE%\.local\state\openlogi`                          |

Use these hardware diagnostics when they match the symptom:

```sh
openlogi diag --help
openlogi diag features
openlogi diag controls
```

`openlogi diag features` reports the HID++ features that the active device exposes. `openlogi diag controls` reports reprogrammable controls and capability flags.

### Report a bug [#report-a-bug]

**Fix:**

1. In **Settings**, open **About** and select **Copy Diagnostics**.
2. Run `openlogi diag features` for the affected device.
3. Redact device IDs and serials from copied diagnostics and command output.
4. File the report in the [OpenLogi issue tracker](https://github.com/AprilNEA/OpenLogi/issues).


# HID++ Reference (/hidpp)



HID++ is Logitech's vendor protocol for configuring mice, keyboards, lights, and
receivers. It runs over USB HID and Bluetooth HID. OpenLogi speaks it directly
through `openlogi-hidpp`, its hard fork of the
[`hidpp`](https://crates.io/crates/hidpp) Rust crate. The library name stays
`hidpp`, so code examples on these pages write `use hidpp::...`.

## Two generations [#two-generations]

* **HID++ 1.0** uses a fixed set of numbered **registers**. Unifying and Bolt
  receivers still use it for pairing and device enumeration.
* **HID++ 2.0** uses a discoverable set of **features**. A 16-bit ID names each
  feature, written `0xZZZZ`, for example `0x2201`. Modern devices use this
  generation for every setting.

## Message format [#message-format]

Every HID++ message is a short or a long HID report. OpenLogi uses only these
two report types for HID++.

| Report | Report ID | Length   | Parameter bytes |
| ------ | --------- | -------- | --------------- |
| Short  | `0x10`    | 7 bytes  | 3               |
| Long   | `0x11`    | 20 bytes | 16              |

The first four bytes have the same meaning in both reports:

| Byte | HID++ 2.0                                              | HID++ 1.0                                  |
| ---- | ------------------------------------------------------ | ------------------------------------------ |
| 0    | Report ID (`0x10` or `0x11`)                           | Report ID                                  |
| 1    | Device index                                           | Device index                               |
| 2    | Feature index                                          | Sub-ID (`0x80`–`0x83` for register access) |
| 3    | Function ID (high nibble) and software ID (low nibble) | Register address                           |
| 4…   | Parameters                                             | Register data                              |

The **device index** selects the target. `0xFF` addresses a receiver itself, or
the features of a directly connected device (Bluetooth or USB cable). A device
paired to a receiver is addressed by its slot number, starting at 1. See
[Receivers](/hidpp/receivers).

The **feature index** is a per-device runtime number, not the feature ID. A host
resolves it once through [`0x0000` root](/hidpp/features/x0000-root). The
**function ID** selects one function of that feature. The feature pages write it
as `fn 0`, `fn 1`, and so on.

### Software ID [#software-id]

The software ID lets a host match a reply to its request. Requests carry
`1`–`15`. A device sends `0` in its own notifications, so a report with software
ID `0` is an event, not a reply. OpenLogi assigns the ID per channel: a fixed ID,
a rotating ID, or an ID leased for the life of the channel. The lease keeps two
concurrent opens of the same HID node from sharing an ID.

A reply echoes bytes 1–3 of its request. OpenLogi matches a reply to the oldest
pending request with the same three bytes. It never has two requests with the
same three bytes in flight at once, because a wireless receiver can deliver
their replies out of order.

### Register access (HID++ 1.0) [#register-access-hid-10]

| Sub-ID | Request                             |
| ------ | ----------------------------------- |
| `0x80` | Set a short register (3 data bytes) |
| `0x81` | Get a short register                |
| `0x82` | Set a long register (16 data bytes) |
| `0x83` | Get a long register                 |
| `0x8F` | Error reply                         |

## Errors [#errors]

A HID++ 2.0 error reply has feature index `0xFF`. Byte 3 repeats the feature
index of the failed request, byte 4 repeats its function and software ID byte,
and byte 5 holds the error code. OpenLogi returns the code as
`Hidpp20Error::Feature(ErrorType)`. An unknown code becomes
`Hidpp20Error::UnsupportedResponse`.

| Code | `ErrorType`           | Meaning                          |
| ---- | --------------------- | -------------------------------- |
| `0`  | `NoError`             | No error                         |
| `1`  | `Unknown`             | Unknown error                    |
| `2`  | `InvalidArgument`     | Invalid argument                 |
| `3`  | `OutOfRange`          | Argument out of range            |
| `4`  | `HwError`             | Hardware error                   |
| `5`  | `LogitechInternal`    | Firmware-internal error          |
| `6`  | `InvalidFeatureIndex` | No feature at this index         |
| `7`  | `InvalidFunctionId`   | The feature has no such function |
| `8`  | `Busy`                | The device is busy               |
| `9`  | `Unsupported`         | The operation is not supported   |

A HID++ 1.0 error reply has sub-ID `0x8F`. Bytes 3 and 4 repeat the request's
sub-ID and register address, and byte 5 holds the error code. OpenLogi returns it
as `Hidpp10Error::RegisterAccess(ErrorType)`.

| Code   | `ErrorType` (1.0) | Code   | `ErrorType` (1.0)    |
| ------ | ----------------- | ------ | -------------------- |
| `0x00` | `Success`         | `0x07` | `Busy`               |
| `0x01` | `InvalidSubId`    | `0x08` | `UnknownDevice`      |
| `0x02` | `InvalidAddress`  | `0x09` | `ResourceError`      |
| `0x03` | `InvalidValue`    | `0x0A` | `RequestUnavailable` |
| `0x04` | `ConnectFail`     | `0x0B` | `InvalidParamValue`  |
| `0x05` | `TooManyDevices`  | `0x0C` | `WrongPinCode`       |
| `0x06` | `AlreadyExists`   |        |                      |

## Feature discovery [#feature-discovery]

A 2.0 device exposes its capabilities through two root features:

* **[`0x0000` root](/hidpp/features/x0000-root)** resolves a feature ID to its
  runtime feature index.
* **[`0x0001` featureSet](/hidpp/features/x0001-featureset)** enumerates every
  feature the device implements.

OpenLogi enumerates the feature set when it probes a device. It then queries
the features it supports: device information, battery, DPI, SmartShift, and
others.
The [feature index](/hidpp/features) lists every catalogued feature and its
status in OpenLogi.

## Identity [#identity]

Two things name a device on the wire. A receiver has a USB vendor and product
ID; see [Receivers](/hidpp/receivers). A device reports a model ID through
[`0x0003` deviceInformation](/hidpp/features/x0003-device-information). The
[model ID index](/hidpp/model-ids) lists every known model ID.

## Reading the feature pages [#reading-the-feature-pages]

Each implemented feature has one page with the same sections:

1. **Spec** line: the sources for the layout and the OpenLogi pages that use the
   feature.
2. **Function reference**: the methods, types, and events of the
   `openlogi-hidpp` wrapper at OpenLogi v0.8.15.
3. **Wire format**: the request and reply bytes of every function and event.
   Multi-byte fields name their byte order (`BE` or `LE`).
4. **How OpenLogi uses it**: the device-layer behavior, such as read-modify-write
   and read-back checks.
5. **Open questions**: points where the sources disagree or no hardware capture
   exists yet.

No public Logitech specification covers some features or fields. For those, the
pages use static analysis of Logitech's own software, done for interoperability.
A Notes cell then carries a source tag:

| Tag            | Source                                                                                                         |
| -------------- | -------------------------------------------------------------------------------------------------------------- |
| `Options+ 2.8` | Static analysis of Logi Options+ 2.8. It shows what that software sends and reads, not what the firmware does. |
| `G HUB`        | Static analysis of Logitech G HUB. Same limit as above.                                                        |
| `Solaar`       | The [Solaar](https://github.com/pwr-Solaar/Solaar) open-source implementation.                                 |

A fact without a tag comes from a Logitech HID++ document or from OpenLogi's
own tested code.

<Callout type="info" title="Attribution & copyright">
  This HID++ reference is an **original summary written for the OpenLogi
  project**. It describes Logitech's HID++ 1.0 and 2.0 protocol from publicly
  available documentation, the [`hidpp`](https://crates.io/crates/hidpp)
  implementation, Solaar, and static analysis of Logitech software for
  interoperability. It reproduces no Logitech document or code. “Logitech”,
  “Unifying”, “Logi Bolt”, “Options+”, and “G HUB” are trademarks of Logitech.
  OpenLogi is an independent project and is not affiliated with, authorized, or
  endorsed by Logitech. The material is provided “as is”, without warranty of
  any kind.
</Callout>


# Model IDs (/hidpp/model-ids)





A **Model ID** is a string Logitech's tools use to identify a product. OpenLogi
uses it to store per-device configuration and locate device renders and hotspot
data.

## Model ID structure [#model-id-structure]

Devices report two kinds of identity data through
[`0x0003 deviceInformation`](/hidpp/features/x0003-device-information):

* `model_id`: up to three PIDs, one for each transport enabled by the firmware.
  Unused entries are `0000`.
* `extended_model_id`: one byte used for production attributes such as the
  colour batch.

`model_id` supports these transports:

* `usb`: wired USB
* `equad`: the RF protocol used by Unifying receivers
* `btle`: Bluetooth Low Energy, also used by Bolt receivers
* `bt`: Bluetooth Classic

`openlogi list` displays both fields under each paired device:

```text
     model_ids=[b042,0000,0000] ext=02 serial=— unit_id=00010203 transports=usb+equad
```

The registry's Model ID joins the extended byte and the first PID:

```text
model id = {extended_model_id:x}{model_id[0]:04x}
```

For example, `ext=02` and `model_ids=[b042,…]` produce `2b042`.

<ModelIdAnatomy />

Four-digit Model IDs contain only the PID. Older asset depots and products that
the registry identifies only by USB PID usually use this format. Examples
include receivers (`c548`), webcams (`085e`), and G-series audio devices
(`0a9b`).

### Match by the last four digits [#match-by-the-last-four-digits]

The extended value in the registry does not always match the value reported by
the device. For example, the MX Master 4 is listed as `2b042`, but the
device reports `ext=01`.

The same product can also report a different PID for each transport. The
MX Master 3S reports `b034` over Bluetooth Low Energy and `b043`
through a receiver. The registry lists both PIDs.

OpenLogi first matches the full `ext + pid`, then the trailing four-digit PID.
If neither matches, it uses the firmware codename to find the product name in
the registry. Search this page by the four-digit PID reported by the device
rather than relying on the extended value.

## Index [#index]

Each row represents one product in the registry and includes all its Model IDs.
A Model ID is not a unique key. The same G915 has old and new asset depots, and
a racing wheel base's PID is repeated in the entries for compatible rims. A few
IDs therefore appear on more than one row.

Some products have no HID++ identity. The registry uses the asset depot name in
place of an ID, as it does with `g29`. This page keeps those original values.

<ModelIdIndex />

The same data is available at [`/model-ids.json`](/model-ids.json), in the same
order as the table. Each item has the shape
`{ ids, name, kind, depot, render }`, so scripts can read it directly. `depot`
is the device's asset directory on
[assets.openlogi.org](https://assets.openlogi.org), and `render` is its main
image.

## Limitations [#limitations]

* **The data comes from the asset registry.** This page includes only products
  for which Logitech has published assets, including some G-series wheels,
  pedals, and headsets. Products without public assets do not appear here, but
  they may still have valid Model IDs.
* **Only IDs published by the registry are included.** A device can report up
  to three PIDs, but most asset depots publish only one. The PID reported by a
  device may therefore be missing from this page. Searching by the last four
  digits avoids mismatches caused only by a different extended value.
* **Receivers are listed as separate products.** The table identifies each one
  by its USB PID. See [Receivers](/hidpp/receivers) for how OpenLogi communicates
  with them.
* **Being listed does not imply support.** OpenLogi opens any Logitech HID
  interface with an HID++ vendor-defined collection, so an unlisted device may
  still work with OpenLogi. Conversely, OpenLogi does not configure the headsets
  and microphones listed here. See [Supported devices](/docs/supported-devices)
  for the features exposed by each product.


# Receivers (/hidpp/receivers)



A *receiver* is a USB adapter for wireless Logitech devices. OpenLogi detects a
known adapter by USB VID/PID. It then controls the adapter at HID++ 1.0 device
index `0xFF` (`RECEIVER_DEVICE_INDEX`). Paired devices use their assigned slot
index when OpenLogi addresses them.

> **Spec:** No public Logitech specification. Layout reverse-engineered from Logi Options+ 2.8 (static analysis). &#x2A;*Cross-checked:** Solaar. &#x2A;*Used by:** [Connect a device](/docs/connect-device) and [Supported devices](/docs/supported-devices).

## Detection [#detection]

`receiver::detect(chan: Arc<HidppChannel>) -> Option<Receiver>` matches an exact
USB VID/PID. It returns `None` for every unlisted adapter. All listed IDs use
Logitech VID `046D`.

| Market family | VID:PID                                                                      | OpenLogi receiver protocol |
| ------------- | ---------------------------------------------------------------------------- | -------------------------- |
| Logi Bolt     | `046D:C548`                                                                  | Bolt                       |
| Unifying      | `046D:C52B`, `046D:C532`                                                     | Unifying                   |
| Nano          | `046D:C534`, `046D:C537`                                                     | Unifying-compatible        |
| Lightspeed    | `046D:C539`, `046D:C53A`, `046D:C53F`, `046D:C541`, `046D:C547`, `046D:C54D` | Unifying-compatible        |

`Receiver` has only `Bolt` and `Unifying` protocol variants. Therefore, Nano and
Lightspeed adapters return `Receiver::Unifying(_)`. `Receiver::name()` also
returns `"Unifying Receiver"` for those Unifying-compatible adapters.

## Common API [#common-api]

`Receiver` owns the concrete receiver listener. Its receiver-wide operations
use device index `0xFF`. Its methods do not expose the adapter market family.

| Item                | Signature or value                                                                       | Purpose                                                  |
| ------------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| Receiver address    | `RECEIVER_DEVICE_INDEX = 0xFF`                                                           | HID++ 1.0 receiver device index.                         |
| Detection           | `detect(Arc<HidppChannel>) -> Option<Receiver>`                                          | Classifies a known VID/PID.                              |
| Receiver kind       | `Receiver::Bolt(bolt::Receiver)` / `Receiver::Unifying(unifying::Receiver)`              | Selects the protocol wrapper.                            |
| Display name        | `name(&self) -> String`                                                                  | Returns `"Logi Bolt Receiver"` or `"Unifying Receiver"`. |
| Unique ID           | `get_unique_id(&self) -> Result<String, ReceiverError>`                                  | Returns the family-specific receiver identifier.         |
| Notifications read  | `read_notification_flags(&HidppChannel) -> Result<NotificationFlags, Hidpp10Error>`      | Reads register `0x00`.                                   |
| Notifications write | `write_notification_flags(&HidppChannel, NotificationFlags) -> Result<(), Hidpp10Error>` | Writes register `0x00`.                                  |

`ReceiverError` is either `UnknownReceiver` or `Protocol(Hidpp10Error)`. The
non-exhaustive `Receiver` and error enums can add variants in later releases.

### Register `0x00`: notification flags [#register-0x00-notification-flags]

Register `0x00` is a complete **24-bit BE** word. `NotificationFlags` retains
unnamed bits when it comes from `read_notification_flags()`. A caller must edit
that read value before it calls `write_notification_flags()`.

| Word bit       | Mask               | Meaning                                                   | OpenLogi handling                                |
| -------------- | ------------------ | --------------------------------------------------------- | ------------------------------------------------ |
| 8              | `0x000100`         | `WIRELESS`: device-arrival/removal (`0x41`) notifications | Modelled.                                        |
| 11             | `0x000800`         | `SOFTWARE_PRESENT`: host software is present              | Modelled. Pairing needs this bit and `WIRELESS`. |
| All other bits | All remaining bits | Receiver- or software-specific notifications              | Retained from a read value, but not named.       |

`write_notification_flags()` writes all three bytes of the supplied value. It
cannot recover bits that a caller omitted. `unifying::Receiver::set_wireless_notifications()`
reads the word, changes only `WIRELESS`, and skips an identical write.

## Logi Bolt [#logi-bolt]

Bolt is a Bluetooth Low Energy receiver. Its wrapper supports up to six paired
devices, discovery, passkey events, pairing, unpairing, and paired-device
reads. Its register layout has no public Logitech specification.

### API [#api]

| Method                   | Signature                                                                         | Returns                                                 |
| ------------------------ | --------------------------------------------------------------------------------- | ------------------------------------------------------- |
| Construct                | `new(Arc<HidppChannel>)`                                                          | `Result<bolt::Receiver, ReceiverError>`                 |
| Subscribe                | `listen(&self)`                                                                   | `async_channel::Receiver<bolt::Event>`                  |
| Read notification flags  | `get_notification_flags(&self)`                                                   | `Result<NotificationFlags, ReceiverError>`              |
| Write notification flags | `set_notification_flags(&self, flags: NotificationFlags)`                         | `Result<(), ReceiverError>`                             |
| Count pairings           | `count_pairings(&self)`                                                           | `Result<u8, ReceiverError>`                             |
| Trigger arrival          | `trigger_device_arrival(&self)`                                                   | `Result<(), ReceiverError>`                             |
| Collect paired devices   | `collect_paired_devices(&self)`                                                   | `Result<Vec<bolt::DeviceConnection>, ReceiverError>`    |
| Read unique ID           | `get_unique_id(&self)`                                                            | `Result<String, ReceiverError>`                         |
| Read pairing information | `get_device_pairing_information(&self, device_index: u8)`                         | `Result<bolt::DevicePairingInformation, ReceiverError>` |
| Read codename            | `get_device_codename(&self, device_index: u8)`                                    | `Result<String, ReceiverError>`                         |
| Unpair                   | `unpair_device(&self, device_index: u8)`                                          | `Result<(), ReceiverError>`                             |
| Pair                     | `pair_device(&self, slot: u8, address: [u8; 6], authentication: u8, entropy: u8)` | `Result<(), ReceiverError>`                             |
| Start discovery          | `discover_devices(&self, timeout: Option<u8>)`                                    | `Result<(), ReceiverError>`                             |
| Cancel discovery         | `cancel_device_discovery(&self)`                                                  | `Result<(), ReceiverError>`                             |

`bolt::DeviceConnection` has `index`, `kind`, `encrypted`, `online`, and
`wpid`. `bolt::DevicePairingInformation` has `wpid`, `kind`, `encrypted`,
`online`, and `unit_id: [u8; 4]`.

`bolt::DeviceKind` models `Keyboard`, `Mouse`, `Numpad`, `Presenter`, `Remote`,
`Trackball`, `Touchpad`, `Tablet`, `Gamepad`, `Joystick`, `Headset`, and
`Unknown`. `PairingPasskeyPressType` models `Initialization` (`0x00`),
`Keypress` (`0x01`), `Submit` (`0x04`), and `Other(u8)`.

### Registers and pairing messages [#registers-and-pairing-messages]

All entries below use receiver index `0xFF`. `N` is the low four bits of a
paired-device slot. Multi-byte WPIDs are **LE**.

| Register or sub-ID      | Request or payload layout                                                                                                        | OpenLogi handling                                                                                 |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `0x02` read             | Short read payload `[00, 00, 00]`; response byte 1 is the pairing count                                                          | `count_pairings()` reads byte 1.                                                                  |
| `0x02` write            | `[02, 00, 00]`                                                                                                                   | `trigger_device_arrival()` requests one `0x41` report per paired slot.                            |
| `0xB5 / 0x5N`           | Long sub-register read with request `[00, 00]`; response byte 1 is device info, bytes 2–3 are WPID **LE**, bytes 4–7 are unit ID | `get_device_pairing_information()` models these fields.                                           |
| `0xB5 / 0x6N`           | Long sub-register read with request `[01, 00]`; response byte 2 is a name length, followed by UTF-8 bytes at byte 3              | `get_device_codename()` returns at most the 13 bytes in one response. It rejects invalid UTF-8.   |
| `0xFB`                  | Long-register read with `[00, 00, 00]`                                                                                           | `get_unique_id()` parses the complete response as UTF-8. The value is not the Bolt serial number. |
| `0xC0` discovery        | Start `[timeout, 01, 00]`; cancel `[00, 02, 00]`                                                                                 | `None` writes timeout `0`. The receiver maximum is 60 seconds.                                    |
| `0xC1` pair             | Long payload: `[01, slot, address[0..6], authentication, entropy, 00 × 6]`                                                       | `pair_device()` sends the supplied slot. The device layer uses slot `0` for auto-assignment.      |
| `0xC1` cancel or unpair | Cancel `[02, 00 × 15]`; unpair `[03, slot, 00 × 14]`                                                                             | The wrapper and device layer use these fixed action values.                                       |

The wrapper emits the following unmatched long notifications. The offsets start
after the HID++ 1.0 two-byte header. Every notification except `0x41` must use
receiver index `0xFF`.

| Sub-ID                   | Payload layout                                                                                                              | `bolt::Event` result                                   |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `0x4D` passkey request   | Bytes 1–6: NUL-padded ASCII digits. Bytes 7–12: device address.                                                             | `PairingPasskeyRequest { device_address, passkey }`    |
| `0x4E` passkey press     | Byte 0: press type. Bytes 1–6: device address.                                                                              | `PairingPasskeyPressed { device_address, press_type }` |
| `0x4F` discovery details | Bytes 0–1: counter **LE**. Byte 2: `0`. Byte 4: kind. Bytes 5–6: WPID **LE**. Bytes 7–12: address. Byte 15: authentication. | `DeviceDiscoveryDeviceDetails`                         |
| `0x4F` discovery name    | Bytes 0–1: counter **LE**. Byte 2: `1`. Byte 3: UTF-8 length. Bytes 4 onward: name.                                         | `DeviceDiscoveryDeviceName`                            |
| `0x53` discovery status  | Byte 0: `0` means discovery enabled.                                                                                        | `DeviceDiscoveryStatus { discovery_enabled }`          |

The public wrapper ignores unused bytes in those layouts. It drops malformed
name and passkey text. The device pairing service also reads `0x54`: byte 1 is
a receiver error, and byte 8 is the assigned slot when byte 0 is `0x02`.

## Unifying [#unifying]

Unifying uses the 2.4 GHz eQuad radio protocol. Its wrapper supports up to six
paired devices, receiver information, paired-device reads, and `0x41` events.
It does not expose direct discovery, pairing, unpairing, or codename methods.

### API [#api-1]

| Method                     | Signature                                                 | Returns                                                     |
| -------------------------- | --------------------------------------------------------- | ----------------------------------------------------------- |
| Construct                  | `new(Arc<HidppChannel>)`                                  | `Result<unifying::Receiver, ReceiverError>`                 |
| Subscribe                  | `listen(&self)`                                           | `async_channel::Receiver<unifying::Event>`                  |
| Count pairings             | `count_pairings(&self)`                                   | `Result<u8, ReceiverError>`                                 |
| Set wireless notifications | `set_wireless_notifications(&self, enabled: bool)`        | `Result<(), ReceiverError>`                                 |
| Trigger arrival            | `trigger_device_arrival(&self)`                           | `Result<(), ReceiverError>`                                 |
| Read receiver information  | `get_receiver_info(&self)`                                | `Result<unifying::ReceiverInfo, ReceiverError>`             |
| Read pairing information   | `get_device_pairing_information(&self, device_index: u8)` | `Result<unifying::DevicePairingInformation, ReceiverError>` |
| Read unique ID             | `get_unique_id(&self)`                                    | `Result<String, ReceiverError>`                             |

`unifying::ReceiverInfo` has `serial_number` and `pairing_slots`.
`unifying::DevicePairingInformation` has `wpid`, `kind`, `encrypted`, `online`,
and `unit_id: [u8; 4]`. `unifying::Event` has only
`DeviceConnection(DeviceConnection)`.

The Unifying kind table matches Bolt for `Keyboard` through `Presenter`.
Unifying uses `Remote = 0x05`, `Trackball = 0x06`, and `Touchpad = 0x07`.
Bolt uses `0x07`, `0x08`, and `0x09` for those three kinds. Unknown kind values
become `Unknown` in both wrappers.

### Registers and pairing lock [#registers-and-pairing-lock]

| Register or sub-ID | Request or payload layout                                                                                         | OpenLogi handling                                                                |
| ------------------ | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `0x02` read        | Short read payload `[00, 00, 00]`; response byte 1 is the pairing count                                           | `count_pairings()` reads byte 1.                                                 |
| `0x02` write       | `[02, 00, 00]`                                                                                                    | `trigger_device_arrival()` requests reports for online and offline paired slots. |
| `0xB5 / 0x03`      | Long sub-register read with `[00, 00]`; response bytes 1–4 form the receiver serial, and byte 6 is the slot count | `get_receiver_info()` renders bytes 1–4 as uppercase hexadecimal.                |
| `0xB5 / 0x5N`      | Long sub-register read with `[00, 00]`; byte 1 is device info, bytes 2–3 are WPID **LE**, bytes 4–7 are unit ID   | `get_device_pairing_information()` models these fields.                          |
| `0xB2` lock        | Open `[01, 00, 30]`; close `[02, 00, 00]`; unpair `[03, slot, 00]`                                                | The device pairing service controls this register.                               |
| `0x4A` lock state  | Byte 0 bit 0: lock open. Byte 1: non-zero receiver error.                                                         | The pairing service confirms an open lock before it reports `Searching`.         |

`InfoSubRegister::DeviceCodename` exists as `0x60`, but the Unifying wrapper
has no codename method. OpenLogi's inventory path uses a separate
Unifying-specific read for codenames.

## The `0x41` connection notification [#the-0x41-connection-notification]

Both receiver wrappers decode sub-ID `0x41`. The header device index is the
one-based paired-device slot. A trigger request produces one report for every
paired slot, including offline slots. A `0x41` report is therefore a slot
report, not proof that its device is online.

The table describes the notification payload after the HID++ 1.0 header.

| Byte or bits           | Wire meaning             | OpenLogi handling                                                                                                                                      |
| ---------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Byte 0                 | Protocol byte            | `0x10` makes `encrypted` true. The value is otherwise not exposed. Options+ 2.8 and Solaar evidence identify this as the Bolt encrypted-link sentinel. |
| Byte 1, bits 0–3       | Device kind              | Modelled with the family-specific kind table. Unknown values become `Unknown`.                                                                         |
| Byte 1, bit 4 (`0x10`) | Software present         | Ignored by the event decoders. It reflects register `0x00` `SOFTWARE_PRESENT`; it is not encryption.                                                   |
| Byte 1, bit 5 (`0x20`) | Link encrypted           | Modelled as `encrypted`.                                                                                                                               |
| Byte 1, bit 6 (`0x40`) | Link not established     | Modelled as `online = false`. Clear means `online = true`.                                                                                             |
| Byte 1, bit 7 (`0x80`) | A payload packet follows | Ignored by the event decoders.                                                                                                                         |
| Bytes 2–3              | Wireless product ID      | Modelled as `wpid`, **LE**.                                                                                                                            |

OpenLogi computes `encrypted` from byte 1 bit 5 **or** byte 0 `0x10`. It
models only `index`, `kind`, `encrypted`, `online`, and `wpid` in the event.
It does not retain byte 0, bit 4, or bit 7 in `DeviceConnection`.

## Enumerating paired devices [#enumerating-paired-devices]

Enable wireless notifications before you trigger arrivals. The Unifying helper
performs a read-modify-write of register `0x00`. The Bolt example below derives
its write from the read flags for the same reason.

```rust
use std::sync::Arc;

use hidpp::{
    channel::HidppChannel,
    receiver::{self, unifying, NotificationFlags, Receiver, ReceiverError},
};

async fn enumerate_receiver(chan: Arc<HidppChannel>) -> Result<(), ReceiverError> {
    let Some(receiver) = receiver::detect(Arc::clone(&chan)) else {
        return Ok(());
    };
    println!("{} — {}", receiver.name(), receiver.get_unique_id().await?);

    match receiver {
        Receiver::Bolt(bolt) => {
            let flags = bolt.get_notification_flags().await?;
            bolt.set_notification_flags(flags | NotificationFlags::WIRELESS)
                .await?;
            for device in bolt.collect_paired_devices().await? {
                println!("slot {}: {:04X}", device.index, device.wpid);
            }
        }
        Receiver::Unifying(unifying_receiver) => {
            unifying_receiver.set_wireless_notifications(true).await?;
            let events = unifying_receiver.listen();
            unifying_receiver.trigger_device_arrival().await?;
            while let Ok(unifying::Event::DeviceConnection(device)) = events.recv().await {
                println!("slot {}: {:04X}", device.index, device.wpid);
            }
        }
        _ => {}
    }
    Ok(())
}
```

The Bolt helper stops collecting after the trigger write completes. The
Unifying wrapper emits individual events, so callers choose their own collection
boundary.

## How OpenLogi uses it [#how-openlogi-uses-it]

The device registry classifies USB receivers before inventory and pairing use
their protocol wrapper. Unifying-compatible Nano and Lightspeed adapters use
the Unifying path. Inventory enables `WIRELESS` with a read-modify-write, then
triggers `0x41` reports to enumerate paired slots.

The pairing service holds one receiver register phase for its full session. It
reads the complete `0x00` word, adds `WIRELESS` and `SOFTWARE_PRESENT`, then
restores the original word after the listener closes. Since v0.8.14, this keeps
unknown notification bits instead of clearing the register.

Bolt writes discovery `0xC0` and reports `Searching` after its write
acknowledgement. Unifying writes the `0xB2` lock-open request, then waits up to
2 seconds for `0x4A` to confirm bit 0. It reports `Searching` only after that
confirmation. Errors, cancellation, and timeouts send the phase-appropriate
close or cancel request.

The public pairing selector has `First` and `BoltUid(String)`. The service can
match either family by a route's stored receiver UID for unpairing. It cannot
select a Unifying receiver by UID when it starts a new pairing session.

## Open questions [#open-questions]

No public Logitech receiver specification defines these register layouts. The
page describes static Options+ 2.8 evidence and Solaar cross-checks. It does
not claim hardware observation.

Options+ writes `0` as the third byte of the Unifying `0xB2` lock-open request.
Solaar supports a 30-second timeout. OpenLogi writes `30`; no static evidence
shows that value is wrong. Hardware traces must resolve the receiver-specific
timing semantics.

Options+ retains byte 0 of `0x41`. Solaar treats byte 0 `0x10` as an encrypted
Bolt link. OpenLogi applies that sentinel in the shared decoder and does not
expose the protocol byte. The sentinel's meaning for a future Unifying protocol
value remains unverified.

OpenLogi can read a Unifying unique ID, but `ReceiverSelector` has no
`UnifyingUid` variant. A new-pairing request with `First` can therefore open the
first supported receiver found. This is an open product limitation, not an
uncertain wire-format claim.

## No receiver [#no-receiver]

Bluetooth-direct and wired devices appear as independent inventory devices. They
do not use receiver registers. See [Connect a device](/docs/connect-device).


# Configuration (/docs/configurations)



OpenLogi stores its shared settings in one **TOML** file. The GUI and the
background agent read the same file. The GUI writes supported changes, but you
can also author the file directly.

## File and safe editing [#file-and-safe-editing]

| Platform      | Path                                                                                |
| ------------- | ----------------------------------------------------------------------------------- |
| macOS / Linux | `$XDG_CONFIG_HOME/openlogi/config.toml` (default: `~/.config/openlogi/config.toml`) |
| Windows       | `%USERPROFILE%\.config\openlogi\config.toml`                                        |

The schema is strict. Unknown, obsolete, malformed, and out-of-range values
stop the file from loading. The GUI opens a read-only error screen with the TOML
error until you fix the file and relaunch OpenLogi.

| Situation          | OpenLogi behavior                                                                                             |
| ------------------ | ------------------------------------------------------------------------------------------------------------- |
| GUI save           | Writes the file atomically. Existing comments and formatting remain for fields that still exist.              |
| Backup             | Before its first save, a process rotates five backups: `config.toml.backup.1` through `config.toml.backup.5`. |
| Schema migration   | The first successful save also copies the old file to `config.toml.v<version>.bak`.                           |
| Symbolic link      | Writes the resolved target and preserves the link. Missing target directories are created.                    |
| Symbolic-link lock | The persistent `.lock` sidecar stays beside the target. Different links to one target share the lock.         |
| External edit      | The next GUI save is refused rather than overwriting the edit. Relaunch OpenLogi to load it.                  |
| Open GUI           | The GUI asks the resident agent to reload the current file.                                                   |

On Unix, OpenLogi creates written files with `0600` permissions. Keep a manual
copy before a large hand edit. A strict parse error prevents OpenLogi from
silently replacing your file with defaults.

## Document shape and migrations [#document-shape-and-migrations]

`schema_version` is required. This release writes schema `9`. A version of `0`
or a version newer than the running build is rejected before other fields are
parsed.

```toml
schema_version = 9
selected_device = "unit:6be9d300"

[app_settings]
# App-wide preferences.

[devices."unit:6be9d300"]
# One physical device.

[keyboard.bindings]
# Function-key remaps for any keyboard.

[apps.chrome]
# An optional cross-platform application alias.
```

| Top-level key     | Type                        | Meaning                                    |
| ----------------- | --------------------------- | ------------------------------------------ |
| `schema_version`  | integer                     | Required schema version. Use `9`.          |
| `selected_device` | physical device key or omit | The last device selected in the GUI.       |
| `app_settings`    | table                       | App-wide preferences.                      |
| `devices`         | table of tables             | Settings by physical device key.           |
| `keyboard`        | table                       | Global function-key bindings.              |
| `apps`            | table of tables             | Optional application aliases for profiles. |

The loader accepts versions `1` through `9`. It migrates supported layouts when
it loads them.

| Version | Change and migration                                                                                                           |
| ------- | ------------------------------------------------------------------------------------------------------------------------------ |
| v1      | Used separate `button_bindings` and `gesture_bindings` maps.                                                                   |
| v2      | Merged those maps into `bindings`. A v1 file rewrites to this shape on save.                                                   |
| v3      | Moved device entries from model keys to physical device keys. Copy a v2 model-key entry manually when identical devices exist. |
| v4      | Removed the device-wide `gesture_owner`. Gesture mode became a per-button binding shape.                                       |
| v5      | Moved direct-device settings to a device identity, retained routes in `links`, and added `ui_scale`.                           |
| v6      | Added `{ short = ..., long = ... }` button bindings.                                                                           |
| v7      | Normalized thumb-wheel directions. Explicit old default pairs migrate in device and per-app bindings.                          |
| v8      | Added wheel-hold bindings and the per-device `smooth_wheel` switch.                                                            |
| v9      | Added `[apps]` aliases and `app:<alias>` profile keys. v8 files otherwise load unchanged.                                      |

## App settings [#app-settings]

`[app_settings]` is omitted when every value is at its default. The values below
are the schema defaults, not GUI state inferred from a connected device.

| Key                           | Type            | Default           | Meaning                                                                                   |
| ----------------------------- | --------------- | ----------------- | ----------------------------------------------------------------------------------------- |
| `launch_at_login`             | bool            | `true`            | Starts the background agent after login.                                                  |
| `check_for_updates`           | bool            | `false`           | Checks the latest GitHub release once per app launch.                                     |
| `auto_install_updates`        | bool            | `false`           | Downloads and stages an update after an enabled update check. It applies on restart.      |
| `update_prompt_seen`          | bool            | `false`           | Records that the first-run update prompt received an answer.                              |
| `show_in_menu_bar`            | bool            | `true`            | Shows the macOS menu-bar item and Windows notification-area icon. Linux ignores it.       |
| `capture_mouse_events`        | bool            | `true`            | Installs the OS mouse hook for mouse remapping. Restart the agent after changing it.      |
| `smooth_scroll`               | bool            | `false`           | Smooths eligible traditional mouse-wheel input. Native trackpad input stays native.       |
| `vertical_scroll_sensitivity` | integer         | `14`              | Traditional vertical-wheel distance, from `1` through `100`. `14` is 1×.                  |
| `auto_download_assets`        | bool            | `true`            | Downloads device assets when a device appears. A manual asset refresh still downloads.    |
| `asset_source`                | enum            | `automatic`       | Asset source: `automatic`, `openlogi`, `cloudflare`, or `fastly`.                         |
| `language`                    | string or omit  | system locale     | Bundled UI locale, such as `en` or `zh-CN`.                                               |
| `thumbwheel_sensitivity`      | integer         | `14`              | Thumb-wheel sensitivity from `1` through `100`. `14` is 1×.                               |
| `appearance`                  | enum            | `system`          | Interface appearance: `system`, `light`, or `dark`.                                       |
| `ui_scale`                    | enum            | `normal`          | Interface scale: `small` (90%), `normal` (100%), `large` (110%), or `extra_large` (125%). |
| `device_view_mode`            | enum            | `grid`            | Home layout: `grid`, `list`, or `carousel`.                                               |
| `app_icon`                    | enum            | `openlogi`        | macOS app icon: `openlogi` or `prism`. Other platforms ignore it.                         |
| `theme_light`                 | string or omit  | brand light theme | Theme name for light mode.                                                                |
| `theme_dark`                  | string or omit  | brand dark theme  | Theme name for dark mode.                                                                 |
| `ui_radius`                   | integer or omit | theme radius      | Corner-radius override in pixels. The UI offers `0`, `6`, and `12`.                       |
| `mouse_profile_target`        | enum            | `pointer`         | Target for mouse profiles: `pointer` or `focused`.                                        |
| `low_battery_notifications`   | bool            | `true`            | Shows one low-battery notice per discharge.                                               |
| `lock_key_notifications`      | bool            | `true`            | Shows lock-key state notices from compatible keyboards.                                   |

With `capture_mouse_events = false`, button remapping stops and OpenLogi does
not grab input devices. HID++ features, such as DPI and SmartShift, continue to
work. A `smooth_wheel` device setting needs mouse capture as well.

`mouse_profile_target = "pointer"` follows the window under the pointer on
macOS, Windows, and X11. Wayland and unidentified surfaces use the focused
application. The desktop uses global mouse bindings.

OpenLogi never focuses a background window to send a shortcut. A mouse binding
that emits keys or runs a workflow is skipped until the hovered window has
focus. Keyboard profiles and Actions Ring layouts always follow the focused
application.

Low battery means 20% or less while not charging, or a device-reported low or
critical level without a percentage. A notice closes after 8 seconds or when
clicked. Lock-key notices require HID++ `0x4220`; macOS does not show Num Lock
or Scroll Lock notices.

## Device identities and routes [#device-identities-and-routes]

`[devices.<key>]` uses a physical identity, not a model id. Two identical mice
therefore keep independent settings. Let OpenLogi create the key, then copy it
from `config.toml`.

| Key form                                     | Use                                                                                                                        |
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `unit:6be9d300`                              | A non-zero four-byte HID++ unit id.                                                                                        |
| `serial:abc123`                              | A non-empty device serial, stored in lower case.                                                                           |
| `receiver:aabbccdd:slot:1`                   | A receiver UID and pairing slot. It identifies a receiver-paired device.                                                   |
| `direct:046d:c08d:serial:abc123`             | A direct HID++ device before its settings move to the bare `serial:` or `unit:` identity.                                  |
| `raw:046d:c900:ff43:0202:serial:your-serial` | A standalone raw-HID device with a serial, such as a Litra light. `stable:<id>` is also valid when its driver provides it. |
| `camera:046d:0893:serial:abc123`             | A camera with a USB serial.                                                                                                |
| `camera:046d:0893`                           | A camera without a USB serial. Same-model cameras then share this settings key.                                            |

A device may have several routes. `links` records each route beneath its
physical entry. `capabilities` records features measured on that route, while
`overrides` deliberately changes a supported route's settings.

```toml
[devices."unit:6be9d300".links."receiver:aabbccdd:slot:1"]

[devices."unit:6be9d300".links."direct:046d:c08d".capabilities]
buttons = true
pointer = true

[devices."unit:6be9d300".links."direct:046d:c08d".overrides]
dpi = 800
```

| Link table     | Fields                                                                              |
| -------------- | ----------------------------------------------------------------------------------- |
| `capabilities` | App-measured capability table. Do not author it by hand.                            |
| `overrides`    | Optional `dpi`, `invert_scroll`, `scroll_resolution`, `lighting`, and `smartshift`. |

A serial-less camera cannot distinguish same-model units across USB ports. Its
GUI card still uses the OS capture id, but its settings key remains
model-scoped. A direct device with only `unit:00000000` has no persistent
identity.

## Per-device fields [#per-device-fields]

Each `[devices.<key>]` table stores settings for one physical device. Optional
values are omitted until you set them.

| Key                      | Type                            | Default     | Meaning                                                                                |
| ------------------------ | ------------------------------- | ----------- | -------------------------------------------------------------------------------------- |
| `enabled`                | bool                            | `true`      | `false` leaves the device native. OpenLogi stops capture and reconnect re-application. |
| `custom_name`            | string or omit                  | omit        | User-assigned device name.                                                             |
| `identity`               | table                           | omit        | App-managed snapshot of model name, type, and capabilities.                            |
| `links`                  | table                           | empty       | App-managed route index, route capabilities, and route overrides.                      |
| `bindings`               | table                           | empty       | Logical control to binding map.                                                        |
| `disabled_gestures`      | table                           | empty       | App-managed saved gesture maps for controls not currently in gesture mode.             |
| `per_app_bindings`       | table of tables                 | empty       | Sparse app-specific button-action overlays.                                            |
| `action_ring`            | table                           | default     | Actions Ring enablement, haptics, default layout, and complete per-app layouts.        |
| `dpi_presets`            | integer array                   | empty       | DPI list used by `CycleDpiPresets` and zero-based `SetDpiPreset`.                      |
| `dpi`                    | integer or omit                 | omit        | Sensor DPI that the agent reapplies after a reconnect.                                 |
| `lighting`               | table or omit                   | omit        | Static HID++ keyboard RGB settings.                                                    |
| `light`                  | table or omit                   | omit        | Standalone light settings.                                                             |
| `smartshift`             | table or omit                   | omit        | SmartShift wheel settings.                                                             |
| `camera_controls`        | string-to-integer table or omit | omit        | Raw UVC values, for example `brightness`, `focus`, or `focus_auto`.                    |
| `camera_profiles`        | table of tables                 | empty       | Named saved camera-control snapshots.                                                  |
| `camera_profile`         | string or omit                  | omit        | Last camera profile applied in the GUI.                                                |
| `thumbwheel_sensitivity` | integer or omit                 | app setting | Per-device override from `1` through `100`.                                            |
| `invert_scroll`          | bool                            | `false`     | Reverses this device's vertical wheel without changing trackpad direction.             |
| `scroll_resolution`      | enum or omit                    | omit        | HID++ `0x2121` resolution: `low` or `high`. Omit it for the device setting.            |
| `smooth_wheel`           | bool                            | `false`     | Smooths this mouse's main wheel in software.                                           |
| `host_switch_targets`    | physical device key array       | empty       | Pointing devices switched before this keyboard leaves an Easy-Switch host.             |
| `fn_lock`                | bool or omit                    | omit        | `true` makes the F row send F1–F12 without Fn.                                         |
| `keep_os_layout`         | bool or omit                    | `true`      | Keeps a compatible keyboard on the host operating-system layout.                       |
| `report_rate`            | integer or omit                 | omit        | Mouse report rate in Hz. The agent applies it only on a route that offers it.          |

There is no `backlight` configuration field. Backlight state is stored by the
keyboard. `lighting` is the static RGB table for supported HID++ keyboards.

### `lighting`, `light`, and `smartshift` [#lighting-light-and-smartshift]

| Table        | Key                  | Default    | Meaning                                                                          |
| ------------ | -------------------- | ---------- | -------------------------------------------------------------------------------- |
| `lighting`   | `enabled`            | `true`     | Applies the static color.                                                        |
| `lighting`   | `color`              | `"ffffff"` | Six hexadecimal `RRGGBB` digits. A leading `#` also loads.                       |
| `lighting`   | `brightness`         | `100`      | Brightness from `0` through `100`.                                               |
| `light`      | `enabled`            | `true`     | Turns a standalone light on or off.                                              |
| `light`      | `auto_camera`        | `false`    | Makes a standalone light follow aggregate camera use.                            |
| `light`      | `brightness_percent` | `100`      | Brightness from `0` through `100`.                                               |
| `light`      | `temperature_kelvin` | omit       | Requested color temperature when the device supports it.                         |
| `light`      | `color`              | omit       | Requested RGB color when the driver supports it.                                 |
| `smartshift` | `mode`               | required   | `ratchet` or `free`.                                                             |
| `smartshift` | `auto_disengage`     | required   | `8` through `254` in 0.25-turn-per-second steps, or `255` for permanent ratchet. |
| `smartshift` | `tunable_torque`     | omit       | `1` through `100` percent, or `0` when the hardware reports no torque level.     |

A `smooth_wheel` mouse diverts HID++ `0x2121` reports and posts eased scrolling.
It applies inversion and vertical sensitivity. It needs input capture and, on
macOS, Accessibility access. Turning it off, disabling the device, or stopping
the agent returns the wheel to native reporting.

## Binding controls [#binding-controls]

`bindings` and `per_app_bindings` use a logical control name as the key. The
mouse controls are always spelled as follows:

| Mouse controls                                                                                                                                                                                          |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `LeftClick`, `RightClick`, `MiddleClick`, `Back`, `Forward`, `DpiToggle`, `Thumbwheel`, `ThumbwheelScrollUp`, `ThumbwheelScrollDown`, `GestureButton`, `HapticPanel`, `WheelTiltLeft`, `WheelTiltRight` |

`Thumbwheel` is a capacitive tap report, not a mechanical click. It stays inert
unless you bind it. It can report an incidental thumb contact.

Keyboard controls are HID++ `0x1b04` controls. A known control uses its catalog
name. Any divertable unlisted control uses `control:0x<cid>`.

| Known keyboard control names                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `KeyBrightnessDown`, `KeyBrightnessUp`, `KeyBacklightDown`, `KeyBacklightUp`, `KeyMissionControl`, `KeyLaunchpad`, `KeyShowDesktop`, `KeySearch`, `KeyDictation`, `KeyEmoji`, `KeyScreenCapture`, `KeyPrintScreen`, `KeyMicMute`, `KeyPreviousTrack`, `KeyPlayPause`, `KeyPlayPauseTrack`, `KeyNextTrack`, `KeyMute`, `KeyVolumeDown`, `KeyVolumeUp`, `KeyCalculator`, `KeyLock`, `KeyDoNotDisturb`, `KeyEmojiHeartEyes`, `KeyEmojiCrying`, `KeyEmojiSmiley`, `KeyEmojiTears`, `KeyLookUp`, `KeyAppSwitch`, `KeyAppSwitchLaunchpad`, `KeyHome`, `KeyEnd`, `KeyPageUp`, `KeyPageDown`, `KeyMultiPlatformAppSwitch`, `KeyMultiPlatformHome`, `KeyMultiPlatformMenu`, `KeyMultiPlatformInsert`, `KeyLanguageSwitch`, `KeyCircle`, `KeyTriangle`, `KeyDiamond`, `KeyStar` |

A keyboard key remains on its firmware function until you bind it. An explicit
`None` binding diverts a bound keyboard control and does nothing. This differs
from removing the entry, which returns the key to its firmware function.

## Actions [#actions]

Binding values use serialized Rust action names. The table lists every action in
schema 9. Payload actions use a one-key TOML table rather than a string.

| Group                  | Actions                                                                                                                 |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| Suppress               | `None`                                                                                                                  |
| Mouse                  | `LeftClick`, `RightClick`, `MiddleClick`, `MouseBack`, `MouseForward`                                                   |
| Editing                | `Copy`, `Paste`, `Cut`, `Undo`, `Redo`, `SelectAll`, `Find`, `Save`                                                     |
| Browser                | `BrowserBack`, `BrowserForward`, `NewTab`, `CloseTab`, `ReopenTab`, `NextTab`, `PrevTab`, `ReloadPage`                  |
| Navigation             | `MissionControl`, `AppExpose`, `PreviousDesktop`, `NextDesktop`, `ShowDesktop`, `LaunchpadShow`                         |
| System                 | `LockScreen`, `Screenshot`, `CaptureRegion`, `Sleep`, `ShowActionsRing`, `OpenApplication`                              |
| Media                  | `PlayPause`, `NextTrack`, `PrevTrack`, `VolumeUp`, `VolumeDown`, `MuteVolume`                                           |
| DPI                    | `CycleDpiPresets`, `SetDpiPreset`, `ToggleSmartShift`                                                                   |
| Scroll                 | `ScrollUp`, `ScrollDown`, `HorizontalScrollLeft`, `HorizontalScrollRight`                                               |
| Payload and automation | `CustomShortcut`, `HoldShortcut`, `TapKeyHoldingModifiers`, `TypeText`, `RunAppleScript`, `RunShellCommand`, `Workflow` |

```toml
Back = "BrowserBack"
DpiToggle = { SetDpiPreset = 2 }
Forward = { CustomShortcut = "Cmd+Shift+P" }
MiddleClick = { HoldShortcut = "Ctrl+Space" }
GestureButton = { TapKeyHoldingModifiers = "Cmd+Tab" }
HapticPanel = { OpenApplication = { path = "~/Downloads", display_name = "Downloads" } }
```

`SetDpiPreset` uses a zero-based index. `OpenApplication` accepts an application,
folder, file-system path, or URL. A leading `~` expands when the action runs.

`CustomShortcut`, `HoldShortcut`, and `TapKeyHoldingModifiers` accept a chord
with one ordinary key. Modifiers are `Cmd`, `Ctrl`, `Alt`, `Shift`, and `Super`.
`Command` aliases `Cmd`; `Control` aliases `Ctrl`; `Option` aliases `Alt`; and
`Win` or `Meta` aliases `Super`.

`Cmd` is the cross-platform primary modifier. It is Command on macOS and Control
on Linux and Windows. `Super` always presses the logo key: Command on macOS, the
Windows key on Windows, and `KEY_LEFTMETA` on Linux.

### Press shapes [#press-shapes]

A global device binding can be a single action, a short/long pair, a wheel hold,
or a gesture map. Per-app and `[keyboard.bindings]` entries always use one action.

```toml
# Release before 500 ms for `short`. Reach 500 ms for `long`.
DpiToggle = { short = "ShowDesktop", long = "MissionControl" }

# The action holds until the physical button releases.
MiddleClick = { HoldShortcut = "Ctrl+Space" }

# Hold modifiers, then tap Tab once. This leaves the app switcher open.
Forward = { TapKeyHoldingModifiers = "Cmd+Tab" }

# Back or Forward can turn the main wheel into a virtual thumb wheel while held.
Back = { click = "BrowserBack", wheel = "HorizontalScroll" }
```

A short/long pair waits for a release. Releasing before `500 ms` fires `short`.
Reaching `500 ms` fires `long` once, and the later release does not fire `short`.
If capture ends, the binding changes, or the agent stops first, neither action
fires.

`CustomShortcut` presses and releases its chord immediately. `HoldShortcut`
holds the whole chord until release. `TapKeyHoldingModifiers` holds only the
modifiers and taps the ordinary key once. Use the latter for `Cmd+Tab`, `Alt+Tab`,
or another application switcher.

Wheel hold is available only on `Back` and `Forward`, and only when the hardware
reports raw-wheel support. A release without wheel motion runs `click`. A press
that scrolls runs no click action. The wheel direction reverses when
`invert_scroll` is on.

### Gesture maps [#gesture-maps]

A gesture map is a device-global binding table keyed by `Up`, `Down`, `Left`,
`Right`, and `Click`. `Click` is the plain press without a swipe.

```toml
[devices."unit:6be9d300".bindings.GestureButton]
Up = "MissionControl"
Down = "ShowDesktop"
Left = "PrevTab"
Right = "NextTab"
Click = "AppExpose"
```

Gesture mode is independent for every supported control. `MiddleClick`, `Back`,
and `Forward` use the OS hook. `DpiToggle`, `GestureButton`, and `HapticPanel`
need a device route with raw-XY support.

Middle-click gesture maps are valid in schema 9. You can author or retain one in
TOML. A per-app single action temporarily replaces the complete gesture map for
that application.

## Keyboard, profiles, and Actions Ring [#keyboard-profiles-and-actions-ring]

### Global function-key bindings [#global-function-key-bindings]

`[keyboard.bindings]` maps function-key triggers for any keyboard. A trigger is
`[modifier+]…key`. Valid modifiers are `shift`, `control` (`ctrl`), `option`
(`alt`), and `command` (`cmd`). Valid keys are `esc` and `f1` through `f19`.

```toml
[keyboard.bindings]
f1 = "MissionControl"
"shift+f2" = "ShowDesktop"
"cmd+f5" = { CustomShortcut = "Cmd+Shift+P" }
```

This map is separate from a Logitech keyboard's HID++ control bindings under
`[devices.<key>.bindings]`. `keyboard.bindings` does not accept short/long pairs,
gesture maps, or wheel holds.

### Application aliases and profiles [#application-aliases-and-profiles]

`per_app_bindings` is a sparse overlay. Its unlisted controls continue to use the
device-global `bindings` value. `action_ring.per_app` instead selects a complete
layout.

Use a platform identifier directly, or define `[apps.<alias>]` and use
`app:<alias>`. Alias names may contain only ASCII letters, digits, `-`, and `_`.
Each platform field accepts one identifier or an array.

```toml
[apps.chrome]
name = "Google Chrome"
macos = "com.google.Chrome"
linux = ["google-chrome", "Google-chrome"]
windows = "exe:chrome.exe"

[devices."unit:6be9d300".per_app_bindings."app:chrome"]
Back = "PrevTab"
```

| Identifier source | Accepted form                                              |
| ----------------- | ---------------------------------------------------------- |
| macOS             | Bundle id, or exact executable path without a bundle id.   |
| Linux             | X11 `WM_CLASS` or Wayland xdg `app_id`.                    |
| Windows           | Exact lower-case executable path, or `exe:<filename>.exe`. |

The most specific matching profile wins in this order:

1. Exact platform identifier.
2. Windows `exe:<filename>.exe` fallback.
3. An alias that lists the exact identifier.
4. An alias that lists the `exe:<filename>.exe` fallback.

The file fails to load when an `app:` key names an undefined alias. It also fails
when two aliases list the same platform identifier, an identifier is empty, an
identifier begins with `app:`, or a Windows identifier has uppercase letters.

### Actions Ring layouts [#actions-ring-layouts]

The ring uses eight slots: `Top`, `TopRight`, `Right`, `BottomRight`, `Bottom`,
`BottomLeft`, `Left`, and `TopLeft`. Each populated slot has `action`, plus
optional `icon` and `label`. Omit a slot to leave it empty.

`None` and `ShowActionsRing` are invalid slot actions. An Actions Ring cannot
contain an empty action or recursively open another ring.

```toml
[devices."unit:6be9d300".action_ring]
enabled = true
haptics = true

[devices."unit:6be9d300".action_ring.default.slots]
Top = { action = "Copy" }
Right = { action = { OpenApplication = { path = "/Applications/Safari.app", display_name = "Safari" } }, icon = "Applications" }
Bottom = { action = "ShowDesktop", label = "Desktop" }

[devices."unit:6be9d300".action_ring.per_app."app:chrome".slots]
Top = { action = "Cut" }
TopRight = { action = "Copy" }
Right = { action = "Paste" }
BottomRight = { action = "BrowserForward" }
Bottom = { action = "PlayPause" }
BottomLeft = { action = "BrowserBack" }
Left = { action = "Undo" }
TopLeft = { action = "Redo" }
```

The default ring contains Cut, Copy, Paste, Browser Forward, Play/Pause, Browser
Back, Undo, and Redo in slot order. A per-app layout replaces that entire layout.
It is not a sparse overlay.

## Valid schema 9 example [#valid-schema-9-example]

This complete example uses only physical keys. Copy the structure, then replace
the keys and paths with values OpenLogi generated for your devices.

```toml
schema_version = 9
selected_device = "receiver:aabbccdd:slot:1"

[app_settings]
launch_at_login = true
check_for_updates = false
auto_install_updates = false
show_in_menu_bar = true
capture_mouse_events = true
mouse_profile_target = "pointer"
smooth_scroll = false
vertical_scroll_sensitivity = 14
auto_download_assets = true
asset_source = "automatic"
language = "en"
thumbwheel_sensitivity = 14
appearance = "system"
ui_scale = "normal"
device_view_mode = "grid"
app_icon = "openlogi"
low_battery_notifications = true
lock_key_notifications = true

[devices."receiver:aabbccdd:slot:1"]
custom_name = "Office mouse"
dpi = 1600
dpi_presets = [800, 1600, 3200]
thumbwheel_sensitivity = 20
invert_scroll = false
scroll_resolution = "high"
smooth_wheel = true

[devices."receiver:aabbccdd:slot:1".bindings]
Back = { click = "BrowserBack", wheel = "HorizontalScroll" }
Forward = { TapKeyHoldingModifiers = "Cmd+Tab" }
MiddleClick = { HoldShortcut = "Ctrl+Space" }
DpiToggle = { short = "ShowDesktop", long = "MissionControl" }
Thumbwheel = "AppExpose"

[devices."receiver:aabbccdd:slot:1".bindings.GestureButton]
Click = "MissionControl"
Up = "MissionControl"
Down = "AppExpose"
Left = "PreviousDesktop"
Right = "NextDesktop"

[devices."receiver:aabbccdd:slot:1".per_app_bindings."app:chrome"]
Back = "PrevTab"
Forward = "NextTab"

[devices."receiver:aabbccdd:slot:1".action_ring]
enabled = true
haptics = true

[devices."receiver:aabbccdd:slot:1".action_ring.default.slots]
Top = { action = "Copy", icon = "Keyboard" }
Right = { action = { OpenApplication = { path = "/Applications/Safari.app", display_name = "Safari" } }, icon = "Applications" }
Bottom = { action = "ShowDesktop", label = "Desktop" }

[devices."receiver:aabbccdd:slot:1".lighting]
enabled = true
color = "ff0000"
brightness = 80

[devices."receiver:aabbccdd:slot:1".smartshift]
mode = "ratchet"
auto_disengage = 16
tunable_torque = 50

[devices."receiver:aabbccdd:slot:2"]
host_switch_targets = ["receiver:aabbccdd:slot:1"]
fn_lock = false
keep_os_layout = true

[devices."receiver:aabbccdd:slot:2".bindings]
KeySearch = "MissionControl"
KeyScreenCapture = "Sleep"
KeyCalculator = { OpenApplication = { path = "/System/Applications/Calculator.app", display_name = "Calculator" } }
"control:0x01f3" = { CustomShortcut = "Cmd+Shift+4" }

[devices."raw:046d:c900:ff43:0202:serial:your-serial".light]
enabled = true
auto_camera = false
brightness_percent = 65
temperature_kelvin = 4600
color = "ffffff"

[keyboard.bindings]
f1 = "MissionControl"
"shift+command+f5" = "ShowDesktop"

[apps.chrome]
name = "Google Chrome"
macos = "com.google.Chrome"
linux = ["google-chrome", "Google-chrome"]
windows = "exe:chrome.exe"
```


# MX Master 4 (/docs/devices/mx-master-4)





Logitech's registry lists this mouse as model id `2b042` — the string OpenLogi
resolves its renders and hotspots by, and the one the
[model id index](/hidpp/model-ids) keys it under. What follows describes the
product; what your own unit answers is what `openlogi diag features` prints.

## Controls [#controls]

<DeviceFigure depot="mx_master_4" name="MX Master 4" />

Controls bind under logical names in `config.toml`. The agent captures only the
controls that the current bindings need:

| # | Control            | Binds as                                                   | Diverted through                                                |
| - | ------------------ | ---------------------------------------------------------- | --------------------------------------------------------------- |
| 1 | Middle click       | `MiddleClick`                                              | OS input hook                                                   |
| 2 | Mode-shift button  | `DpiToggle`                                                | [`0x1b04`](/hidpp/features/x1b04-special-keys-mse-buttons)      |
| 3 | Thumb wheel        | `Thumbwheel`, `ThumbwheelScrollUp`, `ThumbwheelScrollDown` | [`0x2150`](/hidpp/features/x2150-thumbwheel)                    |
| 4 | Gesture button     | `GestureButton`                                            | `0x1b04`                                                        |
| 5 | Forward            | `Forward`                                                  | OS input hook; HID++ also participates in macOS gesture capture |
| 6 | Back               | `Back`                                                     | OS input hook; HID++ also participates in macOS gesture capture |
| 7 | Haptic Sense panel | `HapticPanel`                                              | a divertable `0x01a0` control in the `0x1b04` table             |

A fresh device starts with two non-native defaults. The **Gesture Button** runs a
gesture map: Up is Mission Control, Down is Show Desktop, Left and Right switch
to the previous and next tab, and Click is App Exposé. The **Haptic Sense panel**
opens the [Actions Ring](/docs/features/actions-ring).

**Middle Click**, **Back**, **Forward**, the **Gesture Button**, and the
**Haptic Sense panel** support gesture mode. The **Mode-shift button** also
supports it once raw-XY capability is measured. A gesture binding uses `Up` /
`Down` / `Left` / `Right` / `Click` instead of one action, and several controls
can use gesture mode at once. One Gesture Button hold can chain several swipes.
The thumb wheel does not support gesture mode. See
[Remap buttons](/docs/features/mouse/remap-buttons).

**Back** and **Forward** can also use
[Hold to scroll sideways](/docs/features/mouse/scrolling#hold-to-scroll-sideways)
when the firmware reports raw wheel input for that button. The **Buttons** tab
shows the switch only on a unit that reports it.

The Haptic Sense panel is the one control this mouse has and its predecessors
don't. It is an ordinary divertable control in the reprogrammable-controls
table, so it binds like any button. The haptics themselves come from
[`0x19b0`](/hidpp/features/x19b0-haptic-feedback).

## Settings this mouse shows [#settings-this-mouse-shows]

Settings are gated on the feature ids the device reports, never on its marketing
type, so this list is what an MX Master 4 normally answers with:

| Setting                                                  | Feature                                                                                                                                            | Page                                          |
| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
| DPI presets                                              | `0x2201` AdjustableDpi, or `0x2202` extended                                                                                                       | [DPI](/docs/features/mouse/dpi)               |
| SmartShift                                               | [`0x2111`](/hidpp/features/x2111-smartshift-enhanced) SmartShiftEnhanced                                                                           | [SmartShift](/docs/features/mouse/smartshift) |
| Scroll inversion, wheel resolution, and **Smooth wheel** | [`0x2121`](/hidpp/features/x2121-hires-wheel) HiResWheel                                                                                           | [Scrolling](/docs/features/mouse/scrolling)   |
| Thumb wheel                                              | [`0x2150`](/hidpp/features/x2150-thumbwheel) Thumbwheel                                                                                            | [Scrolling](/docs/features/mouse/scrolling)   |
| Haptic feedback                                          | [`0x19b0`](/hidpp/features/x19b0-haptic-feedback) HapticFeedback                                                                                   | [Actions Ring](/docs/features/actions-ring)   |
| Battery                                                  | [`0x1004`](/hidpp/features/x1004-unified-battery) UnifiedBattery                                                                                   | —                                             |
| Easy-Switch                                              | [`0x1814`](/hidpp/features/x1814-change-host) ChangeHost; [`0x1815`](/hidpp/features/x1815-hosts-info) HostsInfo for channel details when reported | [Easy-Switch](/docs/features/easy-switch)     |

**Scrolling force** on the SmartShift card appears only when the unit's `0x2111`
reports tunable torque. A setting that doesn't appear means the unit didn't
report its feature; the authority is `openlogi diag features`, not this table.

## Configure it [#configure-it]

Device blocks are keyed by the **physical device**, not by model — two MX Master
4s on one machine never share an entry. Configure the mouse once in the app,
then copy its generated key from `config.toml`. A known `unit:…` or `serial:…`
identity follows the device across transports; receiver and direct routes are
stored as links beneath that identity. The key below is illustrative, not a
model-wide identifier.

```toml
schema_version = 9

[devices."unit:6be9d300"]
dpi = 1600
dpi_presets = [800, 1600, 3200]
invert_scroll = false
smooth_wheel = true

[devices."unit:6be9d300".smartshift]
mode = "ratchet"
auto_disengage = 20

[devices."unit:6be9d300".bindings]
DpiToggle = { SetDpiPreset = 2 }
HapticPanel = "ShowActionsRing"
Back = { CustomShortcut = "Cmd+Shift+P" }

[devices."unit:6be9d300".bindings.GestureButton]
Up = "MissionControl"
Down = "ShowDesktop"
Left = "PrevTab"
Right = "NextTab"
Click = "AppExpose"

[devices."unit:6be9d300".bindings.MiddleClick]
Up = "VolumeUp"
Down = "VolumeDown"
Click = "MiddleClick"
```

Every key above, and the full action list, is in
[Configuration](/docs/configurations).

## Identity [#identity]

The registry's `2b042` is not what the firmware reports: an MX Master 4 answers
`0x0003 deviceInformation` with `ext=01` and PID `b042`, so it is the trailing
four digits that line up, not the prefix — OpenLogi matches on them, and
[Model IDs](/hidpp/model-ids) explains why. `openlogi list` prints the raw pair
under the paired device.


# Actions Ring (/docs/features/actions-ring)



The **Actions Ring** is an eight-slot launcher that opens at the cursor.
Open it from the device detail screen's **Actions Ring** tab.

The tab appears on a pointer device with buttons, a device with a haptic panel,
or a keyboard with **Keys**. A keyboard with **Keys** can bind a key to **Show
Actions Ring**. The MX Master 4's **Haptic Sense Panel** is also a supported
control. On a new device, the panel is bound to **Show Actions Ring** by default.

## Open the ring [#open-the-ring]

Press a control bound to **Show Actions Ring** to open the ring.
Press the trigger again to dismiss it. Click a populated slot to run its action.

Click the centre × to dismiss the ring. On macOS, clicking outside the ring also
dismisses it. The ring closes after 15 seconds.

## Configure a ring [#configure-a-ring]

<Steps>
  <Step>
    ### Select the device [#select-the-device]

    Select the device, then open **Actions Ring** from its detail screen.
  </Step>

  <Step>
    ### Choose a layout [#choose-a-layout]

    Use **Profile** to select **Default** or an application.
  </Step>

  <Step>
    ### Edit the slots [#edit-the-slots]

    Select a slot, then choose its action, icon, and optional label.
  </Step>

  <Step>
    ### Bind the trigger [#bind-the-trigger]

    Assign **Show Actions Ring** to a supported mouse control or keyboard key.
  </Step>
</Steps>

## Slots [#slots]

Each populated slot stores one action, an optional custom icon, and an optional
literal label. Select **Clear slot** to remove the whole entry. An omitted entry
is an empty slot.

The editor rejects **Show Actions Ring** and `None` inside a slot. The first
would recursively open a ring. The second does not represent an empty slot.

| Position      | Default action   |
| ------------- | ---------------- |
| `Top`         | `Cut`            |
| `TopRight`    | `Copy`           |
| `Right`       | `Paste`          |
| `BottomRight` | `BrowserForward` |
| `Bottom`      | `PlayPause`      |
| `BottomLeft`  | `BrowserBack`    |
| `Left`        | `Undo`           |
| `TopLeft`     | `Redo`           |

Use **Icon** to keep the action-derived icon with **Use action icon** or select
a custom icon. A custom label renders exactly as written when you hover its
slot.

The additional general-purpose icons are:

* **Folder**, **File**, **Globe**, and **Terminal**
* **Star**, **Heart**, **Calendar**, and **Bell**
* **User**, **Palette**, and **Book**

The **Haptic feedback** switch appears only when the device reports haptic
support. When enabled, OpenLogi requests feedback when you hover or activate a
populated slot.

The editor also offers these **Power User** actions:

* **Type Text**
* **Run AppleScript** on macOS
* **Run Shell Command**

**Workflow** is not available in the Actions Ring editor. It remains available
in the keyboard editor.

## Per-app layouts [#per-app-layouts]

Use **Profile** to edit the **Default** layout or an application layout.
An application without a saved layout displays the default layout. Its first
slot edit copies that layout into an application-specific layout.

The foreground application selects the layout at runtime. The application chosen
in the editor does not change the foreground application. Removing an Actions
Ring application layout does not remove that application's button overrides.

<Callout type="info">
  An application layout replaces the default layout. List every slot that the
  application needs; omitted slots are empty.
</Callout>

## In the config [#in-the-config]

Actions Ring settings live under `[devices.<key>.action_ring]` in
[`config.toml`](/docs/configurations). `action_ring.per_app` accepts the same app
selectors as button profiles, including Windows `exe:<name>.exe` and
`app:<alias>` keys. See [Per-app profiles](/docs/features/profiles) for selector
behavior and [Configuration](/docs/configurations) for the complete reference.

```toml
schema_version = 9

[devices."unit:6be9d300".action_ring]
enabled = true
haptics = true

[devices."unit:6be9d300".action_ring.default.slots]
Top = { action = "Copy" }
Right = { action = "Paste", icon = "Keyboard", label = "Paste plain text" }

# This layout replaces the default. Unlisted slots remain empty.
[devices."unit:6be9d300".action_ring.per_app."com.google.Chrome".slots]
Top = { action = "Paste" }
```

## Platform behavior [#platform-behavior]

OpenLogi opens the ring on the display containing the cursor. On Wayland, it
creates one display-covering window per display and draws the panel at the
pointer. Windows calculates ring placement correctly on mixed-DPI displays.

**Status:** macOS, Linux, and Windows. Haptic feedback requires a device with
[`0x19b0` hapticFeedback](/hidpp/features/x19b0-haptic-feedback). Outside-click
dismissal is available on macOS only.


# Easy-Switch (/docs/features/easy-switch)



Easy-Switch devices store paired computers in numbered channels. Select the device, then open its **Device** tab. The **Easy-Switch** card reads the device’s HID++ `0x1815` host information.

OpenLogi v0.8.14 added this card. It appears only when the device reports Easy-Switch channels.

## Channel details [#channel-details]

Each row represents one channel reported by the device. It identifies the channel, and the current channel adds **This computer**.

| Channel state    | Card contents                                                            |
| ---------------- | ------------------------------------------------------------------------ |
| Current channel  | **This computer**, plus the paired-computer details when available       |
| Paired channel   | Computer name, or **Unnamed computer**; operating system; and connection |
| Unpaired channel | **No computer paired**                                                   |

The connection can be **Bluetooth**, **Unifying receiver**, **Bolt receiver**, or **USB**. The device supplies the name, operating system, connection, and channel state.

## Rename a paired computer [#rename-a-paired-computer]

The card shows **Rename** only for a paired channel when the device permits software renaming. The dialog pre-fills a reported name and shows the **Computer name** field.

OpenLogi trims surrounding whitespace. A name cannot be empty. Its UTF-8 byte length must not exceed that channel’s `name_max_len`.

The limit counts bytes, not characters. The device stores an accepted name. A device that does not permit software renaming has no **Rename** control.

The card can show these messages:

| Situation                                     | Message                                                          |
| --------------------------------------------- | ---------------------------------------------------------------- |
| Empty name                                    | `Enter a name.`                                                  |
| Name exceeds the byte limit                   | `Too long: %{used} of %{max} bytes.`                             |
| The device does not return the requested name | `The device didn't keep the new name for channel %{channel}.`    |
| Another rename failure                        | `Couldn't rename the computer on channel %{channel}. Try again.` |

OpenLogi never writes this computer’s name or operating system to a channel on its own. This differs from Logi Options+, which writes those values to the current channel.

## Use the Automation API [#use-the-automation-api]

Use `openlogi api easy-switch` to read or rename channels:

```sh
openlogi api easy-switch --device "$DEVICE_ID"
openlogi api easy-switch --device "$DEVICE_ID" --channel 2 --name "Work laptop"
```

Both commands need a running, protocol-compatible OpenLogi agent. The read returns channels numbered from 1, the current channel when reported, the name, operating system, connection, and `name_max_len`.

A rename needs both `--channel` and `--name`. The CLI trims surrounding whitespace, never writes `config.toml`, and rejects `--save`.

## Make targets follow a keyboard [#make-targets-follow-a-keyboard]

Set `host_switch_targets` in [`config.toml`](/docs/configurations). OpenLogi v0.8.15 has no GUI editor for this relationship.

Place physical device keys in the keyboard’s entry. Configure every computer where that keyboard can initiate a switch.

```toml
schema_version = 9

[devices."receiver:aabbccdd:slot:2"]
host_switch_targets = ["receiver:aabbccdd:slot:1"]
```

When the configured keyboard is online, the agent observes its available host controls. After a host-key press, it validates the keyboard’s requested channel, attempts targets in list order, and switches the keyboard last.

A target that is unavailable or cannot change hosts does not stop the keyboard switch. Every device that must move needs `0x1814` changeHost. When available, the agent uses `0x1815` hostsInfo to reject a channel it explicitly reports as empty.

**Status:** Available on macOS, Linux, and Windows for compatible HID++ devices. Channel reading and renaming use [`0x1815` hostsInfo](/hidpp/features/x1815-hosts-info). Host switching uses [`0x1814` changeHost](/hidpp/features/x1814-change-host).


# Features (/docs/features)



Select a device on the Home screen to open its detail view. OpenLogi shows only
the tabs that the device supports, based on the features the device reports.

## Find the guide for a tab [#find-the-guide-for-a-tab]

| Tab              | Shown for                                                                   | Guides                                                                                                                                                                       |
| ---------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Buttons**      | Mice and trackballs with reprogrammable buttons                             | [Remap buttons](/docs/features/mouse/remap-buttons), [Per-app profiles](/docs/features/profiles)                                                                             |
| **Keys**         | Keyboards with HID++ controls or a device image                             | [Keyboards](/docs/features/keyboard)                                                                                                                                         |
| **Actions Ring** | Mice with buttons or a haptic panel, and keyboards with a **Keys** tab      | [Actions Ring](/docs/features/actions-ring)                                                                                                                                  |
| **Pointer**      | Devices with a pointer sensor, scroll inversion, or a high-resolution wheel | [DPI](/docs/features/mouse/dpi), [Report rate](/docs/features/mouse/report-rate), [Scrolling](/docs/features/mouse/scrolling), [SmartShift](/docs/features/mouse/smartshift) |
| **Lighting**     | Keyboards with RGB lighting, and Litra lights                               | [Keyboard lighting](/docs/features/keyboard/lighting), [Litra lights](/docs/features/lights)                                                                                 |
| **Camera**       | Logitech webcams                                                            | [Webcams](/docs/features/webcams)                                                                                                                                            |
| **Device**       | Every device                                                                | [Easy-Switch](/docs/features/easy-switch), [Keyboards](/docs/features/keyboard#device-settings), [Keyboard lighting](/docs/features/keyboard/lighting)                       |

App-wide options, such as launch at login, scroll sensitivity, notices, language,
and updates, are in the Settings window. See [Settings and updates](/docs/settings).

## Buttons and automation [#buttons-and-automation]

<Cards>
  <Card title="Remap buttons" description="Browse the action catalog, configure gestures, and learn how buttons are captured." href="/docs/features/mouse/remap-buttons" />

  <Card title="Actions Ring" description="Configure the eight-slot radial launcher for mice and keyboards." href="/docs/features/actions-ring" />

  <Card title="Per-app profiles" description="Override button actions per application, and share profiles across platforms." href="/docs/features/profiles" />
</Cards>

## Mouse [#mouse]

<Cards>
  <Card title="DPI presets" description="Set pointer resolution and cycle through an ordered preset list." href="/docs/features/mouse/dpi" />

  <Card title="Report rate" description="Choose how often the mouse reports to the computer on its current connection." href="/docs/features/mouse/report-rate" />

  <Card title="Scrolling" description="Configure sensitivity, inversion, Smooth wheel, the thumb wheel, and hold to scroll sideways." href="/docs/features/mouse/scrolling" />

  <Card title="SmartShift" description="Switch the main wheel between ratchet and free spin, and set the scrolling force." href="/docs/features/mouse/smartshift" />
</Cards>

## Keyboards and other devices [#keyboards-and-other-devices]

<Cards>
  <Card title="Keyboards" description="Bind HID++ keys, remap function keys, and set Fn Lock and Keep OS layout." href="/docs/features/keyboard" />

  <Card title="Keyboard lighting" description="Control the keyboard backlight and static RGB color." href="/docs/features/keyboard/lighting" />

  <Card title="Easy-Switch" description="See and rename the computer on each channel, and link host switching across devices." href="/docs/features/easy-switch" />

  <Card title="Litra lights" description="Control power, brightness, color temperature, and camera-linked auto-power." href="/docs/features/lights" />

  <Card title="Webcams" description="Preview a Logitech webcam and adjust its UVC image controls and profiles." href="/docs/features/webcams" />

  <Card title="On-screen notices" description="Low-battery notices, lock-key indicators, and the backlight level indicator." href="/docs/features/notifications" />
</Cards>

## Reference [#reference]

<Cards>
  <Card title="Configuration" description="Read the complete config.toml reference." href="/docs/configurations" />

  <Card title="Troubleshooting" description="Fix permission, device access, and configuration problems." href="/docs/troubleshooting" />
</Cards>


# Litra lights (/docs/features/lights)



OpenLogi controls Litra Glow and Litra Beam from the **Lighting** tab. The tab
appears when the device exposes standalone-light capabilities.

## Supported models and connection paths [#supported-models-and-connection-paths]

| Model      | Registered raw-HID identity |
| ---------- | --------------------------- |
| Litra Glow | `046d:c900` / `ff43:0202`   |
| Litra Beam | `046d:c901` / `ff43:0202`   |

OpenLogi selects the connection path per HID node, not per model. For either
model, it first resolves HID++ `0x1990` on the light's node. If the light
responds, OpenLogi reads its state and reads each written control back.

If `0x1990` does not respond, OpenLogi uses the fixed raw-HID report instead.
That fallback has no read-back and cannot report changes made on the light.

## Lighting controls [#lighting-controls]

Select the light in the device gallery and open its **Lighting** tab. The
controls follow the light's advertised capabilities:

* The on/off switch controls the light's power.
* **Brightness** uses lumens: Glow supports `20–250 lm`, and Beam supports
  `30–400 lm`. Both use `1 lm` steps.
* **Color temperature** supports `2700–6500 K` in `100 K` steps.
* **Auto-on with camera** is available on macOS. It turns the light on while
  any [camera](/docs/features/webcams) is in use and off when every camera is
  inactive.

## Saved settings and changes on the light [#saved-settings-and-changes-on-the-light]

The agent reapplies saved `light` settings when the light reconnects. It also
applies them after a save and at camera-use transitions when camera automation
is on.

* On a HID++ path, a change from the light's buttons or another program is
  published by the agent. The desktop app saves the reported value as the
  setting for that control.
* An unrelated save does not overwrite a control changed on the light. A later
  OpenLogi save or write to that same control replaces the external change.
* A raw-HID-only light cannot report an external change. Its saved settings
  therefore apply again on reconnect.
* With **Auto-on with camera** enabled, a power change on the light is a manual
  choice until the next camera-use transition.

## In the config [#in-the-config]

OpenLogi stores brightness as a percentage, even though the **Brightness**
slider uses lumens. Add the settings under the light's physical key; see the
full [configuration reference](/docs/configurations) for every field.

```toml
schema_version = 9

[devices."raw:046d:c900:ff43:0202:serial:YOUR-SERIAL".light]
enabled = true
auto_camera = true
brightness_percent = 65
temperature_kelvin = 4600
```

<Callout type="info">
  OpenLogi needs a persistent identity to save a light's settings. If the system
  exposes only a transient node identity, the settings last for the current session.
</Callout>

**Status:** macOS, Linux, and Windows support Litra controls. **Auto-on with
camera** is macOS-only. HID++ details: [`0x1990` illumination](/hidpp/features/x1990-illumination).


# On-screen notices (/docs/features/notifications)



OpenLogi draws short-lived notices in its own overlay. The notices do not use the operating system notification service.

## Controls and configuration [#controls-and-configuration]

Open **Settings → General** to control the two configurable notices:

* **Low battery notifications** controls the low-battery card. It is on by default.
* **Lock key notifications** controls the lock-key indicator. It is on by default.

The keyboard-backlight indicator has no Settings switch or app configuration key. It appears when a compatible keyboard reports a backlight-level change.

Use `low_battery_notifications` and `lock_key_notifications` under `[app_settings]` to set the two switches in the configuration file.

## Low battery [#low-battery]

OpenLogi shows a low-battery card when a connected device meets either condition:

* Its battery reports `20%` or less and is not charging.
* Its battery has no percentage and reports a low or critical level.

The card identifies the device and includes the percentage when the device reports one. It stays visible for 8 seconds, and clicking it hides it. The overlay shows at most three cards at once; newer cards take precedence.

OpenLogi announces each discharge once. It re-arms the device after it begins charging, reports `30%` or more, or reports a good or full level. When you turn the switch off, OpenLogi still records a recharge, so a later discharge can be announced after you enable it again.

This is an OpenLogi overlay window, not an OS notification. It does not need notification permission from the operating system.

## Lock keys [#lock-keys]

A keyboard with [`0x4220` LockKeyState](/hidpp/features/x4220-lock-key-state) can report lock-key changes. OpenLogi shows these changes only when **Lock key notifications** is on:

* **Caps Lock** shows on macOS, Linux, and Windows.
* **Num Lock** and **Scroll Lock** show on Linux and Windows.
* **Fn Lock** never shows.

OpenLogi does not announce the state that the keyboard reports at connection. A later change opens a centred indicator for 1.5 seconds. A new change replaces the indicator already on screen.

The indicator is a dark card with the lock-key state as text. Its key glyph is blue while the key is on and gray while the key is off.

## Keyboard backlight level [#keyboard-backlight-level]

On a keyboard that exposes [`0x1982` backlight](/hidpp/features/x1982-backlight), OpenLogi follows hardware backlight-change events. After a hardware adjustment, it shows a centred indicator for 1.5 seconds when the reported backlight is lit.

The indicator shows **Backlight**, the current level, and the reported maximum. A blue progress bar represents the current level. A newer backlight event replaces the current indicator and starts a new 1.5-second interval.

No setting controls this indicator. A software-disabled, critically-low-battery, or ambient-light-saturated backlight closes the indicator. A keyboard without the backlight feature does not send the event, so OpenLogi shows no backlight-level indicator for it.

## Screen placement [#screen-placement]

Outside Wayland, low-battery cards use the display under the pointer when its location is available. Otherwise, OpenLogi uses the primary display. On macOS, the card stays above the Dock. On Windows, it stays above the taskbar.

On Wayland, OpenLogi first requests a bottom-edge layer-shell surface. If the compositor does not provide layer-shell support, OpenLogi opens a normal window and the compositor chooses its placement.

The lock-key and backlight indicators are centred popup windows. Their code does not select a display explicitly, so OpenLogi does not guarantee their multi-monitor placement.

## In the config [#in-the-config]

```toml
schema_version = 9

[app_settings]
low_battery_notifications = true
lock_key_notifications = true
```

For the complete configuration reference, see [Configuration](/docs/configurations).

**Status:** macOS + Linux + Windows. Lock-key notices require [`0x4220` LockKeyState](/hidpp/features/x4220-lock-key-state). Backlight-level notices require [`0x1982` backlight](/hidpp/features/x1982-backlight).


# Per-app profiles (/docs/features/profiles)



Per-app profiles change a device automatically when an application matches. Edit them in **Buttons** or **Actions Ring**.

A profile can override button actions and select an Actions Ring layout. OpenLogi matches the target application by its platform identifier.

## What a profile changes [#what-a-profile-changes]

A button profile is a sparse overlay on the device's **Default profile**. An Actions Ring profile is a complete layout for the focused application.

* **Buttons** — each listed button replaces the default with one action. Unlisted buttons inherit the **Default profile**. Gestures remain in the **Default profile**.
* **Actions Ring** — a matching focused application uses its complete saved layout. It does not merge with the default layout.

A button override can replace a gesture-capable button with one action. Clear that override to restore the default gesture map.

## Add and manage a profile [#add-and-manage-a-profile]

Select a device, open **Buttons** or **Actions Ring**, and use the **Profile** selector. The two editors have independent profiles for the same application.

1. Select **Add app**.
2. Choose an application from **Recent applications** or **All applications**.
3. Change a button or an Actions Ring slot to save a profile.

**Recent applications** contains foreground applications that the agent observed. Choose it when possible, because its identifier is the runtime identifier.

**All applications** is an installed-application catalog. Its selected identifier is platform-specific, so it might not equal an application's runtime identifier.

Selecting an application creates an editor draft, not an empty entry in `config.toml`. Switching profiles in the editor never forces the runtime to use that profile.

Use **Profile options** to change the selected profile:

* **Reset to default…** removes custom settings in the current editor. The editor remains on that application. It does not change the other editor or another application.
* **Remove profile…** removes the current editor's profile and returns that editor to **Default**. It does not change the other editor or another application.
* **Delete all app settings…** removes that application's button bindings and Actions Ring layout on this device. Other applications, devices, and default settings remain unchanged.

## Choose the mouse target [#choose-the-mouse-target]

In Settings, set **Mouse button profiles** to **Mouse pointer** or **Focused application**. The setting also persists in [`config.toml`](/docs/configurations).

```toml
schema_version = 9

[app_settings]
mouse_profile_target = "pointer" # Or "focused".
```

* **Mouse pointer** is the default. Mouse profiles follow the identified window under the pointer. The desktop uses global bindings.
* **Focused application** makes mouse profiles follow keyboard focus. Keyboard profiles always follow keyboard focus.

Pointer targeting works on macOS, Windows, and X11. On unsupported sessions, OpenLogi uses the focused application.

If OpenLogi cannot identify the pointer surface, it also uses the focused application. This includes a system overlay or a failed lookup. With no focused application, OpenLogi uses global bindings.

OpenLogi never focuses a background window to send input. In pointer mode, bindings that produce keystrokes or run workflows are skipped for an unfocused hovered window.

The skipped binding does not run in the focused application. Global actions, such as switching desktops, can run without changing focus.

The **Actions Ring** always selects the foreground application. It does not use the pointer target.

## Identifiers and Linux backends [#identifiers-and-linux-backends]

Profile keys match the reported identifier exactly. OpenLogi does not support wildcards or pattern matching.

| Platform                                               | Reported identifier                                               | Example                |
| ------------------------------------------------------ | ----------------------------------------------------------------- | ---------------------- |
| macOS                                                  | Bundle identifier, or executable path without a bundle identifier | `com.microsoft.VSCode` |
| Linux (X11 / XWayland, GNOME Shell)                    | `WM_CLASS` class                                                  | `Code`                 |
| Linux (wlroots compositor, including Sway or Hyprland) | xdg-shell `app_id`                                                | `code`                 |
| Windows                                                | Lower-cased executable path, or `exe:<filename>.exe`              | `exe:sharex.exe`       |

On Windows, an exact executable path wins over `exe:<filename>.exe`. Use the `exe:` form when a Store or self-updating application's path changes.

<Callout type="warning">
  Linux `WM_CLASS` and xdg-shell `app_id` identifiers are separate namespaces. A wlroots profile does not match on GNOME or X11, and vice versa.
</Callout>

GNOME Wayland requires the installed and enabled OpenLogi GNOME Shell extension for native windows. Without it, the X11 backend matches only XWayland windows.

KDE KWin does not expose the wlroots protocol. In v0.8.15, native KDE Wayland windows have no dedicated foreground-application backend.

## Share a profile across platforms [#share-a-profile-across-platforms]

Use an `[apps]` alias when one profile must match the same application on macOS, Linux, and Windows. Reference the alias with an `app:<alias>` profile key.

```toml
schema_version = 9

[apps.chrome]
name = "Google Chrome"
macos = "com.google.Chrome"
linux = ["google-chrome", "Google-chrome"]
windows = "exe:chrome.exe"

[devices."unit:6be9d300".per_app_bindings."app:chrome"]
Back = "PrevTab"
```

Each `macos`, `linux`, or `windows` field accepts one identifier or an array. An alias without an identifier for the current platform remains in the file but does not apply there.

The same selector works in `per_app_bindings` and `action_ring.per_app`. OpenLogi uses this precedence order:

1. Exact identifier.
2. Windows `exe:<filename>.exe` selector.
3. Alias that lists the exact identifier.
4. Alias that lists the `exe:<filename>.exe` selector.

A platform-specific profile therefore wins over an alias profile. Config loading stops when an `app:` profile names no alias.

Config loading also stops for empty identifiers, an identifier beginning with `app:`, duplicate identifiers on one platform, or upper-case Windows identifiers.

The GUI shows alias profiles but does not create aliases. It uses the configured `name`, an agent-reported name, or the alias name. It uses the first identifier's platform icon. Edit [`config.toml`](/docs/configurations) to create or change aliases.

**Status:** button profiles and Actions Ring layouts work on macOS, Linux, and Windows. Pointer targeting works on macOS, Windows, and X11. Application identification uses operating-system APIs, not HID++.


# Webcams (/docs/features/webcams)



OpenLogi recognises Logitech USB Video Class (UVC) cameras and exposes their device controls in the **Camera** tab. It detects Logitech's USB vendor ID, `0x046d`, rather than using a model list.

Most cameras use their USB product string as a name. OpenLogi supplies a registry name when a known camera reports no USB product string.

## Camera controls [#camera-controls]

Select a camera and open **Camera**. The **Camera controls** panel shows only controls that the connected camera reports. Each range and default comes from that camera.

| Control                    | UVC control                            | Auto toggle                                           | Notes                                                                                                |
| -------------------------- | -------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| **Zoom**                   | `CT_ZOOM_ABSOLUTE_CONTROL`             | /                                                     | Lens control.                                                                                        |
| **Pan**                    | `CT_PANTILT_ABSOLUTE_CONTROL`          | /                                                     | Shares one selector with **Tilt**.                                                                   |
| **Tilt**                   | `CT_PANTILT_ABSOLUTE_CONTROL`          | /                                                     | Shares one selector with **Pan**.                                                                    |
| **Focus**                  | `CT_FOCUS_ABSOLUTE_CONTROL`            | **Auto**: `CT_FOCUS_AUTO_CONTROL`                     | Moving the slider changes this control to manual.                                                    |
| **Exposure**               | `CT_EXPOSURE_TIME_ABSOLUTE_CONTROL`    | **Auto**: `CT_AE_MODE_CONTROL`                        | Moving the slider changes this control to manual.                                                    |
| **Anti-flicker**           | `PU_POWER_LINE_FREQUENCY_CONTROL`      | /                                                     | Devices that support its discrete values show 50 Hz, 60 Hz, or **Auto**. Windows does not expose it. |
| **Low light compensation** | `CT_AE_PRIORITY_CONTROL`               | /                                                     | A `0`–`1` range displays a binary switch. Windows does not expose it.                                |
| **Brightness**             | `PU_BRIGHTNESS_CONTROL`                | /                                                     | Image control.                                                                                       |
| **Contrast**               | `PU_CONTRAST_CONTROL`                  | /                                                     | Image control.                                                                                       |
| **Saturation**             | `PU_SATURATION_CONTROL`                | /                                                     | Image control.                                                                                       |
| **Sharpness**              | `PU_SHARPNESS_CONTROL`                 | /                                                     | Image control.                                                                                       |
| **Gain**                   | `PU_GAIN_CONTROL`                      | /                                                     | Image control.                                                                                       |
| **Backlight compensation** | `PU_BACKLIGHT_COMPENSATION_CONTROL`    | /                                                     | Image control.                                                                                       |
| **White balance**          | `PU_WHITE_BALANCE_TEMPERATURE_CONTROL` | **Auto**: `PU_WHITE_BALANCE_TEMPERATURE_AUTO_CONTROL` | Moving the slider changes this control to manual.                                                    |
| **Tint**                   | `PU_HUE_CONTROL`                       | /                                                     | Linux has no standard V4L2 equivalent.                                                               |

Changes write to the camera's UVC controls. Other applications that open the camera can observe the same image settings.

### Set an exact value [#set-an-exact-value]

Click the numeric value on the right side of a slider. OpenLogi opens a dialog such as **Set Brightness** and shows the camera's **Supported range**.

Enter a value that the camera reports as supported. A value outside the range, or outside a discrete supported set, stays in the dialog without changing the camera.

### Profiles and reset [#profiles-and-reset]

**Default** restores each shown control and auto mode to the camera-reported default. **Streaming** and **Video call** set range-relative brightness, contrast, saturation, and sharpness values.

The **Streaming** and **Video call** profiles enable every auto mode the camera reports. You can save the current control and auto-mode values as a custom profile; the most recently applied profile stays selected.

Use **Reset to defaults** to restore all shown controls and auto modes in one operation. Double-click a control row to reset that row to its device default.

## Preview and platform behavior [#preview-and-platform-behavior]

The live preview runs only while the **Camera** tab is visible. Leaving the tab, returning Home, or selecting another device closes the stream.

If the stream cannot start, the preview shows an error and retries every 2 seconds while the tab remains visible. A resource error can mean another application uses the camera, but it does not prove that cause.

Live preview works on macOS, Windows, and Linux. On other platforms, OpenLogi shows: “Live preview isn't available on this platform yet.”

On macOS, select &#x2A;*Click to enable camera access.** in the preview, then allow **OpenLogi** to use the camera. OpenLogi may not appear under System Settings → Privacy & Security → Camera until it has requested access.

Camera permission controls the preview. Reading and writing UVC controls uses a separate USB path and does not require that permission.

### Linux and Windows [#linux-and-windows]

Linux uses V4L2 through the kernel's `uvcvideo` driver. Access depends on permission for the camera's `/dev/video*` node, so Linux has no per-application camera prompt.

A Logitech Brio can expose both colour and infrared capture nodes on Linux. OpenLogi combines those nodes into one camera entry and selects the colour node.

Windows maps lens and image controls through DirectShow. It does not expose **Anti-flicker** or **Low light compensation**.

## Names and saved settings [#names-and-saved-settings]

A camera with a USB serial number uses a serial-based device key for saved camera controls and profiles. A camera without a USB serial number has no unique port-stable identity.

For a serial-less camera, `custom_name` follows the OS capture ID. Moving that camera to another USB port can require naming it again.

## In the config [#in-the-config]

OpenLogi saves controls and custom profiles under the camera's physical device key. This example uses a camera that reports USB serial `1234abcd`.

```toml
[devices."camera:046d:0893:serial:1234abcd".camera_controls]
brightness = 128
focus_auto = 1

[devices."camera:046d:0893:serial:1234abcd".camera_profiles."Custom 1"]
brightness = 140
focus_auto = 0
```

For the complete schema and the device key that OpenLogi generated, see [Configuration](/docs/configurations).

**Status:** Camera controls and live preview work on macOS, Windows, and Linux. Other platforms do not have a camera capture or UVC-control backend.


# Architecture (/docs/project/architecture)



OpenLogi separates one authoritative **background agent** from two GPUI clients.
The agent owns HID++ inventory and channels, input capture, binding resolution,
device writes, and action execution. The settings app mirrors agent snapshots
and sends commands. The overlay renders the Actions Ring and the on-screen
notices, and reports ring interactions back. `openlogi list` prefers a
compatible agent snapshot and falls back to direct enumeration. `openlogi api`
uses agent IPC exclusively; hardware-diagnostic CLI commands open devices
directly.

## Components [#components]

* **OpenLogi agent** (`openlogi-agent`) — the background process: HID++ device
  I/O, input hooks and capture sessions, per-app watching, pairing, action
  dispatch, low-battery and lock-key events, the menu-bar / tray item, and
  supervision of the overlay helper.
* **OpenLogi desktop** (`openlogi-desktop`) — the GPUI settings app. Its local
  state is a presentation and editing mirror; HID++ reads and writes go through
  the agent. Webcam preview and UVC controls remain direct desktop operations.
  The desktop also runs the app's update check: Sparkle on macOS and the MSI
  updater on Windows.
* **Overlay** (`openlogi-overlay`) — a warm, separately supervised GPUI process.
  It draws the Actions Ring from presentation-only snapshots and returns hover,
  activation, and cancellation events; the agent retains and executes actions.
  It also draws the notices and indicators the agent marks as current: the
  low-battery notice, the lock-key indicator, and the keyboard backlight level.
  See [On-screen notices](/docs/features/notifications).
* **OpenLogi CLI** (`openlogi`) — headless inventory, asset sync, light and
  camera control, fixture capture and verification, and HID++ diagnostics.
  Inventory can use agent IPC; `openlogi api` provides JSON output and device
  control through the agent. Hardware diagnostics remain direct operations.
  See the [CLI guide](/docs/cli).
* **[assets.openlogi.org](https://assets.openlogi.org)** — a static host serving
  per-device renders and clickable-hotspot metadata, keyed by each device's
  `modelId`; a versioned Cloudflare Pages origin and jsDelivr npm shards are
  raced as mirrors.

## Component relationships [#component-relationships]

### Processes and local state [#processes-and-local-state]

`openlogi-ipc` is a shared contract and transport library, not another process.
The desktop and overlay connect to the same endpoint. Each client uses the
shared version handshake; incompatible clients do not exchange runtime data.

```text
openlogi-desktop ── snapshots + commands ──┐
openlogi-overlay ── ring + notices ────────┼── openlogi-ipc ── openlogi-agent
openlogi list ───── inventory snapshot ────┤                        │
openlogi api ────── status + control ──────┘                        │
                                                                  ├── HID I/O
openlogi diagnostics ─────────────────────── direct HID I/O         └── OS input
```

The agent supervises the separate overlay. The desktop writes `config.toml`
and requests a validated reload. For UI development, `openlogi-agent-mock`
can replace the real agent on the development socket.

### Runtime and I/O boundaries [#runtime-and-io-boundaries]

The agent is authoritative for HID++ and native input capture. The desktop only
opens UVC cameras directly; both the desktop and CLI can populate the verified
asset cache.

`openlogi-device` implements discovery, probing, sessions, pairing, and device
writes against the `HidBackend` interface. It does not open host devices itself.
`openlogi-hid` supplies the real `async-hid` transport, permissions, and probe
cache. A recording or replay backend can exercise the same device layer without
changing its protocol logic. Native input capture and synthesis stay in
`openlogi-hook` and `openlogi-inject` respectively.

## Crates [#crates]

| Crate                       | Role                                                                                                                                                                      |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `openlogi-core`             | Leaf-level types, TOML config, paths, device models, the button / action catalog, and locale negotiation                                                                  |
| `openlogi-hidpp-derive`     | Private proc macros that generate HID++ feature boilerplate                                                                                                               |
| `openlogi-hidpp`            | Workspace hard fork of the HID++ protocol crate (lib name `hidpp`): channels, features, receivers                                                                         |
| `openlogi-device-registry`  | Pure receiver-protocol and standalone-device identity metadata                                                                                                            |
| `openlogi-fixture`          | Host-free fixture schemas, synthetic identities, and privacy / relationship verification                                                                                  |
| `openlogi-device`           | Backend-independent enumeration, probing, sessions, pairing, and device writes                                                                                            |
| `openlogi-hid`              | Host HID transport, permission integration, and on-disk probe cache                                                                                                       |
| `openlogi-hook`             | OS input hook and frontmost-app lookup: macOS CGEventTap; Linux evdev/uinput with X11, wlroots, and GNOME Shell window lookup; Windows `WH_MOUSE_LL` and `WH_KEYBOARD_LL` |
| `openlogi-inject`           | OS event synthesis: macOS CGEvent; Linux uinput, MPRIS, KWin and GNOME D-Bus calls, and Hyprland dispatch; Windows `SendInput`                                            |
| `openlogi-camera`           | UVC webcam discovery, capture, and image controls                                                                                                                         |
| `openlogi-permissions`      | Read-only permission status and System Settings links; camera permission APIs live in `openlogi-camera`                                                                   |
| `openlogi-assets`           | Device-render registry schema and cached mirror fetches                                                                                                                   |
| `openlogi-ipc`              | The tarpc service contract and bincode transport over Unix sockets or Windows named pipes                                                                                 |
| `openlogi-agent-core`       | Headless orchestration, capture planning, hardware operations, action dispatch, and Actions Ring sessions                                                                 |
| `openlogi-ui`               | Shared GPUI action icons, colors, and locale catalogs                                                                                                                     |
| `openlogi-cli` / `openlogi` | CLI implementation library and its thin binary wrapper                                                                                                                    |
| `openlogi-agent`            | The real agent and `openlogi-agent-mock` binary targets                                                                                                                   |
| `openlogi-desktop`          | GPUI settings application and agent IPC client                                                                                                                            |
| `openlogi-overlay`          | Separate GPUI renderer for the Actions Ring, notices, and keyboard indicators; agent IPC client                                                                           |
| `xtask`                     | CI, packaging, bundling, and release tooling                                                                                                                              |

## How a button press becomes an action [#how-a-button-press-becomes-an-action]

1. The control is captured: either by the OS hook (middle / back / forward) or
   diverted over HID++ `0x1b04` (gesture button, haptic panel, mode-shift,
   keyboard F-row) by a per-device capture session the agent rebuilds whenever
   bindings change. The same session diverts the main wheel over `0x2121` for
   **Smooth wheel**, and reads raw wheel input over `0x1b04` for
   **Hold to scroll sideways**.
2. The agent resolves the binding: the target app's overlay first, then the
   device's global map. Mouse profiles default to the app under the pointer;
   `mouse_profile_target = "focused"` selects the focused app instead.
   Keyboard profiles always follow focus. See [Per-app profiles](/docs/features/profiles).
3. The agent dispatches it: host actions go through `openlogi-inject`; device
   actions use an agent-owned `openlogi-hid` channel.
4. For `ShowActionsRing`, the agent selects the focused app's complete layout
   and snapshots a presentation for the overlay. It accepts hover / activate /
   cancel RPCs, validates the session and slot, then executes the selected
   action itself.

Configuration is a plain TOML file; there is no cloud or account. The desktop
loads and atomically writes the file, then requests `reload_config`. The agent
independently loads it at startup and validates it again on reload before
replacing live state.

Some settings live in volatile device RAM and are lost when a device reconnects.
The agent writes them again after a reconnect or a system wake: DPI, report
rate, SmartShift, the native wheel mode (resolution and inversion), keyboard
lighting, Fn-lock, **Keep OS layout**, and Litra light settings. A disabled
device gets no writes.

## Developing without hardware [#developing-without-hardware]

`openlogi-agent-mock` serves the real IPC contract from a scripted in-memory
inventory without opening HID devices or installing input hooks, so the desktop
and overlay can be developed with no Logitech device attached. It can load a
recorded device profile instead of the built-in inventory. HID++ cassettes test
the separate device layer through a replay backend; they are not a live input
stream for the UI mock. See [Contributing](/docs/project/contributing) and
[MOCK\_DEVICE\_TESTING.md](https://github.com/AprilNEA/OpenLogi/blob/master/docs/MOCK_DEVICE_TESTING.md).


# Contributing (/docs/project/contributing)



OpenLogi is experimental and testing help is especially valuable; support is
limited by the devices contributors can test against. Device reports are as
useful as patches: the repository has an issue template for them.

* **Source:** [github.com/AprilNEA/OpenLogi](https://github.com/AprilNEA/OpenLogi)
* **Build from source:**
  [DEVELOPMENT.md](https://github.com/AprilNEA/OpenLogi/blob/master/docs/DEVELOPMENT.md)
  — including `openlogi-agent-mock`, which lets you work on the GUI with no
  hardware attached
* **Translations:** [Crowdin](https://crowdin.com/project/openlogi)

## Contribute a device fixture [#contribute-a-device-fixture]

OpenLogi v0.8.4 added a guided way to contribute a sanitized device profile and,
for HID++ devices, replayable read cases. Work from a clone of the OpenLogi
repository and read the full
[recording workflow](https://github.com/AprilNEA/OpenLogi/blob/master/docs/MOCK_DEVICE_TESTING.md#contribution-wizard)
before capturing.

Choose a synthetic specimen ID—never a serial number or other hardware ID—and
use a new output directory whose basename exactly matches `--id`. `--name` is a
human-readable synthetic name. `--device` must be either a case-insensitive
exact display name or an exact rendered route; omit it only when there is one
candidate. Ambiguous selection is rejected.

With the real agent for that device running, and with `OPENLOGI_PROFILE` set to
the same profile as the agent when you use a non-default profile, run:

```sh
openlogi fixture contribute \
  --id mx-master-3s-001 \
  --name "MX Master 3S" \
  --device "MX Master 3S" \
  --output fixtures/devices/mx-master-3s-001
```

The first phase reads semantic state through the agent; it does not fall back
to direct hardware access. For an HID++ fixture, follow the command's prompt:
stop that same agent and competing hardware clients such as Options+ or Solaar,
then rerun the identical command with the same physical device and route. The
CLI holds the selected profile's agent lock during direct capture and refuses
an active or unhealthy agent endpoint. Do not delete locks or switch profiles
to bypass that check. Receiver discovery may enable notification flags and
request arrival reports, so the direct phase is not a zero-write session, even
though the eight recorded operations do not change settings or pairings.

Use `--profile-only` when direct CLI hardware access is not authorized; raw-HID
standalone devices select this mode automatically. Finally, verify the result
offline:

```sh
openlogi fixture verify fixtures/devices/mx-master-3s-001
```

This checks the schema, privacy ledger, relationships, and replay framing. It
does not access hardware or prove that the captured semantic values are
correct.

<Callout type="warning">
  The wizard sanitizes identities before writing fixture files and uploads
  nothing. Inspect the generated JSON before sharing it. Do not submit raw
  traffic or logs, original serial or receiver IDs, Bluetooth addresses,
  pairing secrets, host paths, or hashes of private identifiers.
</Callout>

### Develop with a mock profile [#develop-with-a-mock-profile]

`openlogi-agent-mock` can load a fixture's semantic `profile.json` and serve the
normal agent IPC contract without opening hardware:

```sh
cargo run -p openlogi-agent --bin openlogi-agent-mock -- \
  --fixture fixtures/devices/mx-master-3s-001/profile.json
```

Fixture mode keeps battery, camera, foreground-app, and pairing state frozen for
repeatable UI work. Running the mock without `--fixture` uses its animated
built-in demo instead. See the
[`openlogi-agent-mock` source](https://github.com/AprilNEA/OpenLogi/blob/master/crates/openlogi-agent/src/bin/mock_agent.rs)
for the current behavior and launch examples.

## Acknowledgments [#acknowledgments]

* **Windows port** by [@davidbudnick](https://github.com/davidbudnick) — the
  input hook, MSI in-app updates, tray and settings parity
* **Linux port** by [@cserby](https://github.com/cserby) — the evdev/uinput
  hook, D-Bus actions, `.deb` / `.rpm` packaging
* [`hidpp`](https://crates.io/crates/hidpp) by [@lus](https://github.com/lus) —
  vendored as `openlogi-hidpp`
* [Solaar](https://github.com/pwr-Solaar/Solaar) by
  [@pwr](https://github.com/pwr) — the most complete open-source HID++
  implementation, and OpenLogi's protocol reference
* [Mouser](https://github.com/TomBadash/Mouser) by
  [@TomBadash](https://github.com/TomBadash) — prior art for a local,
  account-free Options+ replacement

## License [#license]

Dual-licensed under Apache-2.0 or MIT, at your option. The vendored
`openlogi-hidpp` crate is 0BSD. The OpenLogi name, logo, and app icon are **not**
covered by those licenses; see
[`design/LICENSE`](https://github.com/AprilNEA/OpenLogi/blob/master/design/LICENSE).


# Roadmap (/docs/project/roadmap)



<Callout type="warning">
  OpenLogi is under active development and not yet stable; features and config
  may still change.
</Callout>

This page lists the state of each capability in the published **v0.8.15**
release. **Supported** means the capability works on the platforms named in the
notes. **Partial** means part of the capability works; the notes say which part.
**Not yet** means OpenLogi does not implement it.

## Devices and connections [#devices-and-connections]

| Capability                                                                                   | State     | Notes                                                                                                  |
| -------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------ |
| Bolt receivers: discovery, paired devices, and pairing from the GUI                          | Supported |                                                                                                        |
| Unifying receivers: paired devices and the pairing flow                                      | Supported |                                                                                                        |
| Lightspeed and Nano receivers                                                                | Supported | Enumeration and device control. The pairing flow lists them; pairing on this hardware is not verified. |
| Bluetooth-direct and wired devices                                                           | Supported |                                                                                                        |
| Battery level and charge state (`0x1000`, `0x1001`, `0x1004`)                                | Supported | Online devices                                                                                         |
| Headsets                                                                                     | Partial   | G733 battery and link state (`0x1f20`). G735 identity only. No audio controls.                         |
| Device gallery: grid, list, and carousel; custom names; mouse diagram                        | Supported | macOS, Linux, Windows                                                                                  |
| [Easy-Switch](/docs/features/easy-switch) channels: computer name, OS, and rename (`0x1815`) | Supported | Devices that report Easy-Switch channels                                                               |
| Host switching that moves linked devices with a keyboard (`0x1814`)                          | Supported | Set in `config.toml` only                                                                              |

## Mouse [#mouse]

| Capability                                                                                               | State     | Notes                                                                                            |
| -------------------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------ |
| Button remapping through the OS input hook                                                               | Supported | macOS, Linux, Windows                                                                            |
| Action catalog, custom shortcuts, app launchers, and Power User actions                                  | Supported | Platform-dependent¹                                                                              |
| Short and long presses, held shortcuts, and tap-key-holding-modifiers                                    | Supported | Short and long press pairs are set in `config.toml` only                                         |
| Gestures on Middle Click, Back, Forward, the gesture button, the haptic panel, and DPI/ModeShift buttons | Supported | DPI/ModeShift only when the button reports diversion and raw-XY. Chained swipes in one hold.     |
| [Hold to scroll sideways](/docs/features/mouse/scrolling#hold-to-scroll-sideways) on Back and Forward    | Supported | Firmware that reports raw wheel input for the button (`0x1b04`)                                  |
| [Per-app profiles](/docs/features/profiles) and `[apps]` aliases                                         | Supported | Mouse profiles follow the pointer on macOS, Windows, and X11; other sessions use the focused app |
| [Actions Ring](/docs/features/actions-ring) with per-app layouts                                         | Supported | macOS, Linux, Windows; mice and keyboards                                                        |
| DPI presets and the Cycle / Set preset actions (`0x2201`, `0x2202`)                                      | Supported |                                                                                                  |
| [Report rate](/docs/features/mouse/report-rate) (`0x8060`, `0x8061`)                                     | Supported | Rates offered on the current connection                                                          |
| Pointer motion scaling (`0x2205`)                                                                        | Partial   | `openlogi diag pointer-speed` only; no GUI control                                               |
| SmartShift mode, sensitivity, and permanent ratchet (`0x2110`, `0x2111`)                                 | Supported |                                                                                                  |
| SmartShift scrolling force                                                                               | Supported | `0x2111` devices that report tunable torque                                                      |
| Scroll inversion and wheel resolution (`0x2121`)                                                         | Supported | Software inversion for wheels without native inversion                                           |
| **Smooth wheel** (main wheel diverted over `0x2121`)                                                     | Supported | Needs mouse hook access                                                                          |
| App-wide smooth scrolling and vertical sensitivity                                                       | Supported | Trackpad input stays native                                                                      |
| Thumb wheel presets and bindings (`0x2150`)                                                              | Supported |                                                                                                  |
| Main-wheel tilt bindings                                                                                 | Supported | Devices that expose tilt controls                                                                |

## Keyboards [#keyboards]

| Capability                                                                    | State     | Notes                                                                     |
| ----------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------- |
| HID++ key bindings on the **Keys** tab (`0x1b04`)                             | Supported | macOS, Linux, Windows; keyboards that report reprogrammable controls      |
| Function-key remapper through the OS keyboard hook                            | Partial   | macOS and Windows. Linux does not capture keyboard events.                |
| Fn Lock (`0x40a0`, `0x40a2`, `0x40a3`)                                        | Supported |                                                                           |
| **Keep OS layout** (`0x4531`)                                                 | Supported | Multi-platform keyboards                                                  |
| Backlight: on/off, automatic brightness, level, and battery saving (`0x1982`) | Supported | **Device** tab                                                            |
| Static RGB lighting (`0x8070`, `0x8071`, `0x8080`, `0x8081`)                  | Supported | Supported devices                                                         |
| [Lock-key indicator](/docs/features/notifications) (`0x4220`)                 | Supported | Caps Lock on all platforms; Num Lock and Scroll Lock on Windows and Linux |

## Lights and webcams [#lights-and-webcams]

| Capability                                                        | State     | Notes                                                                    |
| ----------------------------------------------------------------- | --------- | ------------------------------------------------------------------------ |
| Litra lights: power, brightness, and color temperature            | Supported | HID++ `0x1990` with read-back, or raw HID when `0x1990` does not respond |
| Litra auto-on with the camera                                     | Partial   | macOS only                                                               |
| Logitech webcams: live preview, UVC controls, and camera profiles | Supported | macOS, Linux, Windows                                                    |

## App and platform [#app-and-platform]

| Capability                                                                                     | State     | Notes                                                                                                         |
| ---------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------- |
| [Settings window](/docs/settings): launch at login, updates, permissions, language, appearance | Supported | macOS, Linux, Windows                                                                                         |
| Agent status icon                                                                              | Partial   | macOS menu bar and Windows notification area; none on Linux                                                   |
| [On-screen notices](/docs/features/notifications): low battery, lock keys, backlight level     | Supported |                                                                                                               |
| In-app updates                                                                                 | Supported | Off by default. Sparkle on macOS; MSI updater on Windows. Linux packages update through their package source. |
| [JSON automation API](/docs/cli)                                                               | Supported | Needs a running agent; `--save` writes the change to `config.toml`                                            |
| Interface localization                                                                         | Supported | 24 languages                                                                                                  |
| Linux packaging: udev rules, systemd unit, `.deb` / `.rpm` / `.pkg.tar.zst`, Flatpak           | Supported |                                                                                                               |
| [Verified Linux installer](/docs/installation#linux)                                           | Supported | Signature and checksum verification before package installation                                               |
| Native desktop actions on GNOME, KDE, and Hyprland                                             | Supported | See [Remap buttons](/docs/features/mouse/remap-buttons)                                                       |
| Privacy-safe device fixtures and offline verification                                          | Supported | CLI; [contribution workflow](/docs/project/contributing)                                                      |
| Windows (agent, GUI, event hook, installer)                                                    | Supported | Validated on Windows 11 hardware; newer port with ongoing polish                                              |

¹ Some actions work differently per platform. For example, media keys use D-Bus
MPRIS on Linux, and a few macOS actions such as Launchpad have no equivalent on
every Linux desktop. The [Actions by platform](/docs/features/mouse/remap-buttons#actions-by-platform)
table lists the details.

Help improve the interface translations on
[Crowdin](https://crowdin.com/project/openlogi).

*Release baseline: v0.8.15 (October 20, 2026). Updated October 21, 2026. See the
[changelog](/docs/changelog) for release history.*


# Index (/hidpp/features)



Each HID++ 2.0 feature is identified by a 16-bit ID. The
[HID++ overview](/hidpp) explains the message format, the source tags used
on the feature pages, and the attribution notice.

## Support status [#support-status]

OpenLogi v0.8.15 ships typed wrappers for 48 of the 112 catalogued
features. 21 more have a reference page for the protocol only. The
remaining 43 are listed without a page.

**Legend:** ✅ **Implemented**: typed wrapper in `openlogi-hidpp` · 📄 **Documented**: reference page, not implemented · ▫ **Listed**: known feature, no page yet

A typed wrapper does not mean a GUI setting. Each feature page says which
OpenLogi feature, if any, uses it.

`0x8080` is the one exception to the legend: OpenLogi drives it with raw
frames for per-key lighting and has no typed wrapper for it.

## Core [#core]

| ID                                                     | Feature                | Status | Summary                               |
| ------------------------------------------------------ | ---------------------- | ------ | ------------------------------------- |
| [`0x0000`](/hidpp/features/x0000-root)                 | root                   | ✅      | Resolve a feature ID to its index     |
| [`0x0001`](/hidpp/features/x0001-featureset)           | featureSet             | ✅      | Enumerate the device's features       |
| `0x0002`                                               | featureInfo            | ▫      | Query metadata about a feature        |
| [`0x0003`](/hidpp/features/x0003-device-information)   | deviceInformation      | ✅      | Firmware, serial, entity list         |
| `0x0004`                                               | unitId                 | ▫      | Persistent unit identity across hosts |
| [`0x0005`](/hidpp/features/x0005-device-type-and-name) | deviceTypeAndName      | ✅      | Device type and marketing name        |
| `0x0006`                                               | deviceGroups           | ▫      | Logical device group membership       |
| [`0x0007`](/hidpp/features/x0007-device-friendly-name) | deviceFriendlyName     | ✅      | User-assigned device name             |
| `0x0008`                                               | keepAlive              | ▫      | Periodic heartbeat to prevent timeout |
| `0x0020`                                               | configChange           | ▫      | Notify host of configuration changes  |
| `0x0021`                                               | uniqueRandomId         | ▫      | Random unique ID generation           |
| `0x0030`                                               | targetSoftware         | ▫      | Target software compatibility info    |
| `0x0080`                                               | wirelessSignalStrength | ▫      | RF signal strength reporting          |

## Firmware, DFU & device management [#firmware-dfu--device-management]

| ID                                                   | Feature            | Status | Summary                          |
| ---------------------------------------------------- | ------------------ | ------ | -------------------------------- |
| `0x00c0`                                             | dfuControlLegacy   | ▫      | Legacy DFU mode entry control    |
| `0x00c1`                                             | dfuControlUnsigned | ▫      | Unsigned firmware update control |
| [`0x00c2`](/hidpp/features/x00c2-dfu-control-signed) | dfuControlSigned   | 📄     | Signed firmware update entry     |
| `0x00c3`                                             | dfuControlBolt     | ▫      | Bolt-receiver DFU control        |
| [`0x00d0`](/hidpp/features/x00d0-dfu)                | dfu                | 📄     | Device firmware upgrade protocol |
| `0x00d1`                                             | dfuResumable       | ▫      | Resumable DFU session support    |
| [`0x1802`](/hidpp/features/x1802-device-reset)       | deviceReset        | 📄     | Trigger a device software reset  |
| [`0x1805`](/hidpp/features/x1805-oob-state)          | oobState           | 📄     | Out-of-box state management      |
| `0x1806`                                             | configDeviceProps  | ▫      | Configurable device properties   |
| `0x1f1f`                                             | firmwareProperties | ▫      | Firmware build properties        |
| `0x1800`                                             | genericTest        | ▫      | Generic manufacturing test hooks |
| `0x1df0`                                             | remainingPairings  | ▫      | Remaining pairing slots count    |

## Power & battery [#power--battery]

| ID                                                         | Feature                | Status | Summary                              |
| ---------------------------------------------------------- | ---------------------- | ------ | ------------------------------------ |
| [`0x1000`](/hidpp/features/x1000-battery-status)           | batteryStatus          | ✅      | Battery charge percentage & status   |
| [`0x1001`](/hidpp/features/x1001-battery-voltage)          | batteryVoltage         | ✅      | Raw battery voltage level            |
| [`0x1004`](/hidpp/features/x1004-unified-battery)          | unifiedBattery         | ✅      | Battery percentage and state         |
| [`0x1010`](/hidpp/features/x1010-charging-control)         | chargingControl        | 📄     | Charge speed and threshold control   |
| [`0x1f20`](/hidpp/features/x1f20-adc-measurement)          | adcMeasurement         | ✅      | Battery voltage on G-series headsets |
| [`0x4301`](/hidpp/features/x4301-solar-keyboard-dashboard) | solarKeyboardDashboard | ✅      | Solar keyboard light & battery       |

## Connection, hosts & platform [#connection-hosts--platform]

| ID                                                       | Feature              | Status | Summary                            |
| -------------------------------------------------------- | -------------------- | ------ | ---------------------------------- |
| [`0x1814`](/hidpp/features/x1814-change-host)            | changeHost           | ✅      | Switch the active host channel     |
| [`0x1815`](/hidpp/features/x1815-hosts-info)             | hostsInfo            | ✅      | Multi-host slots & names           |
| [`0x1d4b`](/hidpp/features/x1d4b-wireless-device-status) | wirelessDeviceStatus | ✅      | Link / reconnect events            |
| `0x1bc0`                                                 | reportHidUsages      | ▫      | Report HID usage pages to host     |
| `0x1300`                                                 | ledControl           | ▫      | Generic LED on/off control         |
| [`0x4530`](/hidpp/features/x4530-dual-platform)          | dualPlatform         | ✅      | Two-platform (iOS/Android…) select |
| [`0x4531`](/hidpp/features/x4531-multi-platform)         | multiPlatform        | ✅      | Per-host OS platform select        |

## Pointer, DPI & wheel [#pointer-dpi--wheel]

| ID                                                          | Feature                 | Status | Summary                            |
| ----------------------------------------------------------- | ----------------------- | ------ | ---------------------------------- |
| [`0x2100`](/hidpp/features/x2100-vertical-scrolling)        | verticalScrolling       | ✅      | Roller info & ratchet/free toggle  |
| [`0x2110`](/hidpp/features/x2110-smartshift)                | smartShiftWheel         | ✅      | Original SmartShift variant        |
| [`0x2111`](/hidpp/features/x2111-smartshift-enhanced)       | smartShiftWheelEnhanced | ✅      | Ratchet/free-spin + threshold      |
| [`0x2120`](/hidpp/features/x2120-high-resolution-scrolling) | highResolutionScrolling | 📄     | High-resolution scroll wheel       |
| [`0x2121`](/hidpp/features/x2121-hires-wheel)               | hiResWheel              | ✅      | High-resolution / free-spin wheel  |
| `0x2130`                                                    | ratchetWheel            | ▫      | Ratchet mode control               |
| [`0x2150`](/hidpp/features/x2150-thumbwheel)                | thumbwheel              | ✅      | Side thumbwheel                    |
| [`0x2200`](/hidpp/features/x2200-mouse-pointer)             | mousePointer            | ✅      | Sensor resolution & tuning hints   |
| [`0x2201`](/hidpp/features/x2201-adjustable-dpi)            | adjustableDpi           | ✅      | DPI sensors and presets            |
| [`0x2202`](/hidpp/features/x2202-extended-adjustable-dpi)   | extendedAdjustableDpi   | ✅      | X/Y DPI, LOD, calibration          |
| [`0x2205`](/hidpp/features/x2205-pointer-motion-scaling)    | pointerMotionScaling    | ✅      | Pointer speed multiplier           |
| `0x2230`                                                    | sensorAngleSnapping     | ▫      | Sensor angle-snapping control      |
| `0x2240`                                                    | surfaceTuning           | ▫      | Surface-specific sensor tuning     |
| `0x2250`                                                    | xyStats                 | ▫      | X/Y motion statistics              |
| `0x2251`                                                    | wheelStats              | ▫      | Scroll-wheel motion statistics     |
| `0x2400`                                                    | hybridTrackingEngine    | ▫      | Hybrid optical/mechanical tracking |

## Report rate [#report-rate]

| ID                                                     | Feature                      | Status | Summary                    |
| ------------------------------------------------------ | ---------------------------- | ------ | -------------------------- |
| [`0x8060`](/hidpp/features/x8060-report-rate)          | adjustableReportRate         | ✅      | Report-rate list & select  |
| [`0x8061`](/hidpp/features/x8061-extended-report-rate) | extendedAdjustableReportRate | ✅      | Per-connection report rate |

## Keyboard [#keyboard]

| ID                                                        | Feature                        | Status | Summary                            |
| --------------------------------------------------------- | ------------------------------ | ------ | ---------------------------------- |
| [`0x40a0`](/hidpp/features/x40a0-fn-inversion)            | fnInversion                    | ✅      | Legacy global Fn-inversion toggle  |
| [`0x40a2`](/hidpp/features/x40a2-fn-inversion)            | fnInversionWithDefaultState    | ✅      | Global Fn-inversion state          |
| [`0x40a3`](/hidpp/features/x40a3-fn-inversion-multi-host) | fnInversionForMultiHostDevices | ✅      | Per-host Fn-inversion state        |
| `0x4100`                                                  | encryption                     | ▫      | Keyboard encryption key management |
| [`0x4220`](/hidpp/features/x4220-lock-key-state)          | lockKeyState                   | ✅      | Caps / Num / Scroll Lock state     |
| [`0x4520`](/hidpp/features/x4520-keyboard-layout)         | keyboardLayout                 | 📄     | Physical keyboard layout reporting |
| [`0x4521`](/hidpp/features/x4521-disable-keys)            | disableKeys                    | ✅      | Disable lock / system keys         |
| [`0x4522`](/hidpp/features/x4522-disable-keys-by-usage)   | disableKeysByUsage             | ✅      | Disable any keys by HID usage      |
| `0x4540`                                                  | keyboardInternationalLayouts   | ▫      | International layout variants      |

## Buttons & remapping [#buttons--remapping]

| ID                                                             | Feature                    | Status | Summary                           |
| -------------------------------------------------------------- | -------------------------- | ------ | --------------------------------- |
| [`0x1b00`](/hidpp/features/x1b00-reprog-controls)              | reprogControls             | 📄     | Legacy reprogrammable controls    |
| `0x1b01`                                                       | reprogControls2            | ▫      | Reprogrammable controls v2        |
| `0x1b02`                                                       | reprogControls3            | ▫      | Reprogrammable controls v3        |
| `0x1b03`                                                       | reprogControls4            | ▫      | Reprogrammable controls v4        |
| [`0x1b04`](/hidpp/features/x1b04-special-keys-mse-buttons)     | reprogControls5            | ✅      | Reprogrammable controls / buttons |
| [`0x1c00`](/hidpp/features/x1c00-persistent-remappable-action) | persistentRemappableAction | ✅      | Persistent on-device remaps       |
| [`0x2001`](/hidpp/features/x2001-swap-left-right-button)       | swapLeftRightButton        | 📄     | Swap primary mouse buttons        |
| `0x2005`                                                       | buttonSwapCancel           | ▫      | Cancel button-swap state          |
| `0x2006`                                                       | pointerAxesOrientation     | ▫      | Flip X/Y pointer axes             |

## Lighting [#lighting]

| ID                                                   | Feature            | Status | Summary                         |
| ---------------------------------------------------- | ------------------ | ------ | ------------------------------- |
| [`0x1981`](/hidpp/features/x1981-backlight1)         | backlight1         | 📄     | Keyboard backlight (v1)         |
| [`0x1982`](/hidpp/features/x1982-backlight)          | backlight2         | ✅      | Keyboard backlight & effects    |
| `0x1983`                                             | backlight3         | ▫      | Keyboard backlight v3           |
| [`0x1990`](/hidpp/features/x1990-illumination)       | illumination       | ✅      | Brightness & color temperature  |
| [`0x19b0`](/hidpp/features/x19b0-haptic-feedback)    | hapticFeedback     | ✅      | Haptic actuator control         |
| `0x19c0`                                             | forceSensingButton | ▫      | Force-sensing button thresholds |
| [`0x8040`](/hidpp/features/x8040-brightness-control) | brightnessControl  | ✅      | Generic brightness control      |
| [`0x8070`](/hidpp/features/x8070-color-led-effects)  | colorLedEffects    | ✅      | Per-zone RGB effect engine      |
| [`0x8071`](/hidpp/features/x8071-rgb-effects)        | rgbEffects         | ✅      | Modern per-cluster RGB engine   |
| [`0x8080`](/hidpp/features/x8080-per-key-lighting)   | perKeyLighting     | 📄     | Per-key RGB (legacy protocol)   |
| [`0x8081`](/hidpp/features/x8081-per-key-lighting)   | perKeyLighting2    | ✅      | Per-key RGB zones               |
| [`0x8090`](/hidpp/features/x8090-mode-status)        | modeStatus         | ✅      | Host vs onboard mode            |

## Audio [#audio]

| ID                                          | Feature    | Status | Summary                |
| ------------------------------------------- | ---------- | ------ | ---------------------- |
| [`0x8300`](/hidpp/features/x8300-sidetone)  | sidetone   | ✅      | Headset sidetone level |
| [`0x8310`](/hidpp/features/x8310-equalizer) | equalizer  | ✅      | Audio EQ band gains    |
| `0x8320`                                    | headsetOut | ▫      | Headset output routing |

## Touch & crown [#touch--crown]

| ID                                                | Feature                   | Status | Summary                         |
| ------------------------------------------------- | ------------------------- | ------ | ------------------------------- |
| [`0x4600`](/hidpp/features/x4600-crown)           | crown                     | ✅      | MX crown mode & events          |
| `0x6010`                                          | touchpadFwItems           | ▫      | Touchpad firmware items         |
| `0x6011`                                          | touchpadSwItems           | ▫      | Touchpad software items         |
| `0x6012`                                          | touchpadWin8FwItems       | ▫      | Win8-specific touchpad firmware |
| [`0x6020`](/hidpp/features/x6020-tap-enable)      | tapEnable                 | 📄     | Touchpad tap-to-click toggle    |
| `0x6021`                                          | tapEnableExtended         | ▫      | Extended tap gesture settings   |
| `0x6030`                                          | cursorBallistic           | ▫      | Touchpad cursor ballistics      |
| `0x6040`                                          | touchpadResolutionDivider | ▫      | Touchpad resolution scaling     |
| [`0x6100`](/hidpp/features/x6100-touchpad-raw-xy) | touchpadRawXY             | ✅      | Raw touchpad multi-touch data   |
| [`0x6110`](/hidpp/features/x6110-touch-mouse-raw) | touchMouseRawTouchPoints  | ✅      | Raw touch-mouse points          |
| `0x6120`                                          | btTouchMouseSettings      | ▫      | Bluetooth touch-mouse settings  |
| [`0x6500`](/hidpp/features/x6500-gestures1)       | gestures1                 | 📄     | Gesture configuration (v1)      |
| [`0x6501`](/hidpp/features/x6501-gestures2)       | gestures2                 | ✅      | Gesture configuration (v2)      |

## Gaming [#gaming]

| ID                                                    | Feature           | Status | Summary                          |
| ----------------------------------------------------- | ----------------- | ------ | -------------------------------- |
| [`0x8010`](/hidpp/features/x8010-gaming-g-keys)       | gamingGKeys       | 📄     | Programmable G-key management    |
| [`0x8020`](/hidpp/features/x8020-gaming-m-keys)       | gamingMKeys       | 📄     | Mode M-key management            |
| [`0x8030`](/hidpp/features/x8030-macro-record)        | macroRecord       | 📄     | On-device macro recording        |
| [`0x8100`](/hidpp/features/x8100-onboard-profiles)    | onboardProfiles   | 📄     | Onboard profile storage & switch |
| [`0x8110`](/hidpp/features/x8110-mouse-button-filter) | mouseButtonFilter | 📄     | Mouse button event filtering     |
| [`0x8111`](/hidpp/features/x8111-latency-monitoring)  | latencyMonitoring | 📄     | End-to-end click latency stats   |
| `0x8120`                                              | gamingAttachments | ▫      | Peripheral attachment detection  |
| [`0x8123`](/hidpp/features/x8123-force-feedback)      | forceFeedback     | 📄     | Force-feedback actuator control  |

## Presenter & special input [#presenter--special-input]

| ID                                                  | Feature          | Status | Summary                           |
| --------------------------------------------------- | ---------------- | ------ | --------------------------------- |
| [`0x1a00`](/hidpp/features/x1a00-presenter-control) | presenterControl | 📄     | Presenter remote control actions  |
| `0x1a01`                                            | sensor3D         | ▫      | 3D gyroscope/accelerometer sensor |


# 0x0000 · root (/hidpp/features/x0000-root)



Every HID++ 2.0 device exposes `root` at feature index `0`. It maps a 16-bit feature ID to its session feature index. Its ping function also returns the device protocol version.

> **Spec:** Logitech HID++ 2.0 — *x0000 root*. &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis). &#x2A;*Used by:** [Supported devices](/docs/supported-devices).

## Function reference [#function-reference]

The `RootFeature` wrapper is fixed at feature index `0`.

### Methods [#methods]

| Function      | HID++ fn | Signature    | Returns                      |
| ------------- | -------- | ------------ | ---------------------------- |
| `get_feature` | 0        | `(id: u16)`  | `Option<FeatureInformation>` |
| `ping`        | 1        | `(data: u8)` | `u8`                         |

All methods are asynchronous and return `Result<…, Hidpp20Error>`. `get_feature` returns `None` when response byte `0` is zero. `ping` returns only the echoed byte.

### Types [#types]

#### `FeatureInformation` [#featureinformation]

`get_feature` returns this information for a supported feature.

| Field     | Type          | Description                                                      |
| --------- | ------------- | ---------------------------------------------------------------- |
| `index`   | `u8`          | Session feature index used for later calls.                      |
| `typ`     | `FeatureType` | Feature classification flags from the type byte.                 |
| `version` | `u8`          | Latest compatible feature version. It is `0` on Root v0 devices. |

#### `FeatureType` [#featuretype]

`FeatureType` is a bitfield. OpenLogi retains unknown bits, but exposes fields only for the bits below.

| Field                         | Bit | Description                                                                                 |
| ----------------------------- | --- | ------------------------------------------------------------------------------------------- |
| `obsolete`                    | 7   | An older feature remains advertised for older software.                                     |
| `hidden`                      | 6   | End-user configuration software should ignore the feature.                                  |
| `engineering`                 | 5   | The feature is for engineering or manufacturing use.                                        |
| `manufacturing_deactivatable` | 4   | Manufacturing can permanently deactivate the feature. Available in feature type v2.         |
| `compliance_deactivatable`    | 3   | Compliance processing can permanently deactivate the feature. Available in feature type v2. |

### Events [#events]

This feature does not define events.

## Wire format [#wire-format]

Both requests use a 3-byte short payload. OpenLogi reads replies through `extend_payload()`, which presents a 16-byte payload.

### `get_feature` (fn 0) [#get_feature-fn-0]

Request: `[id_hi, id_lo, 0x00]`

| Byte | Field    | Notes                       |
| ---- | -------- | --------------------------- |
| 0–1  | `id`     | Requested feature ID, `BE`. |
| 2    | reserved | OpenLogi writes `0x00`.     |

| Byte | Field            | Notes                                                                          |
| ---- | ---------------- | ------------------------------------------------------------------------------ |
| 0    | `index`          | The session feature index. `0` means unsupported, and OpenLogi returns `None`. |
| 1    | `typ`            | `FeatureType` bits 7–3. OpenLogi retains other bits without named fields.      |
| 2    | `version`        | Latest compatible version.                                                     |
| 3–15 | trailing payload | OpenLogi ignores these bytes.                                                  |

### `ping` (fn 1) [#ping-fn-1]

Request: `[0x00, 0x00, data]`

| Byte | Field    | Notes                      |
| ---- | -------- | -------------------------- |
| 0–1  | reserved | OpenLogi writes `0x00`.    |
| 2    | `data`   | Caller-selected ping byte. |

| Byte | Field            | Notes                                                               |
| ---- | ---------------- | ------------------------------------------------------------------- |
| 0    | `protocol_num`   | HID++ protocol number. `RootFeature::ping` does not expose it.      |
| 1    | `target_sw`      | Target software identifier. `RootFeature::ping` does not expose it. |
| 2    | echoed `data`    | `RootFeature::ping` returns this byte.                              |
| 3–15 | trailing payload | OpenLogi ignores these bytes.                                       |

## How OpenLogi uses it [#how-openlogi-uses-it]

`Device::new()` sends a Root ping through `determine_version` before it installs `RootFeature`. It rejects HID++ 1.0 peripherals and stores the detected HID++ 2.x protocol number and target software identifier.

`Device::enumerate_features()` calls `get_feature(0x0001)`. It uses the returned index to install `FeatureSetFeature` and discover the remaining feature table. Root has no device state to restore.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::root::RootFeature,
    protocol::v20::Hidpp20Error,
};

async fn inspect_root(device: &Device) -> Result<(), Hidpp20Error> {
    let root: std::sync::Arc<RootFeature> = device.root();

    if let Some(info) = root.get_feature(0x1000).await? {
        println!("0x1000 is at index {} and supports v{}", info.index, info.version);
    }

    assert_eq!(root.ping(0x42).await?, 0x42);
    Ok(())
}
```


# 0x0001 · featureSet (/hidpp/features/x0001-featureset)



`featureSet` lists the HID++ features available during one device connection. The table excludes `root`, which always uses index `0`. Clients read the count, then read entries `1` through that count.

> **Spec:** Logitech HID++ 2.0 — *x0001 featureSet*. &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis). &#x2A;*Used by:** [Supported devices](/docs/supported-devices).

## Function reference [#function-reference]

The `FeatureSetFeature` wrapper implements feature version `0`.

### Methods [#methods]

| Function      | HID++ fn | Signature     | Returns              |
| ------------- | -------- | ------------- | -------------------- |
| `count`       | 0        | `()`          | `u8`                 |
| `get_feature` | 1        | `(index: u8)` | `FeatureInformation` |

Both methods are asynchronous and return `Result<…, Hidpp20Error>`.

### Types [#types]

#### `FeatureInformation` [#featureinformation]

`get_feature` returns one entry from the feature table.

| Field     | Type          | Description                                                                    |
| --------- | ------------- | ------------------------------------------------------------------------------ |
| `id`      | `u16`         | HID++ feature ID.                                                              |
| `typ`     | `FeatureType` | Feature classification flags.                                                  |
| `version` | `u8`          | Latest compatible feature version. It is `0` for older feature-table versions. |

#### `FeatureType` [#featuretype]

`FeatureType` uses the same type-byte bitfield as [`0x0000` root](/hidpp/features/x0000-root). OpenLogi retains unknown bits and exposes bits 7–3 as `obsolete`, `hidden`, `engineering`, `manufacturing_deactivatable`, and `compliance_deactivatable`.

### Events [#events]

This feature does not define events.

## Wire format [#wire-format]

Both requests use a 3-byte short payload. OpenLogi reads replies through `extend_payload()`, which presents a 16-byte payload.

### `count` (fn 0) [#count-fn-0]

Request: `[0x00, 0x00, 0x00]`

| Byte | Field            | Notes                                                           |
| ---- | ---------------- | --------------------------------------------------------------- |
| 0    | `count`          | Number of feature-table entries. Root at index `0` is excluded. |
| 1–15 | trailing payload | OpenLogi ignores these bytes.                                   |

### `get_feature` (fn 1) [#get_feature-fn-1]

Request: `[index, 0x00, 0x00]`

| Byte | Field    | Notes                                                                                                                                 |
| ---- | -------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| 0    | `index`  | 1-based feature-table index. Index `0` is Root and the protocol does not allow it here. OpenLogi passes the supplied value unchanged. |
| 1–2  | reserved | OpenLogi writes `0x00`.                                                                                                               |

| Byte | Field            | Notes                                                                |
| ---- | ---------------- | -------------------------------------------------------------------- |
| 0–1  | `id`             | Feature ID, `BE`.                                                    |
| 2    | `typ`            | Feature type byte. OpenLogi decodes bits 7–3 and retains other bits. |
| 3    | `version`        | Latest compatible feature version.                                   |
| 4–15 | trailing payload | OpenLogi ignores these bytes.                                        |

## How OpenLogi uses it [#how-openlogi-uses-it]

`Device::enumerate_features()` first discovers `0x0001` through Root. It calls `count()` once, then walks `1..=count`. It registers known wrappers from each returned feature ID and version.

The feature table is immutable for the connection. Therefore, `count()` and `get_feature()` may adopt an identical reply left by an abandoned request. The device layer retries a lost table entry up to four times. Each attempt has a 700 ms deadline and a 120 ms backoff.

OpenLogi uses this table during inventory probing to derive capabilities and event feature indexes. The walk does not modify device state and needs no restore operation.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{device::Device, protocol::v20::Hidpp20Error};

async fn list_features(device: &mut Device) -> Result<(), Hidpp20Error> {
    let Some(features) = device.enumerate_features().await? else {
        return Ok(());
    };

    for feature in features {
        println!("0x{:04x} at index {} (v{})", feature.id, feature.index, feature.version);
    }
    Ok(())
}
```


# 0x0003 · deviceInformation (/hidpp/features/x0003-device-information)



`deviceInformation` supplies static identity metadata and firmware records. Feature versions add the unit ID, transport PIDs, extended model ID, and optional serial-number capability. OpenLogi uses this data while it probes an online device.

> **Spec:** Logitech HID++ 2.0 — *x0003 deviceInformation* (v4). &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis), Solaar. &#x2A;*Used by:** [Supported devices](/docs/supported-devices) and `openlogi diag features`.

## Function reference [#function-reference]

The `DeviceInformationFeature` wrapper implements feature version `0` and reads fields introduced by later feature versions.

### Methods [#methods]

| Function            | HID++ fn | Signature            | Returns                    |
| ------------------- | -------- | -------------------- | -------------------------- |
| `get_device_info`   | 0        | `()`                 | `DeviceInformation`        |
| `get_fw_info`       | 1        | `(entity_index: u8)` | `DeviceEntityFirmwareInfo` |
| `get_serial_number` | 2        | `()`                 | `String`                   |

All methods are asynchronous and return `Result<…, Hidpp20Error>`. Callers should check `capabilities.serial_number` before calling `get_serial_number`.

### Types [#types]

#### `DeviceInformation` [#deviceinformation]

| Field               | Type                            | Description                                                                                     |
| ------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------- |
| `entity_count`      | `u8`                            | Number of firmware entities available through `get_fw_info`.                                    |
| `unit_id`           | `[u8; 4]`                       | Per-unit identifier. It is zero before feature version `1`.                                     |
| `transport`         | `DeviceTransport`               | Supported transports. It is zero before feature version `1`.                                    |
| `model_id`          | `[u16; 3]`                      | Up to three application PIDs. It is zero before feature version `1`.                            |
| `extended_model_id` | `u8`                            | Production-line attribute for the model, such as colour. It is zero before feature version `2`. |
| `capabilities`      | `DeviceInformationCapabilities` | Feature capabilities. It is false before feature version `4`.                                   |

#### `DeviceTransport` [#devicetransport]

| Field       | Bit | Description                                             |
| ----------- | --- | ------------------------------------------------------- |
| `usb`       | 3   | USB transport.                                          |
| `e_quad`    | 2   | eQuad transport, used by Unifying receivers.            |
| `btle`      | 1   | Bluetooth Low Energy transport, used by Bolt receivers. |
| `bluetooth` | 0   | Bluetooth transport.                                    |

#### `DeviceInformationCapabilities` [#deviceinformationcapabilities]

| Field           | Bit | Description                       |
| --------------- | --- | --------------------------------- |
| `serial_number` | 0   | `get_serial_number` is available. |

#### `DeviceEntityFirmwareInfo` [#deviceentityfirmwareinfo]

| Field             | Type               | Description                                                                    |
| ----------------- | ------------------ | ------------------------------------------------------------------------------ |
| `entity_type`     | `DeviceEntityType` | Firmware-entity type, including an unnamed raw value.                          |
| `firmware_prefix` | `String`           | Three-byte UTF-8 firmware prefix. Invalid UTF-8 returns `UnsupportedResponse`. |
| `firmware_number` | `u8`               | First two decimal digits of the decoded version field.                         |
| `revision`        | `u8`               | Last two decimal digits of the decoded version field.                          |
| `build`           | `u16`              | Decoded four-digit build value.                                                |
| `active`          | `bool`             | Bit 0 of the active byte.                                                      |
| `transport_pid`   | `u16`              | OpenLogi's `BE` interpretation of transport-specific bytes 9–10.               |
| `extra_version`   | `[u8; 5]`          | OpenLogi's remaining transport-specific bytes 11–15.                           |

A version and build field first decode as packed BCD. For a non-BCD field, OpenLogi reads the `BE` value as binary and caps it at `9999`. It splits the decoded version into `firmware_number = value / 100` and `revision = value % 100`.

#### `DeviceEntityType` [#deviceentitytype]

| Variant              | Value        | Description                                                                            |
| -------------------- | ------------ | -------------------------------------------------------------------------------------- |
| `MainApplication`    | 0            | Main application firmware.                                                             |
| `Bootloader`         | 1            | Bootloader firmware.                                                                   |
| `Hardware`           | 2            | Hardware entity.                                                                       |
| `Touchpad`           | 3            | Touchpad entity.                                                                       |
| `OpticalSensor`      | 4            | Optical-sensor entity.                                                                 |
| `Softdevice`         | 5            | Bluetooth SoftDevice entity.                                                           |
| `RfCompanionMcu`     | 6            | RF companion MCU entity.                                                               |
| `FactoryApplication` | 7            | Factory application entity.                                                            |
| `RgbCustomEffect`    | 8            | RGB custom-effect entity.                                                              |
| `MotorDrive`         | 9            | Motor-drive entity.                                                                    |
| `Other(u8)`          | other values | Raw entity type without a named OpenLogi variant. This includes protocol values 10–15. |

### Events [#events]

This feature does not define events.

## Wire format [#wire-format]

Each function sends a 3-byte short payload. OpenLogi parses replies through `extend_payload()`, which presents a 16-byte payload.

### `get_device_info` (fn 0) [#get_device_info-fn-0]

Request: `[0x00, 0x00, 0x00]`

| Byte  | Field               | Notes                                         |
| ----- | ------------------- | --------------------------------------------- |
| 0     | `entity_count`      | Firmware entity count.                        |
| 1–4   | `unit_id`           | Four-byte unit ID.                            |
| 5     | reserved            | OpenLogi ignores this byte.                   |
| 6     | `transport`         | Bits 3–0 are USB, eQuad, BTLE, and Bluetooth. |
| 7–8   | `model_id[0]`       | Application PID, `BE`.                        |
| 9–10  | `model_id[1]`       | Application PID, `BE`.                        |
| 11–12 | `model_id[2]`       | Application PID, `BE`.                        |
| 13    | `extended_model_id` | Extended model attribute.                     |
| 14    | `capabilities`      | Bit 0 is `serial_number`.                     |
| 15    | trailing payload    | OpenLogi ignores this byte.                   |

The three PID slots follow enabled transport bits in Bluetooth, BTLE, eQuad, then USB order. A device can report at most three PIDs. Solaar.

### `get_fw_info` (fn 1) [#get_fw_info-fn-1]

Request: `[entity_index, 0x00, 0x00]`

| Byte | Field                          | Notes                                                                                                                                            |
| ---- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| 0    | `entity_type`                  | `0` is main application, `1` bootloader, `2` hardware, and `3–15` are Other. OpenLogi retains unnamed values as `Other(u8)`.                     |
| 1–3  | `firmware_prefix`              | Three UTF-8 bytes.                                                                                                                               |
| 4–5  | version                        | Four-digit version field, `BE`. Packed BCD is standard. OpenLogi accepts a non-BCD binary value and caps it at `9999`.                           |
| 6–7  | `build`                        | Four-digit build field, `BE`. Packed BCD is standard. OpenLogi accepts a non-BCD binary value and caps it at `9999`.                             |
| 8    | `active`                       | Bit 0 is the active flag. OpenLogi ignores bits 1–7.                                                                                             |
| 9–15 | `transportLayer_Specific_info` | Seven transport-specific bytes. The protocol does not define a generic PID field here. Options+ 2.8 and Solaar preserve this region as raw data. |

OpenLogi preserves bytes 9–15 only as split fields. It labels bytes 9–10 as `transport_pid` (`BE`) and bytes 11–15 as `extra_version`. It does not expose one raw seven-byte field.

### `get_serial_number` (fn 2) [#get_serial_number-fn-2]

Request: `[0x00, 0x00, 0x00]`

| Byte  | Field            | Notes                                                                                 |
| ----- | ---------------- | ------------------------------------------------------------------------------------- |
| 0–11  | serial number    | Twelve UTF-8 bytes returned as `String`. Invalid UTF-8 returns `UnsupportedResponse`. |
| 12–15 | trailing payload | OpenLogi ignores these bytes.                                                         |

`get_serial_number` was introduced with feature version `4`. The wrapper does not enforce that version gate. Inventory checks capability bit 0 before it calls the method.

## How OpenLogi uses it [#how-openlogi-uses-it]

Inventory probing reads `get_device_info()` after feature enumeration. It stores the unit ID, transport flags, three PIDs, and extended model ID in `DeviceModelInfo`. It reads the serial number only when capability bit 0 is set.

`openlogi diag features` reads every entity from `0..entity_count`. A refused entity or one with an invalid UTF-8 prefix becomes an `Unreadable` diagnostic row. A channel failure stops the diagnostic operation.

A failed identity read marks the probe incomplete. If a prior complete cache entry exists, OpenLogi restores its model information or serial number. These functions do not write device state.

## Open questions [#open-questions]

The protocol calls bytes 9–15 transport-layer-specific information. It does not establish that bytes 9–10 always form a PID. Options+ 2.8 and Solaar retain the region as raw data, while OpenLogi assigns it the `transport_pid` and `extra_version` fields.

Hardware capture has not established the meaning of this region for every entity and transport. The reference therefore shows the protocol field and the current OpenLogi API view separately.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::device_information::DeviceInformationFeature,
    protocol::v20::Hidpp20Error,
};

async fn read_identity(device: &mut Device) -> Result<(), Hidpp20Error> {
    device.enumerate_features().await?;
    let Some(feature) = device.get_feature::<DeviceInformationFeature>() else {
        return Ok(());
    };

    let info = feature.get_device_info().await?;
    println!("unit ID: {:02x?}", info.unit_id);

    for entity in 0..info.entity_count {
        let firmware = feature.get_fw_info(entity).await?;
        println!("{entity}: {:?}, build {}", firmware.entity_type, firmware.build);
    }

    if info.capabilities.serial_number {
        println!("serial: {}", feature.get_serial_number().await?);
    }
    Ok(())
}
```


# 0x0005 · deviceTypeAndName (/hidpp/features/x0005-device-type-and-name)



`deviceTypeAndName` reports a marketing type and a marketing name. The name is read in chunks. A caller first reads the reported count, then requests consecutive indexes until it has the full string.

> **Spec:** Logitech HID++ 2.0 — *x0005 deviceTypeAndName* (v2). &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis). &#x2A;*Used by:** [Supported devices](/docs/supported-devices).

## Function reference [#function-reference]

The `DeviceTypeAndNameFeature` wrapper implements feature version `0`.

### Methods [#methods]

| Function                | HID++ fn    | Signature     | Returns      |
| ----------------------- | ----------- | ------------- | ------------ |
| `get_device_name_count` | 0           | `()`          | `u8`         |
| `get_device_name`       | 1           | `(index: u8)` | `Vec<u8>`    |
| `get_whole_device_name` | convenience | `()`          | `String`     |
| `get_device_type`       | 2           | `()`          | `DeviceType` |

All methods are asynchronous and return `Result<…, Hidpp20Error>`. `get_whole_device_name` calls fn 0 once, then fn 1 until the assembled string reaches the reported count.

### Types [#types]

#### `DeviceType` [#devicetype]

| Variant                  | Value | Description              |
| ------------------------ | ----- | ------------------------ |
| `Keyboard`               | 0     | Keyboard.                |
| `RemoteControl`          | 1     | Remote control.          |
| `Numpad`                 | 2     | Numeric keypad.          |
| `Mouse`                  | 3     | Mouse.                   |
| `Trackpad`               | 4     | Trackpad.                |
| `Trackball`              | 5     | Trackball.               |
| `Presenter`              | 6     | Presenter.               |
| `Receiver`               | 7     | Receiver.                |
| `Headset`                | 8     | Headset.                 |
| `Webcam`                 | 9     | Webcam.                  |
| `SteeringWheel`          | 10    | Steering wheel.          |
| `Joystick`               | 11    | Joystick.                |
| `Gamepad`                | 12    | Gamepad.                 |
| `Dock`                   | 13    | Dock.                    |
| `Speaker`                | 14    | Speaker.                 |
| `Microphone`             | 15    | Microphone.              |
| `IlluminationLight`      | 16    | Illumination light.      |
| `ProgrammableController` | 17    | Programmable controller. |
| `CarSimPedals`           | 18    | Car-simulator pedals.    |
| `Adapter`                | 19    | Adapter.                 |

An unknown type byte returns `Hidpp20Error::UnsupportedResponse`.

### Events [#events]

This feature does not define events.

## Wire format [#wire-format]

All requests use a 3-byte short payload.

### `get_device_name_count` (fn 0) [#get_device_name_count-fn-0]

Request: `[0x00, 0x00, 0x00]`

| Byte | Field            | Notes                                                  |
| ---- | ---------------- | ------------------------------------------------------ |
| 0    | `count`          | Reported marketing-name count.                         |
| 1–15 | trailing payload | OpenLogi ignores these bytes after `extend_payload()`. |

### `get_device_name` (fn 1) [#get_device_name-fn-1]

Request: `[index, 0x00, 0x00]`

| Byte | Field    | Notes                                  |
| ---- | -------- | -------------------------------------- |
| 0    | `index`  | Name offset at which the chunk starts. |
| 1–2  | reserved | OpenLogi writes `0x00`.                |

The response is the raw report payload. OpenLogi returns all 3 payload bytes from a short reply or all 16 payload bytes from a long reply. It does not zero-extend this function's `Vec<u8>`.

`get_whole_device_name` decodes each chunk as UTF-8, appends it, and uses the assembled string length as the next index. It removes trailing NUL characters after the final chunk.

### `get_device_type` (fn 2) [#get_device_type-fn-2]

Request: `[0x00, 0x00, 0x00]`

| Byte | Field            | Notes                                                  |
| ---- | ---------------- | ------------------------------------------------------ |
| 0    | `device_type`    | `DeviceType` discriminant.                             |
| 1–15 | trailing payload | OpenLogi ignores these bytes after `extend_payload()`. |

## How OpenLogi uses it [#how-openlogi-uses-it]

Inventory probing calls this feature after it enumerates the feature table. It reads the marketing type and name independently. A failed read leaves only that property unavailable.

OpenLogi maps `DeviceType` to its device kind. The type is the authoritative kind signal. The marketing name helps when an operating system reports a generic HID collection name, such as `Mouse` on Windows Bluetooth.

When a fresh probe lacks a type or marketing name, cache backfill keeps the prior value. This feature has no setters, so it does not use read-modify-write or restore device state.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::device_type_and_name::DeviceTypeAndNameFeature,
    protocol::v20::Hidpp20Error,
};

async fn read_marketing_identity(device: &mut Device) -> Result<(), Hidpp20Error> {
    device.enumerate_features().await?;
    let Some(feature) = device.get_feature::<DeviceTypeAndNameFeature>() else {
        return Ok(());
    };

    println!("type: {:?}", feature.get_device_type().await?);
    println!("name: {}", feature.get_whole_device_name().await?);
    Ok(())
}
```


# 0x0007 · deviceFriendlyName (/hidpp/features/x0007-device-friendly-name)



`deviceFriendlyName` stores a user-assigned label. It is separate from the fixed marketing name in [`0x0005` deviceTypeAndName](/hidpp/features/x0005-device-type-and-name). The feature reads current and default names in 15-byte chunks and can write or reset the current name.

> **Spec:** No public Logitech specification. Layout reverse-engineered from Logi Options+ 2.8 (static analysis). &#x2A;*Used by:** Not used by OpenLogi.

## Function reference [#function-reference]

The registered `DeviceFriendlyNameFeature` wrapper implements feature version `0`.

### Methods [#methods]

| Function                          | HID++ fn    | Signature                      | Returns                    |
| --------------------------------- | ----------- | ------------------------------ | -------------------------- |
| `get_friendly_name_length`        | 0           | `()`                           | `DeviceFriendlyNameLength` |
| `get_friendly_name`               | 1           | `(index: u8)`                  | `[u8; 15]`                 |
| `get_whole_friendly_name`         | convenience | `()`                           | `String`                   |
| `get_default_friendly_name`       | 2           | `(index: u8)`                  | `[u8; 15]`                 |
| `get_whole_default_friendly_name` | convenience | `()`                           | `String`                   |
| `set_friendly_name`               | 3           | `(index: u8, chunk: [u8; 15])` | `u8`                       |
| `set_whole_device_name`           | convenience | `(name: String)`               | `u8`                       |
| `reset_friendly_name`             | 4           | `()`                           | `u8`                       |

All methods are asynchronous and return `Result<…, Hidpp20Error>`. Convenience methods make several HID++ calls and have no single function index.

`set_friendly_name` returns the response's echoed `charIndex`. It does not return a name length. `set_whole_device_name` returns the number of input name bytes it sent after truncation.

### Types [#types]

#### `DeviceFriendlyNameLength` [#devicefriendlynamelength]

| Field                 | Type | Description                                          |
| --------------------- | ---- | ---------------------------------------------------- |
| `name_length`         | `u8` | Current name length reported by the device.          |
| `name_max_length`     | `u8` | Maximum writable name length reported by the device. |
| `default_name_length` | `u8` | Default-name length reported by the device.          |

### Events [#events]

No event layout is documented by the available evidence. OpenLogi does not register an event handler for this feature.

## Wire format [#wire-format]

Functions 0, 1, 2, and 4 use 3-byte short requests. Function 3 uses a 16-byte long request. Byte offsets below start after the HID++ feature header.

### `get_friendly_name_length` (fn 0) [#get_friendly_name_length-fn-0]

Request: `[0x00, 0x00, 0x00]`

| Byte | Field                 | Notes                                                  |
| ---- | --------------------- | ------------------------------------------------------ |
| 0    | `name_length`         | Current friendly-name length.                          |
| 1    | `name_max_length`     | Maximum writable friendly-name length.                 |
| 2    | `default_name_length` | Default friendly-name length.                          |
| 3–15 | trailing payload      | OpenLogi ignores these bytes after `extend_payload()`. |

### `get_friendly_name` (fn 1) [#get_friendly_name-fn-1]

Request: `[char_index, 0x00, 0x00]`

| Byte | Field        | Notes                                            |
| ---- | ------------ | ------------------------------------------------ |
| 0    | `char_index` | Name offset at which the requested chunk starts. |
| 1–2  | reserved     | OpenLogi writes `0x00`.                          |

| Byte | Field               | Notes                                                                                                 |
| ---- | ------------------- | ----------------------------------------------------------------------------------------------------- |
| 0    | echoed `char_index` | OpenLogi logs a warning when this differs from the request. It keeps the chunk, as Options+ 2.8 does. |
| 1–15 | name chunk          | Fifteen name bytes. OpenLogi copies these bytes into `[u8; 15]`.                                      |

### `get_default_friendly_name` (fn 2) [#get_default_friendly_name-fn-2]

Request: `[char_index, 0x00, 0x00]`

| Byte | Field        | Notes                                                    |
| ---- | ------------ | -------------------------------------------------------- |
| 0    | `char_index` | Default-name offset at which the requested chunk starts. |
| 1–2  | reserved     | OpenLogi writes `0x00`.                                  |

| Byte | Field               | Notes                                                                                         |
| ---- | ------------------- | --------------------------------------------------------------------------------------------- |
| 0    | echoed `char_index` | OpenLogi logs a warning when this differs from the request. It keeps the chunk. Options+ 2.8. |
| 1–15 | default-name chunk  | Fifteen default-name bytes. OpenLogi copies these bytes into `[u8; 15]`.                      |

OpenLogi uses `extend_payload()` for both read functions. A short reply is zero-extended before the wrapper copies bytes 1–15. The wrapper does not reject a short reply.

### `set_friendly_name` (fn 3) [#set_friendly_name-fn-3]

Request: `[char_index, chunk[0], …, chunk[14]]`

| Byte | Field        | Notes                                                                           |
| ---- | ------------ | ------------------------------------------------------------------------------- |
| 0    | `char_index` | Name offset at which this chunk is written.                                     |
| 1–15 | `chunk`      | Exactly 15 name bytes. The whole-name helper zero-pads its final partial chunk. |

| Byte | Field               | Notes                                                                        |
| ---- | ------------------- | ---------------------------------------------------------------------------- |
| 0    | echoed `char_index` | `set_friendly_name` returns this raw byte. It is not a length. Options+ 2.8. |
| 1–15 | trailing payload    | OpenLogi ignores these bytes.                                                |

### `reset_friendly_name` (fn 4) [#reset_friendly_name-fn-4]

Request: `[0x00, 0x00, 0x00]`

| Byte | Field            | Notes                                                  |
| ---- | ---------------- | ------------------------------------------------------ |
| 0    | reported length  | `reset_friendly_name` returns this byte.               |
| 1–15 | trailing payload | OpenLogi ignores these bytes after `extend_payload()`. |

## How OpenLogi uses it [#how-openlogi-uses-it]

The feature is registered when a device feature table advertises `0x0007`. OpenLogi v0.8.15 has no device-layer, agent, IPC, CLI, or UI caller for this wrapper. It does not use the friendly name in inventory or cache identity.

The public whole-name reader obtains a length, reads chunks at its own offsets, and trims trailing NUL characters. A mismatched read echo creates a warning but does not fail the read.

The public whole-name writer reads `name_max_length`, trims the input at a UTF-8 character boundary, and sends padded chunks at offsets `0`, `15`, `30`, and so on. It uses local offsets, not response bytes. A mismatched write echo creates a warning and does not stop later chunks. The writer does not read back the stored name.

## Open questions [#open-questions]

No public Logitech specification covers this feature. The known function numbers and echo fields come from static analysis of Options+ 2.8. Hardware capture has not verified all layouts.

The available evidence does not establish a feature-version gate or a rename notification event. It also does not establish device behavior for an empty name or a name that is exactly 15 bytes. OpenLogi writes no chunk for an empty input and recommends `reset_friendly_name` to restore the default.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::device_friendly_name::DeviceFriendlyNameFeature,
    protocol::v20::Hidpp20Error,
};

async fn inspect_or_rename(device: &mut Device) -> Result<(), Hidpp20Error> {
    device.enumerate_features().await?;
    let Some(feature) = device.get_feature::<DeviceFriendlyNameFeature>() else {
        return Ok(());
    };

    println!("current name: {}", feature.get_whole_friendly_name().await?);
    let bytes_written = feature.set_whole_device_name("Desk mouse".to_owned()).await?;
    println!("sent {bytes_written} name bytes");
    Ok(())
}
```


# 0x00c2 · dfuControlSigned (/hidpp/features/x00c2-dfu-control-signed)



OpenLogi v0.8.15 lists `0x00c2` as `DfuControlSigned`, but it has no feature wrapper. G HUB static analysis names `getDFUStatus` and `startDFU`; it does not establish their HID++ function indices or packet layouts.

> **Spec:** No public Logitech specification. Operation names reverse-engineered from G HUB (static analysis); no wire layout is established. &#x2A;*Used by:** Not used by OpenLogi.

<Callout type="warn" title="Not implemented in OpenLogi">
  OpenLogi v0.8.15 recognises this feature ID in its registry, but provides no typed wrapper. It does not send, receive, or validate this feature's packets.
</Callout>

## What it does [#what-it-does]

G HUB contains operations named `getDFUStatus` and `startDFU`. Those names establish a status query and a DFU-start operation in vendor software. They do not establish the signature format, request fields, reply fields, accepted firmware images, or device lifecycle.

**Status:** Not implemented in OpenLogi v0.8.15 · &#x2A;*Feature ID:** `0x00c2`


# 0x00d0 · dfu (/hidpp/features/x00d0-dfu)



OpenLogi v0.8.15 lists `0x00d0` as `Dfu`, but it has no feature wrapper. G HUB static analysis names start, data-packet, synchronous and asynchronous send, restart, and report-handling operations; it does not establish function indices or packet layouts.

> **Spec:** No public Logitech specification. Operation names reverse-engineered from G HUB (static analysis); no wire layout is established. &#x2A;*Used by:** Not used by OpenLogi.

<Callout type="warn" title="Not implemented in OpenLogi">
  OpenLogi v0.8.15 recognises this feature ID in its registry, but provides no typed wrapper. It does not transfer firmware through this feature.
</Callout>

## What it does [#what-it-does]

G HUB contains operations named `DfuStart`, `DfuCmdData`, `DFUSendPacket`, `DFUSendPacketAsync`, `DFUSendPacketAsync_WithResult`, `Restart`, and `RestartAsync`. These names show that vendor software has a DFU workflow. They do not establish image format, packet size, packet order, verification, or reboot behavior.

**Status:** Not implemented in OpenLogi v0.8.15 · &#x2A;*Feature ID:** `0x00d0`


# 0x1000 · batteryStatus (/hidpp/features/x1000-battery-status)



`batteryStatus` is the legacy battery source for devices such as MX Master 2S.
It reports a charge reading, the next lower threshold, and a charging status.
OpenLogi selects it when `0x1004` unifiedBattery is absent.

> **Spec:** Logitech HID++ 2.0 — *x1000 batteryStatus*. &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis), Linux HID++ driver, Solaar. &#x2A;*Used by:** device inventory and `openlogi list`.

## Function reference [#function-reference]

`BatteryStatusFeature` (`0x1000`) implements one request method.

### Methods [#methods]

| Function                   | HID++ fn     | Signature           | Returns             |
| -------------------------- | ------------ | ------------------- | ------------------- |
| `get_battery_level_status` | 0            | `()`                | `LegacyBatteryInfo` |
| `is_status_broadcast`      | event helper | `(function_id: u8)` | `bool`              |

`get_battery_level_status` is `async` and returns `Result<LegacyBatteryInfo, Hidpp20Error>`.
The wrapper does not implement `getBatteryCapability` (function `1`).

### Types [#types]

#### `LegacyBatteryInfo` [#legacybatteryinfo]

| Field             | Type                  | Description                                                                                                           |
| ----------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `discharge_level` | `u8`                  | Charge reading while `status` is `Discharging`. Firmware reports coarse steps. A zero in another state means unknown. |
| `next_level`      | `u8`                  | Next lower charge step the firmware will report.                                                                      |
| `status`          | `LegacyBatteryStatus` | Current battery charging status.                                                                                      |

#### `LegacyBatteryStatus` [#legacybatterystatus]

| Variant          | Value | Description                                                   |
| ---------------- | ----- | ------------------------------------------------------------- |
| `Discharging`    | 0     | Battery is discharging.                                       |
| `Recharging`     | 1     | Battery is recharging.                                        |
| `AlmostFull`     | 2     | Battery is charging and nearly full.                          |
| `Full`           | 3     | Battery charge is complete.                                   |
| `SlowRecharge`   | 4     | Battery is recharging below its normal rate.                  |
| `InvalidBattery` | 5     | Battery type is invalid.                                      |
| `ThermalError`   | 6     | Battery subsystem reported a thermal error.                   |
| `Other`          | 7     | Other charging error. Inventory maps it to an unknown status. |

### Events [#events]

This wrapper does not implement `EmittingFeature` or expose a typed event.
`is_status_broadcast(function_id)` returns `true` only for function `0`.

## Wire format [#wire-format]

### `get_battery_level_status` (fn 0) [#get_battery_level_status-fn-0]

Request: `[0x00, 0x00, 0x00]` (no parameters).

Response:

| Byte | Field             | Notes                                                                                                            |
| ---- | ----------------- | ---------------------------------------------------------------------------------------------------------------- |
| 0    | `discharge_level` | Coarse charge reading. `0` is a valid empty reading while discharging. It means unknown in an off-battery state. |
| 1    | `next_level`      | Next lower charge step.                                                                                          |
| 2    | `status`          | `LegacyBatteryStatus`: `0`–`7` as listed above.                                                                  |

The wrapper rejects a status value outside `0`–`7` with `Hidpp20Error::UnsupportedResponse`.

### `getBatteryCapability` (fn 1, not implemented) [#getbatterycapability-fn-1-not-implemented]

Logi Options+ reads this response. OpenLogi does not send this function.

| Byte | Field                | Notes                                                                                                                         |
| ---- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| 0    | number of levels     | Number of firmware charge levels. Options+ 2.8                                                                                |
| 1    | capability flags     | Includes disable-OSD, mileage, and rechargeable information. Individual bit assignments are not documented here. Options+ 2.8 |
| 2–3  | nominal battery life | `u16`, BE. Options+ 2.8                                                                                                       |
| 4    | critical level       | Firmware critical-level value. Options+ 2.8                                                                                   |

### Status broadcast (event fn 0) [#status-broadcast-event-fn-0]

An unsolicited function-`0` report carries the same three bytes as `get_battery_level_status`.
It announces a changed reading or charging state.

## How OpenLogi uses it [#how-openlogi-uses-it]

Inventory prefers `0x1004`, then `0x1000`, then `0x1001`, then `0x1F20`.
OpenLogi maps a discharging reading to a percentage and a local level bucket.

Since v0.8.14, PR #1768 maps an off-battery zero to no percentage.
It maps `Full` with a zero reading to `100%` and `Full`.
Other off-battery zero readings become an unknown level.

OpenLogi does not read function `1`.
It therefore uses fixed `0`–`19`, `20`–`49`, `50`–`89`, and `90`–`100` display buckets.
Those buckets do not use the device capability's level count or mileage flag.

Since v0.8.14, PR #1787 records this feature's runtime index.
An unsolicited software-ID-`0`, function-`0` report queues one coalesced inventory reconciliation.
OpenLogi re-reads the authoritative state instead of trusting the event payload.

OpenLogi only reads this feature.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::battery_status::BatteryStatusFeature,
};

// `channel` and `device_index` identify an already opened HID++ device.
let mut device = Device::new(channel, device_index).await?;
device.enumerate_features().await?;

if let Some(feature) = device.get_feature::<BatteryStatusFeature>() {
    let info = feature.get_battery_level_status().await?;
    println!("level={} status={:?}", info.discharge_level, info.status);
}
```

**See also:** [0x1001 batteryVoltage](/hidpp/features/x1001-battery-voltage) and [0x1004 unifiedBattery](/hidpp/features/x1004-unified-battery).


# 0x1001 · batteryVoltage (/hidpp/features/x1001-battery-voltage)



`batteryVoltage` reports a measured battery voltage and a charging-flags byte.
G915, G903 LIGHTSPEED, and G502 LIGHTSPEED expose it without `0x1000` or `0x1004`.
The feature contains no percentage.

> **Spec:** No public Logitech specification. Layout reverse-engineered from Logi Options+ 2.8 (static analysis). &#x2A;*Cross-checked:** Linux HID++ driver, Solaar. &#x2A;*Used by:** device inventory and `openlogi list`.

## Function reference [#function-reference]

`BatteryVoltageFeature` (`0x1001`) implements one request method.

### Methods [#methods]

| Function              | HID++ fn     | Signature           | Returns              |
| --------------------- | ------------ | ------------------- | -------------------- |
| `get_battery_info`    | 0            | `()`                | `VoltageBatteryInfo` |
| `is_status_broadcast` | event helper | `(function_id: u8)` | `bool`               |

`get_battery_info` is `async` and returns `Result<VoltageBatteryInfo, Hidpp20Error>`.
The wrapper has no `show_battery_status` method for function `1`.

### Types [#types]

#### `VoltageBatteryInfo` [#voltagebatteryinfo]

| Field        | Type                    | Description                                                                                              |
| ------------ | ----------------------- | -------------------------------------------------------------------------------------------------------- |
| `voltage_mv` | `u16`                   | Battery voltage in millivolts. The code describes a single-cell Li-Po range of about `3,500`–`4,200 mV`. |
| `status`     | `VoltageChargingStatus` | Charging state decoded from the flags byte.                                                              |
| `critical`   | `bool`                  | Firmware critical-charge marker from bit `5`.                                                            |

#### `VoltageChargingStatus` [#voltagechargingstatus]

| Variant        | Description                                           |
| -------------- | ----------------------------------------------------- |
| `Discharging`  | Battery power; external-power bit is clear.           |
| `Charging`     | External power with standard charging.                |
| `ChargingFast` | External power with the fast-charge bit.              |
| `ChargingSlow` | External power with the slow-charge bit.              |
| `Full`         | External power with charge-status value `1`.          |
| `NotCharging`  | External power with charge-status value `2`.          |
| `Unknown`      | External power with an undefined charge-status value. |

### Events [#events]

This wrapper does not implement `EmittingFeature` or expose a typed event.
`is_status_broadcast(function_id)` returns `true` only for function `0`.

## Wire format [#wire-format]

### `get_battery_info` (fn 0) [#get_battery_info-fn-0]

Request: `[0x00, 0x00, 0x00]` (no parameters).

Response:

| Byte | Field        | Notes                                              |
| ---- | ------------ | -------------------------------------------------- |
| 0–1  | `voltage_mv` | `u16`, BE, in millivolts.                          |
| 2    | flags        | Link, charging, charge-rate, and critical markers. |

Flags:

| Bit or bits | Meaning                                       | OpenLogi decoding                                                                          |
| ----------- | --------------------------------------------- | ------------------------------------------------------------------------------------------ |
| 7           | External power present                        | Clear means `Discharging`; other flag bits do not change that result.                      |
| 0–2         | Charge status while external power is present | `0` is charging, `1` is `Full`, and `2` is `NotCharging`. Values `3`–`7` become `Unknown`. |
| 3           | Fast-charge marker                            | With status `0`, produces `ChargingFast`.                                                  |
| 4           | Slow-charge marker                            | With status `0` and bit `3` clear, produces `ChargingSlow`.                                |
| 5           | Critical marker                               | Exposed independently as `critical`.                                                       |
| 6           | /                                             | No meaning is modeled.                                                                     |

With external power, a low status of `0x03` means `Unknown`.
For example, the full flags byte `0x83` is not `Full`.

### `showBatteryStatus` (fn 1, not implemented) [#showbatterystatus-fn-1-not-implemented]

Logi Options+ calls function `1` and checks that it receives a nonempty reply.
Its request layout, reply layout, and physical effect are not established.
OpenLogi does not expose or call this function.

### Status broadcast (event fn 0) [#status-broadcast-event-fn-0]

An unsolicited function-`0` report carries the same three bytes as `get_battery_info`.
It announces a changed voltage or charging state.

## How OpenLogi uses it [#how-openlogi-uses-it]

Inventory selects this source only after `0x1004` and `0x1000` are absent.
It prefers this voltage source over `0x1F20` ADC measurement.

OpenLogi estimates a percentage from a 13-point single-cell Li-Po curve.
The curve clamps at `3,500 mV` for `0%` and `4,186 mV` for `100%`.
The critical flag overrides the estimated display level.

Since v0.8.14, PR #1768 maps undefined external-power status values to `Unknown`.
This includes a low status of `0x03`.
OpenLogi does not call function `1` because its effect is unverified.

Since v0.8.14, PR #1787 records this feature's runtime index.
An unsolicited software-ID-`0`, function-`0` report queues one coalesced inventory reconciliation.
OpenLogi re-reads the authoritative state instead of trusting the event payload.

OpenLogi only reads this feature.

## Open questions [#open-questions]

Logi Options+ uses `showBatteryStatus` at function `1` and requires a nonempty reply.
Static analysis does not establish whether it controls an LED, an on-screen indicator, or another device action.
OpenLogi leaves it uncalled until hardware capture establishes its effect.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::battery_voltage::BatteryVoltageFeature,
};

// `channel` and `device_index` identify an already opened HID++ device.
let mut device = Device::new(channel, device_index).await?;
device.enumerate_features().await?;

if let Some(feature) = device.get_feature::<BatteryVoltageFeature>() {
    let info = feature.get_battery_info().await?;
    println!("{} mV: {:?}", info.voltage_mv, info.status);
}
```

**See also:** [0x1000 batteryStatus](/hidpp/features/x1000-battery-status) and [0x1004 unifiedBattery](/hidpp/features/x1004-unified-battery).


# 0x1004 · unifiedBattery (/hidpp/features/x1004-unified-battery)



`unifiedBattery` is OpenLogi's preferred battery source.
It reports supported level buckets, a state-of-charge capability, and current battery status.
The device can also push a status event.

> **Spec:** Logitech HID++ 2.0 — *x1004 unifiedBattery*. &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis), Linux HID++ driver, Solaar. &#x2A;*Used by:** device inventory and `openlogi list`.

## Function reference [#function-reference]

`UnifiedBatteryFeature` (`0x1004`) provides two reads and an event listener.

### Methods [#methods]

| Function                   | HID++ fn | Signature | Returns                  |
| -------------------------- | -------- | --------- | ------------------------ |
| `get_battery_capabilities` | 0        | `()`      | `BatteryCapabilities`    |
| `get_battery_info`         | 1        | `()`      | `BatteryInfo`            |
| `listen`                   | event    | `()`      | `Receiver<BatteryEvent>` |

The two getter methods are `async` and return `Result<…, Hidpp20Error>`.
`listen` is synchronous and comes from `EmittingFeature<BatteryEvent>`.

### Types [#types]

#### `BatteryCapabilities` [#batterycapabilities]

| Field             | Type                    | Description                                                                |
| ----------------- | ----------------------- | -------------------------------------------------------------------------- |
| `reported_levels` | `HashSet<BatteryLevel>` | Firmware level variants that the feature reports.                          |
| `rechargeable`    | `bool`                  | Battery can recharge.                                                      |
| `percentage`      | `bool`                  | Device has a state-of-charge gauge for `BatteryInfo::charging_percentage`. |

#### `BatteryInfo` [#batteryinfo]

| Field                 | Type            | Description                                                                   |
| --------------------- | --------------- | ----------------------------------------------------------------------------- |
| `charging_percentage` | `u8`            | Raw status byte `0`. It is zero when the device has no state-of-charge gauge. |
| `level`               | `BatteryLevel`  | Firmware coarse battery bucket.                                               |
| `status`              | `BatteryStatus` | Firmware charging status.                                                     |

#### `BatteryLevel` [#batterylevel]

| Variant    | Value | Description             |
| ---------- | ----- | ----------------------- |
| `Critical` | 1     | Critical battery level. |
| `Low`      | 2     | Low battery level.      |
| `Good`     | 4     | Good battery level.     |
| `Full`     | 8     | Full battery level.     |

#### `BatteryStatus` [#batterystatus]

| Variant              | Value | Description                                  |
| -------------------- | ----- | -------------------------------------------- |
| `Discharging`        | 0     | Battery is discharging.                      |
| `Charging`           | 1     | Battery is charging.                         |
| `ChargingNearlyFull` | 2     | Battery is charging in its final stage.      |
| `Full`               | 3     | Battery charge is complete.                  |
| `ChargingSlow`       | 4     | Battery is recharging below its normal rate. |
| `InvalidBattery`     | 5     | Battery type is invalid.                     |
| `ThermalError`       | 6     | Battery subsystem reported a thermal error.  |
| `ChargingError`      | 7     | Battery subsystem reported a charging error. |

### Events [#events]

#### `BatteryEvent` [#batteryevent]

| Variant      | Payload       | Description                                       |
| ------------ | ------------- | ------------------------------------------------- |
| `InfoUpdate` | `BatteryInfo` | Status broadcast decoded from event function `0`. |

`BatteryEvent::decode(function_id, payload)` accepts only function `0`.
It returns `None` for an unknown level or status value.

## Wire format [#wire-format]

Both request payloads are `[0x00, 0x00, 0x00]` (no parameters).

### `get_battery_capabilities` (fn 0) [#get_battery_capabilities-fn-0]

Response:

| Byte and bit | Field                                 | Notes                             |
| ------------ | ------------------------------------- | --------------------------------- |
| 0, bit 0     | `reported_levels` contains `Critical` | Set when supported.               |
| 0, bit 1     | `reported_levels` contains `Low`      | Set when supported.               |
| 0, bit 2     | `reported_levels` contains `Good`     | Set when supported.               |
| 0, bit 3     | `reported_levels` contains `Full`     | Set when supported.               |
| 1, bit 0     | `rechargeable`                        | Set when rechargeable.            |
| 1, bit 1     | `percentage`                          | State-of-charge gauge is present. |

### `get_battery_info` (fn 1) [#get_battery_info-fn-1]

Response:

| Byte | Field                    | Notes                                                                                       |
| ---- | ------------------------ | ------------------------------------------------------------------------------------------- |
| 0    | `charging_percentage`    | Raw state of charge. Treat as a percentage only when capabilities byte `1`, bit `1` is set. |
| 1    | `level`                  | `BatteryLevel`: `1`, `2`, `4`, or `8`.                                                      |
| 2    | `status`                 | `BatteryStatus`: `0`–`7`.                                                                   |
| 3    | external-power status    | Logi Options+ exposes it. OpenLogi ignores it. Options+ 2.8                                 |
| 4    | fast-charge status       | Present for feature version `4` or later. OpenLogi ignores it. Options+ 2.8                 |
| 5    | removable-battery status | Present for feature version `5` or later. OpenLogi ignores it. Options+ 2.8                 |

OpenLogi does not preserve these read-only tail bytes.
It has no writer for this feature and therefore writes no fixed values.

### `InfoUpdate` event (event fn 0) [#infoupdate-event-event-fn-0]

The device sends an unsolicited function-`0` report when battery information changes.
The event has the same status layout as function `1`.

`BatteryEvent` parses bytes `0`–`2` only.
OpenLogi ignores bytes `3`–`5` on an event.

## How OpenLogi uses it [#how-openlogi-uses-it]

Inventory selects `0x1004` before every other battery source.
After the feature-table walk, it reads capabilities once and memoizes the state-of-charge flag.

When the state-of-charge flag is clear, inventory sets its public percentage to `None`.
The firmware level remains the authoritative display value.
If the capability read fails, OpenLogi temporarily assumes a percentage, marks capabilities incomplete, and retries on the next reconciliation.

PR #1788 shipped in v0.8.14.
It fixed the prior treatment of an unsupported state-of-charge byte as `0%`.

The persistent inventory listener accepts an unsolicited software-ID-`0` event only when bytes `0`–`2` decode.
It queues one coalesced authoritative reconciliation.
The wrapper's `listen()` remains available for direct crate users.

OpenLogi only reads this feature.
It does not model external power, fast charging, removable batteries, or version-gated tail lengths.

## Open questions [#open-questions]

Linux labels status values `2` and `4` differently from the current OpenLogi names.
Logi Options+ preserves the numeric values but its recovered enum names are incomplete.
OpenLogi uses `ChargingNearlyFull` for `2` and `ChargingSlow` for `4`.

The extended status fields are supported by static analysis, not hardware capture.
Their value sets remain unverified.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::{EmittingFeature, unified_battery::UnifiedBatteryFeature},
};

// `channel` and `device_index` identify an already opened HID++ device.
let mut device = Device::new(channel, device_index).await?;
device.enumerate_features().await?;

if let Some(feature) = device.get_feature::<UnifiedBatteryFeature>() {
    let caps = feature.get_battery_capabilities().await?;
    let info = feature.get_battery_info().await?;
    println!("gauge={} status={:?}", caps.percentage, info.status);

    let events = feature.listen();
    while let Ok(event) = events.recv().await {
        println!("battery event: {event:?}");
    }
}
```

**See also:** [0x1000 batteryStatus](/hidpp/features/x1000-battery-status) and [0x1001 batteryVoltage](/hidpp/features/x1001-battery-voltage).


# 0x1010 · chargingControl (/hidpp/features/x1010-charging-control)



OpenLogi v0.8.15 lists `0x1010` as `ChargingControl`, but it has no feature wrapper. G HUB static analysis identifies a charging-control mode getter and setter, with function indices `0` and `1`.

> **Spec:** No public Logitech specification. Function names and indices reverse-engineered from G HUB (static analysis); no wire layout is established. &#x2A;*Used by:** Not used by OpenLogi.

<Callout type="warn" title="Not implemented in OpenLogi">
  OpenLogi v0.8.15 recognises this feature ID in its registry, but provides no typed wrapper. It does not read or write the charging-control mode.
</Callout>

## What it does [#what-it-does]

G HUB names the value a charging-control mode. The available evidence does not define its values, request bytes, reply bytes, persistence, or supported devices.

## Functions [#functions]

| Function                 | fn  | Purpose                                |
| ------------------------ | --- | -------------------------------------- |
| `GetChargingControlMode` | `0` | Read the charging-control mode. G HUB  |
| `SetChargingControlMode` | `1` | Write the charging-control mode. G HUB |

The function table establishes indices only. OpenLogi does not implement or verify either packet layout.

**Status:** Not implemented in OpenLogi v0.8.15 · &#x2A;*Feature ID:** `0x1010`


# 0x1802 · deviceReset (/hidpp/features/x1802-device-reset)



OpenLogi v0.8.15 lists `0x1802` as `DeviceReset`, but it has no feature wrapper. G HUB static analysis names one operation, `forceDeviceReset`, without a function index or packet layout.

> **Spec:** No public Logitech specification. Operation name reverse-engineered from G HUB (static analysis); no wire layout is established. &#x2A;*Used by:** Not used by OpenLogi.

<Callout type="warn" title="Not implemented in OpenLogi">
  OpenLogi v0.8.15 recognises this feature ID in its registry, but provides no typed wrapper. It cannot issue a device-reset command through this feature.
</Callout>

## What it does [#what-it-does]

The G HUB operation name indicates a forced device reset. The available evidence does not establish its function index, parameters, reset target, reply, timing, or effect on stored settings.

**Status:** Not implemented in OpenLogi v0.8.15 · &#x2A;*Feature ID:** `0x1802`


# 0x1805 · oobState (/hidpp/features/x1805-oob-state)



OpenLogi v0.8.15 lists `0x1805` as `OobState`, but it has no feature wrapper. G HUB static analysis names `SetOobState`; it does not identify a getter, a function index, or a packet layout.

> **Spec:** No public Logitech specification. Operation name reverse-engineered from G HUB (static analysis); no wire layout is established. &#x2A;*Used by:** Not used by OpenLogi.

<Callout type="warn" title="Not implemented in OpenLogi">
  OpenLogi v0.8.15 recognises this feature ID in its registry, but provides no typed wrapper. It does not inspect or change OOB state.
</Callout>

## What it does [#what-it-does]

G HUB contains an operation named `SetOobState`. The available evidence establishes only that vendor software writes a state with this name. It does not establish the state meaning, value encoding, readback, persistence, or whether a read operation exists.

**Status:** Not implemented in OpenLogi v0.8.15 · &#x2A;*Feature ID:** `0x1805`


# 0x1814 · changeHost (/hidpp/features/x1814-change-host)



`changeHost` selects the active channel on a multi-host device. It reports the channel count and current zero-based channel. It also reads and writes one persistent cookie byte for each channel. OpenLogi uses this feature for configured [Easy-Switch](/docs/features/easy-switch) target coordination.

> **Spec:** Logitech HID++ 2.0 — *changeHost*. &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis), Solaar. &#x2A;*Used by:** [Easy-Switch](/docs/features/easy-switch).

## Function reference [#function-reference]

`ChangeHostFeature` implements functions 0–3. The wrapper does not expose function 4 or the unsolicited function-0 report.

### Methods [#methods]

| Function           | HID++ fn | Signature                | Returns          |
| ------------------ | -------- | ------------------------ | ---------------- |
| `get_host_info`    | 0        | `()`                     | `ChangeHostInfo` |
| `set_current_host` | 1        | `(host: u8)`             | `()`             |
| `get_cookies`      | 2        | `(host_count: u8)`       | `Vec<u8>`        |
| `set_cookie`       | 3        | `(host: u8, cookie: u8)` | `()`             |

All methods are `async` and return `Result<…, Hidpp20Error>`.

### Types [#types]

#### `ChangeHostInfo` [#changehostinfo]

`get_host_info` returns the following fields.

| Field          | Type                     | Description                                           |
| -------------- | ------------------------ | ----------------------------------------------------- |
| `host_count`   | `u8`                     | Number of available host channels.                    |
| `current_host` | `u8`                     | Active zero-based host channel.                       |
| `capabilities` | `ChangeHostCapabilities` | OpenLogi's typed representation of function-0 byte 2. |

#### `ChangeHostCapabilities` [#changehostcapabilities]

OpenLogi exposes function-0 byte 2 as this bitflag type.

| Flag                   | Bit/Value | Description                                                                                    |
| ---------------------- | --------- | ---------------------------------------------------------------------------------------------- |
| `ENHANCED_HOST_SWITCH` | `1 << 0`  | OpenLogi's name for function-0 flag bit 0. Its relationship to function-4 bit 0 is unverified. |

### Events [#events]

OpenLogi v0.8.15 exposes no typed `ChangeHost` event.

| Wire event            | HID++ fn       | Typed exposure |
| --------------------- | -------------- | -------------- |
| `ChangeHostRequested` | 0, unsolicited | Not exposed    |

## Wire format [#wire-format]

Functions 0–3 use a three-byte short request payload. OpenLogi reads replies through `extend_payload()` when a function has a reply.

### `get_host_info` (fn 0) [#get_host_info-fn-0]

Request: `[0x00, 0x00, 0x00]`.

| Byte | Field          | Notes                                                                              |
| ---- | -------------- | ---------------------------------------------------------------------------------- |
| 0    | `host_count`   | Number of host channels.                                                           |
| 1    | `current_host` | Active zero-based host channel.                                                    |
| 2    | `flags`        | Options+ 2.8 calls this field flags. OpenLogi maps it to `ChangeHostCapabilities`. |

OpenLogi retains unknown bits with `from_bits_retain`. The evidence does not establish that this byte's bit 0 equals function-4 bit 0.

### `set_current_host` (fn 1) [#set_current_host-fn-1]

OpenLogi sends this function with `notify`, so it does not await a reply.

| Byte | Field   | Notes                           |
| ---- | ------- | ------------------------------- |
| 0    | `host`  | Target zero-based host channel. |
| 1–2  | padding | OpenLogi writes `0x00`.         |

Request: `[host, 0x00, 0x00]`.

### `get_cookies` (fn 2) [#get_cookies-fn-2]

Request: `[0x00, 0x00, 0x00]`.

| Byte               | Field    | Notes                                                        |
| ------------------ | -------- | ------------------------------------------------------------ |
| 0…`host_count - 1` | `cookie` | One persistent byte for each host channel, in channel order. |

The caller supplies `host_count`. OpenLogi rejects a reply shorter than that count. Bytes after that count are ignored.

### `set_cookie` (fn 3) [#set_cookie-fn-3]

Request: `[host, cookie, 0x00]`.

| Byte | Field    | Notes                                       |
| ---- | -------- | ------------------------------------------- |
| 0    | `host`   | Zero-based host channel.                    |
| 1    | `cookie` | Arbitrary software-defined persistent byte. |
| 2    | padding  | OpenLogi writes `0x00`.                     |

OpenLogi awaits the acknowledgement and discards its payload. Firmware clearing a cookie after new pairing is not hardware-verified.

### `get_capabilities` (fn 4) [#get_capabilities-fn-4]

OpenLogi v0.8.15 does not expose this function. Options+ 2.8 calls function 4 and reads response bit 0 as a Boolean.

| Byte        | Field      | Notes                                                                                                         |
| ----------- | ---------- | ------------------------------------------------------------------------------------------------------------- |
| 0, bit 0    | capability | Options+ 2.8 reads this bit. It combines the bit with feature version 2 or later for its coupled-switch path. |
| 0, bits 1–7 | /          | No meaning was recovered.                                                                                     |
| 1…          | /          | No layout was recovered.                                                                                      |

The request has no recovered parameters. This layout is reverse-engineered from Options+ 2.8.

### `ChangeHostRequested` (unsolicited fn 0) [#changehostrequested-unsolicited-fn-0]

This report shares function 0 with `get_host_info`. Options+ 2.8 accepts an unsolicited report only with software ID 0 and at least two payload bytes.

| Byte | Field  | Notes                              |
| ---- | ------ | ---------------------------------- |
| 0    | `from` | Source zero-based host channel.    |
| 1    | `to`   | Requested zero-based host channel. |
| 2…   | /      | No meaning was recovered.          |

Options+ 2.8 treats this report as a host-change request. Solaar has reported the notification from hardware. OpenLogi v0.8.15 does not decode or forward it.

## How OpenLogi uses it [#how-openlogi-uses-it]

OpenLogi uses `ChangeHost` when `host_switch_targets` links a keyboard to target devices. The session temporarily configures reportable host controls through `0x1b04`. It snapshots the changed reporting fields and restores them on exit. A failed restoration remains pending for retry.

Before function 1, OpenLogi reads function 0 for each device. It rejects a requested channel outside `0..host_count`. It then consults `0x1815` when available. Only an explicit `Empty` status with `name_len == 0` blocks the change. An unreadable status remains advisory.

OpenLogi prepares target devices first and sends the keyboard's function 1 last. This preserves a shared receiver channel until target moves are attempted. The v0.8.15 device layer does not use cookie functions, function 4, or `ChangeHostRequested` reports.

## Open questions [#open-questions]

Options+ 2.8 calls function 0 byte 2 `flags`. It separately calls function 4 and reads bit 0. OpenLogi exposes the function-0 byte as `ChangeHostCapabilities::ENHANCED_HOST_SWITCH`. No public bit definition or hardware capture proves that the two bits mean the same thing.

Options+ 2.8 and Solaar provide evidence for `ChangeHostRequested`. Static analysis cannot show which models emit it or whether every switch emits it. OpenLogi v0.8.15 instead observes temporary `0x1b04` host-control reports.

Options+ 2.8 can choose a synchronous or asynchronous function-1 path. OpenLogi always sends a notification. No evidence establishes that a synchronous call is required.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::feature::change_host::ChangeHostFeature;

// `device` is an initialized mutable HID++ device.
device.enumerate_features().await?;
if let Some(feature) = device.get_feature::<ChangeHostFeature>() {
    let info = feature.get_host_info().await?;
    let target = 1;

    if target < info.host_count && target != info.current_host {
        feature.set_current_host(target).await?;
    }
}
```


# 0x1815 · hostsInfo (/hidpp/features/x1815-hosts-info)



`hostsInfo` describes the host channels stored by a multi-host device. It reports channel pairing, connection type, name metadata, and raw descriptor pages. It also provides paged host-name and operating-system data. OpenLogi v0.8.15 reads this data and can rename a paired channel.

> **Spec:** No public Logitech specification. Layout reverse-engineered from Logi Options+ 2.8 (static analysis). &#x2A;*Cross-checked:** Solaar. &#x2A;*Used by:** [Easy-Switch](/docs/features/easy-switch).

## Function reference [#function-reference]

`HostsInfoFeature` requires advertised feature version 2. It implements functions 0–4 and 7. Functions 5, 6, and 8 have no v0.8.15 wrapper.

### Methods [#methods]

| Function                 | HID++ fn | Signature                                                         | Returns                |
| ------------------------ | -------- | ----------------------------------------------------------------- | ---------------------- |
| `get_feature_info`       | 0        | `()`                                                              | `HostsInfoFeatureInfo` |
| `get_host_info`          | 1        | `(host: HostIndex)`                                               | `HostInfo`             |
| `get_host_descriptor`    | 2        | `(host: HostIndex, page: u8)`                                     | `HostDescriptorPage`   |
| `get_host_friendly_name` | 3        | `(host: HostIndex, offset: u8)`                                   | `HostNameChunk`        |
| `set_host_friendly_name` | 4        | `(host: HostIndex, offset: u8, bytes: [u8; HOST_NAME_CHUNK_LEN])` | `()`                   |
| `get_host_os_version`    | 7        | `(host: HostIndex)`                                               | `HostOsVersion`        |

All methods are `async` and return `Result<…, Hidpp20Error>`.

### Types [#types]

#### `HostsInfoFeatureInfo` [#hostsinfofeatureinfo]

Function 0 returns static information for this feature.

| Field                     | Type                         | Description                            |
| ------------------------- | ---------------------------- | -------------------------------------- |
| `capabilities`            | `HostsInfoCapabilities`      | High byte of the wire capability mask. |
| `descriptor_capabilities` | `HostDescriptorCapabilities` | Low byte of the wire capability mask.  |
| `host_count`              | `u8`                         | Number of host channels.               |
| `current_host`            | `HostIndex`                  | Current host selector or `Current`.    |

#### `HostInfo` [#hostinfo]

Function 1 returns one host-channel record.

| Field          | Type             | Description                        |
| -------------- | ---------------- | ---------------------------------- |
| `host_index`   | `HostIndex`      | Selector echoed by the device.     |
| `status`       | `HostSlotStatus` | Empty or paired state.             |
| `bus_type`     | `HostBusType`    | Connection type.                   |
| `page_count`   | `u8`             | Number of descriptor pages.        |
| `name_len`     | `u8`             | Current host-name length in bytes. |
| `name_max_len` | `u8`             | Maximum host-name length in bytes. |

#### `HostDescriptorPage` [#hostdescriptorpage]

Function 2 returns one raw descriptor page.

| Field        | Type          | Description                                    |
| ------------ | ------------- | ---------------------------------------------- |
| `host_index` | `HostIndex`   | Selector echoed by the device.                 |
| `bus_type`   | `HostBusType` | OpenLogi's decode of the header's high nibble. |
| `page_index` | `u8`          | OpenLogi's decode of the header's low nibble.  |
| `body`       | `[u8; 14]`    | Raw descriptor bytes.                          |

#### `HostNameChunk` [#hostnamechunk]

Function 3 returns a fixed name chunk.

| Field        | Type        | Description                          |
| ------------ | ----------- | ------------------------------------ |
| `host_index` | `HostIndex` | Selector echoed by the device.       |
| `offset`     | `u8`        | Byte offset echoed by the device.    |
| `bytes`      | `[u8; 14]`  | Name bytes and any trailing padding. |

`HOST_NAME_CHUNK_LEN` is `14`.

#### `HostOsVersion` [#hostosversion]

Function 7 returns the recorded operating system and version.

| Field        | Type         | Description                     |
| ------------ | ------------ | ------------------------------- |
| `host_index` | `HostIndex`  | Selector echoed by the device.  |
| `os_type`    | `HostOsType` | Recorded operating-system type. |
| `major`      | `u8`         | Major version.                  |
| `minor`      | `u16`        | Minor version, decoded as BE.   |
| `patch`      | `u16`        | Patch version, decoded as BE.   |

#### `HostIndex` [#hostindex]

| Variant    | Value     | Description                           |
| ---------- | --------- | ------------------------------------- |
| `Current`  | `0xFF`    | Device-selected current host channel. |
| `Slot(u8)` | `0`–`254` | Explicit zero-based host channel.     |

#### `HostSlotStatus` [#hostslotstatus]

| Variant  | Value | Description                 |
| -------- | ----- | --------------------------- |
| `Empty`  | `0`   | Channel has no paired host. |
| `Paired` | `1`   | Channel has a paired host.  |

#### `HostBusType` [#hostbustype]

| Variant     | Value | Description                      |
| ----------- | ----- | -------------------------------- |
| `Undefined` | `0`   | Undefined or unknown connection. |
| `Equad`     | `1`   | eQuad wireless connection.       |
| `Usb`       | `2`   | USB connection.                  |
| `Bt`        | `3`   | Bluetooth Classic connection.    |
| `Ble`       | `4`   | Bluetooth Low Energy connection. |
| `BlePro`    | `5`   | BLE Pro / Logi Bolt connection.  |

#### `HostOsType` [#hostostype]

| Variant           | Value | Description                   |
| ----------------- | ----- | ----------------------------- |
| `Unknown`         | `0`   | No recorded operating system. |
| `Windows`         | `1`   | Windows.                      |
| `WindowsEmbedded` | `2`   | Windows Embedded.             |
| `Linux`           | `3`   | Linux.                        |
| `Chrome`          | `4`   | ChromeOS.                     |
| `Android`         | `5`   | Android.                      |
| `MacOs`           | `6`   | macOS.                        |
| `Ios`             | `7`   | iOS or iPadOS.                |
| `WebOs`           | `8`   | webOS.                        |
| `Tizen`           | `9`   | Tizen.                        |

#### `HostsInfoCapabilities` [#hostsinfocapabilities]

These named flags occupy the high byte of the BE capability mask.

| Flag             | Bit/Value           | Description                                                                  |
| ---------------- | ------------------- | ---------------------------------------------------------------------------- |
| `GET_NAME`       | `1 << 0` / `0x0100` | Host names can be read. Solaar cross-checks this flag.                       |
| `SET_NAME`       | `1 << 1` / `0x0200` | Host names can be written. Options+ 2.8 gates renaming on this flag.         |
| `MOVE_HOST`      | `1 << 2` / `0x0400` | Host slots can be moved. Options+ 2.8 gates function 5 on this flag.         |
| `DELETE_HOST`    | `1 << 3` / `0x0800` | Host slots can be deleted. Options+ 2.8 gates function 6 on this flag.       |
| `SET_OS_VERSION` | `1 << 4` / `0x1000` | Host OS versions can be written. Options+ 2.8 gates function 8 on this flag. |

#### `HostDescriptorCapabilities` [#hostdescriptorcapabilities]

OpenLogi assigns these flags to the capability mask's low byte.

| Flag    | Bit/Value           | Description                                             |
| ------- | ------------------- | ------------------------------------------------------- |
| `EQUAD` | `1 << 0` / `0x0001` | OpenLogi's eQuad descriptor-family flag.                |
| `USB`   | `1 << 1` / `0x0002` | OpenLogi's USB descriptor-family flag.                  |
| `BT`    | `1 << 2` / `0x0004` | OpenLogi's Bluetooth Classic descriptor-family flag.    |
| `BLE`   | `1 << 3` / `0x0008` | OpenLogi's Bluetooth Low Energy descriptor-family flag. |

The low-byte names are not independently confirmed by a public specification.

### Events [#events]

OpenLogi v0.8.15 exposes no typed `HostsInfo` events. The reviewed evidence establishes no unsolicited `0x1815` report layout.

## Wire format [#wire-format]

The feature uses three-byte short requests except function 4. Function 4 uses a 16-byte long request. OpenLogi reads replies through `extend_payload()`.

### `get_feature_info` (fn 0) [#get_feature_info-fn-0]

Request: `[0x00, 0x00, 0x00]`.

| Byte | Field                     | Notes                                                                     |
| ---- | ------------------------- | ------------------------------------------------------------------------- |
| 0–1  | `capability_mask`         | One BE `u16`. OpenLogi exposes byte 0 and byte 1 as separate bitflags.    |
| 0    | `capabilities`            | High byte of `capability_mask`. Unknown bits are retained.                |
| 1    | `descriptor_capabilities` | Low byte of `capability_mask`. Unknown bits are retained.                 |
| 2    | `host_count`              | Number of host channels.                                                  |
| 3    | `current_host`            | `0xFF` becomes `HostIndex::Current`; every other value becomes `Slot(n)`. |

The OpenLogi fields preserve the complete mask. Reconstruct it with `u16::from_be_bytes([capabilities.bits(), descriptor_capabilities.bits()])`.

### `get_host_info` (fn 1) [#get_host_info-fn-1]

Request: `[host, 0x00, 0x00]`. `host` is `0xFF` for `HostIndex::Current`, or a zero-based channel number.

| Byte | Field          | Notes                                                                                                   |
| ---- | -------------- | ------------------------------------------------------------------------------------------------------- |
| 0    | `host_index`   | Echoed selector.                                                                                        |
| 1    | `status`       | `0` is `Empty`; `1` is `Paired`. Other values fail decoding.                                            |
| 2    | `bus_type`     | `0`=`Undefined`, `1`=`Equad`, `2`=`Usb`, `3`=`Bt`, `4`=`Ble`, `5`=`BlePro`. Other values fail decoding. |
| 3    | `page_count`   | Number of descriptor pages.                                                                             |
| 4    | `name_len`     | Current host-name length in bytes.                                                                      |
| 5    | `name_max_len` | Maximum host-name length in bytes.                                                                      |

### `get_host_descriptor` (fn 2) [#get_host_descriptor-fn-2]

Request: `[host, page, 0x00]`.

| Byte        | Field        | Notes                                                                          |
| ----------- | ------------ | ------------------------------------------------------------------------------ |
| 0           | `host_index` | Echoed selector.                                                               |
| 1, bits 7–4 | `bus_type`   | OpenLogi decodes the high nibble as `HostBusType`. This meaning is unverified. |
| 1, bits 3–0 | `page_index` | OpenLogi decodes the low nibble as the page index. This meaning is unverified. |
| 2–15        | `body`       | Fourteen raw descriptor bytes.                                                 |

### `get_host_friendly_name` (fn 3) [#get_host_friendly_name-fn-3]

Request: `[host, offset, 0x00]`. `offset` is a byte offset, not a page number.

| Byte | Field        | Notes                                                    |
| ---- | ------------ | -------------------------------------------------------- |
| 0    | `host_index` | Echoed selector.                                         |
| 1    | `offset`     | Echoed byte offset.                                      |
| 2–15 | `bytes`      | Up to 14 name bytes, followed by padding when necessary. |

The wrapper returns all 14 bytes. The device layer reads chunks until `name_len`. It verifies the returned selector and offset before joining chunks.

### `set_host_friendly_name` (fn 4) [#set_host_friendly_name-fn-4]

OpenLogi sends a 16-byte long request and awaits the acknowledgement.

| Byte | Field    | Notes                                  |
| ---- | -------- | -------------------------------------- |
| 0    | `host`   | Explicit zero-based host channel.      |
| 1    | `offset` | Byte offset into the host-name buffer. |
| 2–15 | `bytes`  | Exactly 14 name or padding bytes.      |

The raw wrapper writes the supplied bytes unchanged. The device layer writes every `name_max_len` byte in 14-byte chunks. It pads the final chunk with zero bytes, which clears an older name tail.

### `move_host` (fn 5) [#move_host-fn-5]

OpenLogi v0.8.15 does not expose this function. Options+ 2.8 uses two request bytes for the host move.

| Byte | Field            | Notes                                   |
| ---- | ---------------- | --------------------------------------- |
| 0    | source host      | Source host channel. Options+ 2.8.      |
| 1    | destination host | Destination host channel. Options+ 2.8. |
| 2    | /                | No meaning was recovered.               |

Options+ 2.8 gates this operation on mask bit `0x0400`. The acknowledgement layout was not recovered.

### `delete_host` (fn 6) [#delete_host-fn-6]

OpenLogi v0.8.15 does not expose this function. Options+ 2.8 deletes one host channel.

| Byte | Field  | Notes                                 |
| ---- | ------ | ------------------------------------- |
| 0    | `host` | Host channel to delete. Options+ 2.8. |
| 1–2  | /      | No meaning was recovered.             |

Options+ 2.8 gates this operation on mask bit `0x0800`. The acknowledgement layout was not recovered.

### `get_host_os_version` (fn 7) [#get_host_os_version-fn-7]

Request: `[host, 0x00, 0x00]`.

| Byte | Field        | Notes                                             |
| ---- | ------------ | ------------------------------------------------- |
| 0    | `host_index` | Echoed selector.                                  |
| 1    | `os_type`    | `HostOsType` value. Unknown values fail decoding. |
| 2    | `major`      | Major OS version.                                 |
| 3–4  | `minor`      | BE `u16`.                                         |
| 5–6  | `patch`      | BE `u16`.                                         |

### `set_host_os_version` (fn 8) [#set_host_os_version-fn-8]

OpenLogi v0.8.15 does not expose this function. Options+ 2.8 writes the following seven bytes.

| Byte | Field     | Notes                                |
| ---- | --------- | ------------------------------------ |
| 0    | `host`    | Host channel. Options+ 2.8.          |
| 1    | `os_type` | Operating-system type. Options+ 2.8. |
| 2    | `major`   | Major OS version. Options+ 2.8.      |
| 3–4  | `minor`   | BE `u16`. Options+ 2.8.              |
| 5–6  | `patch`   | BE `u16`. Options+ 2.8.              |
| 7…   | /         | No layout was recovered.             |

Options+ 2.8 gates this operation on mask bit `0x1000`.

## How OpenLogi uses it [#how-openlogi-uses-it]

OpenLogi requires `HostsInfo` version 2 before it creates the wrapper. For an Easy-Switch read, it gets function 0 and reads function 1 for every index in `0..host_count`. It reads functions 3 and 7 only for paired channels. It maps the bus type to the user-facing connection type.

A device can decline functions 3 or 7. OpenLogi then leaves the name or operating system absent. A timeout, a transport failure, or a mismatched function-3 or function-7 echo fails the complete read.

A rename first reads the complete channel table. OpenLogi requires `SET_NAME`, a paired target channel, a non-empty trimmed UTF-8 name, and a byte length within `name_max_len`. It writes the full zero-padded name buffer with function 4. It then reads the table again and rejects a name that the device did not keep.

OpenLogi never automatically writes a host name or OS version. It does not call functions 5, 6, or 8. The user-facing [Easy-Switch](/docs/features/easy-switch) page describes the card and Automation API.

## Open questions [#open-questions]

Options+ 2.8 reads bytes 0–1 of function 0 as one BE `u16` mask. OpenLogi exposes the same two bytes as two `u8` bitflag sets, so no wire bits are lost. The semantic names for the low-byte descriptor flags remain unverified.

OpenLogi decodes the function-2 header as a bus-type nibble and a page-index nibble. The reviewed static evidence does not independently establish those nibble meanings.

Options+ 2.8 establishes functions 5, 6, and 8 and their observed request fields. No public specification or hardware capture confirms their remaining bytes, acknowledgements, or device support. OpenLogi intentionally leaves these persistent operations unavailable.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::feature::hosts_info::{HostIndex, HostSlotStatus, HostsInfoFeature};

// `device` is an initialized mutable HID++ device.
device.enumerate_features().await?;
if let Some(feature) = device.get_feature::<HostsInfoFeature>() {
    let feature_info = feature.get_feature_info().await?;

    for slot in 0..feature_info.host_count {
        let host = feature.get_host_info(HostIndex::Slot(slot)).await?;
        if host.status == HostSlotStatus::Paired {
            let name = feature
                .get_host_friendly_name(HostIndex::Slot(slot), 0)
                .await?;
            let os = feature.get_host_os_version(HostIndex::Slot(slot)).await?;
            println!("slot {slot}: {:?}, {:?}", name.bytes, os.os_type);
        }
    }
}
```


# 0x1981 · backlight (/hidpp/features/x1981-backlight1)



`0x1981` is the first-generation HID++ keyboard-backlight feature. OpenLogi lists it as `Backlight1`, separately from `0x1982` and `0x1983`. It has no typed wrapper at v0.8.15.

> **Spec:** No public Logitech specification. Function names reverse-engineered from Logi Options+ 2.8 (static analysis). &#x2A;*Used by:** Not used by OpenLogi.

<Callout type="warn" title="Not implemented in OpenLogi">
  OpenLogi registers the `0x1981` name only. It has no typed wrapper, device-layer operation, CLI command, agent path, or user-interface control.
</Callout>

## What it does [#what-it-does]

Logi Options+ 2.8 static analysis identifies separate duration read, duration write, and change-event handling for `0x1981`. That evidence confirms that it is distinct from `0x1982`. It does not establish function indices, byte layouts, value ranges, or hardware behavior.

OpenLogi does not fall back from `0x1982` to `0x1981`. A keyboard with only `0x1981` receives no backlight control. See [Keyboard lighting](/docs/features/keyboard/lighting) for the supported `0x1982` path.

**Status:** Not implemented in OpenLogi v0.8.15 · &#x2A;*Feature ID:** `0x1981` · &#x2A;*See also:** [`0x1982` backlight](/hidpp/features/x1982-backlight)


# 0x1982 · backlight (/hidpp/features/x1982-backlight)



`0x1982` controls a keyboard's white, level-adjustable backlight. OpenLogi implements a version-3 typed wrapper and a device-layer control path. The path exposes on/off, automatic brightness, a held brightness level, and battery saving when the keyboard advertises each control.

> **Spec:** No public Logitech specification. Layout reverse-engineered from Logi Options+ 2.8 (static analysis). &#x2A;*Cross-checked:** Solaar. &#x2A;*Used by:** [Keyboard lighting](/docs/features/keyboard/lighting) and [On-screen notices](/docs/features/notifications).

## Function reference [#function-reference]

`BacklightFeature` implements HID++ `0x1982` from version 3. Every asynchronous method returns `Result<…, Hidpp20Error>`.

### Methods [#methods]

| Function                  | HID++ fn | Signature                             | Returns                                   |
| ------------------------- | -------- | ------------------------------------- | ----------------------------------------- |
| `get_backlight_config`    | 0        | `(&self)`                             | `BacklightConfig`                         |
| `set_backlight_config`    | 1        | `(&self, config: SetBacklightConfig)` | `()`                                      |
| `get_backlight_info`      | 2        | `(&self)`                             | `BacklightInfo`                           |
| `set_backlight_effect`    | 3        | `(&self, effect: BacklightEffect)`    | `()`                                      |
| `EmittingFeature::listen` | event    | `(&self)`                             | `async_channel::Receiver<BacklightEvent>` |

### Types [#types]

#### `BacklightConfig` [#backlightconfig]

`get_backlight_config` returns this persistent configuration.

| Field                  | Type                  | Description                                                                                                  |
| ---------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------ |
| `enabled`              | `bool`                | Whether the backlight system is enabled.                                                                     |
| `options`              | `BacklightOptions`    | Enabled flags, capability flags, and retained unknown bits.                                                  |
| `mode`                 | `BacklightMode`       | The decoded two-bit mode.                                                                                    |
| `effect_list`          | `BacklightEffectList` | The effects advertised by the keyboard.                                                                      |
| `current_level`        | `u8`                  | The stored manual level.                                                                                     |
| `duration_hands_out`   | `u16`                 | First fade-out duration, in 5-second units.                                                                  |
| `duration_hands_in`    | `u16`                 | Second fade-out duration, in 5-second units.                                                                 |
| `duration_powered`     | `u16`                 | Third fade-out duration, in 5-second units.                                                                  |
| `duration_not_powered` | `u16`                 | Fourth fade-out duration, in 5-second units. Options+ 2.8 calls it “not powered”; its trigger is unverified. |

#### `SetBacklightConfig` [#setbacklightconfig]

`set_backlight_config` persistently writes this configuration.

| Field                  | Type                      | Description                                          |
| ---------------------- | ------------------------- | ---------------------------------------------------- |
| `enabled`              | `bool`                    | The requested master switch state.                   |
| `options`              | `BacklightOptions`        | Only `WOW`, `CROWN`, and `PWR_SAVE` are sent.        |
| `mode`                 | `BacklightMode`           | The mode encoded into request bits 3–4.              |
| `effect`               | `Option<BacklightEffect>` | `None` sends `0xff` and leaves the effect unchanged. |
| `current_level`        | `u8`                      | The requested manual level.                          |
| `duration_hands_out`   | `u16`                     | First fade-out duration, in 5-second units.          |
| `duration_hands_in`    | `u16`                     | Second fade-out duration, in 5-second units.         |
| `duration_powered`     | `u16`                     | Third fade-out duration, in 5-second units.          |
| `duration_not_powered` | `u16`                     | Fourth fade-out duration, in 5-second units.         |

#### `BacklightInfo` [#backlightinfo]

`get_backlight_info` returns live state and out-of-box durations.

| Field                      | Type              | Description                                             |
| -------------------------- | ----------------- | ------------------------------------------------------- |
| `nb_levels`                | `u8`              | Number of selectable levels, including level `0`.       |
| `current_level`            | `u8`              | The currently shown level.                              |
| `status`                   | `BacklightStatus` | The current operating state.                            |
| `effect`                   | `BacklightEffect` | The currently applied effect.                           |
| `oob_duration_hands_out`   | `u16`             | First out-of-box fade-out duration, in 5-second units.  |
| `oob_duration_hands_in`    | `u16`             | Second out-of-box fade-out duration, in 5-second units. |
| `oob_duration_powered`     | `u16`             | Third out-of-box fade-out duration, in 5-second units.  |
| `oob_duration_not_powered` | `u16`             | Fourth out-of-box fade-out duration, in 5-second units. |

#### `BacklightMode` [#backlightmode]

| Variant           | Value | Description                                             |
| ----------------- | ----- | ------------------------------------------------------- |
| `None`            | 0     | No selected mode.                                       |
| `Automatic`       | 1     | The ambient-light sensor selects the level.             |
| `TemporaryManual` | 2     | The keyboard's backlight keys select a temporary level. |
| `PermanentManual` | 3     | Software selects and holds a level.                     |

`BacklightMode` is `#[non_exhaustive]`. The low-level setter accepts every listed variant. OpenLogi's device layer does not write `TemporaryManual`; it substitutes `Automatic` before a device-layer write.

#### `BacklightEffect` [#backlighteffect]

| Variant     | Value | Description       |
| ----------- | ----- | ----------------- |
| `Static`    | 0     | Static effect.    |
| `None`      | 1     | None effect.      |
| `Breathing` | 2     | Breathing effect. |
| `Contrast`  | 3     | Contrast effect.  |
| `Reaction`  | 4     | Reaction effect.  |
| `Random`    | 5     | Random effect.    |
| `Waves`     | 6     | Waves effect.     |

`BacklightEffect` is `#[non_exhaustive]`.

#### `BacklightStatus` [#backlightstatus]

| Variant                     | Value | Description                                                   |
| --------------------------- | ----- | ------------------------------------------------------------- |
| `DisabledBySoftware`        | 0     | Software disabled the backlight.                              |
| `DisabledByCriticalBattery` | 1     | The battery is critically low.                                |
| `AlsAutomatic`              | 2     | The ambient-light sensor controls the level.                  |
| `AlsSaturated`              | 3     | The sensor reports enough ambient light to keep the LEDs off. |
| `TemporaryManual`           | 4     | The keyboard backlight keys set the level.                    |
| `PermanentManual`           | 5     | Software set the level.                                       |

`BacklightStatus` is `#[non_exhaustive]`.

#### `BacklightOptions` [#backlightoptions]

The wrapper retains unknown bits when it reads this `u16` bitfield. A configuration write sends only the three writable low bits.

| Flag                    | Bit | Description                                       |
| ----------------------- | --- | ------------------------------------------------- |
| `WOW`                   | 0   | Enable the WOW power-on effect.                   |
| `CROWN`                 | 1   | Enable the CROWN touch effect.                    |
| `PWR_SAVE`              | 2   | Enable low-battery backlight saving.              |
| `WOW_SUPPORTED`         | 8   | The keyboard advertises WOW support.              |
| `CROWN_SUPPORTED`       | 9   | The keyboard advertises CROWN support.            |
| `PWR_SAVE_SUPPORTED`    | 10  | The keyboard advertises battery-saving support.   |
| `AUTO_MODE_SUPPORTED`   | 11  | The keyboard advertises automatic-mode support.   |
| `TEMP_MANUAL_SUPPORTED` | 12  | The keyboard advertises temporary-manual support. |
| `PERM_MANUAL_SUPPORTED` | 13  | The keyboard advertises permanent-manual support. |

#### `BacklightEffectList` [#backlighteffectlist]

This `u16` bitfield retains unknown bits when it is read.

| Flag        | Bit | Description       |
| ----------- | --- | ----------------- |
| `STATIC`    | 0   | Static effect.    |
| `NONE`      | 1   | None effect.      |
| `BREATHING` | 2   | Breathing effect. |
| `CONTRAST`  | 3   | Contrast effect.  |
| `REACTION`  | 4   | Reaction effect.  |
| `RANDOM`    | 5   | Random effect.    |
| `WAVES`     | 6   | Waves effect.     |

### Events [#events]

`BacklightFeature` implements `EmittingFeature<BacklightEvent>`. The event source accepts unsolicited reports at function 0 only.

| Variant       | Payload               | Description                                  |
| ------------- | --------------------- | -------------------------------------------- |
| `InfoChanged` | `BacklightInfoUpdate` | A backlight status, level, or effect update. |

`BacklightInfoUpdate` has `nb_levels: u8`, `current_level: u8`, `status: BacklightStatus`, and `effect: BacklightEffect`.

## Wire format [#wire-format]

Getter requests use a 3-byte short payload of zeroes. `set_backlight_config` uses a 16-byte long payload. Every multi-byte field below is LE.

OpenLogi requires a long reply for functions 0 and 2. It rejects a short reply rather than zero-filling a configuration that can later be written back. It awaits but ignores response payloads for functions 1 and 3.

### `get_backlight_config` (fn 0) [#get_backlight_config-fn-0]

Request: `[0x00, 0x00, 0x00]`.

| Byte  | Field                  | Notes                                                                                              |
| ----- | ---------------------- | -------------------------------------------------------------------------------------------------- |
| 0     | `enabled`              | Bit 0 is the master switch.                                                                        |
| 1–2   | options and mode       | LE `u16`. Bits 0–2 are writable options. OpenLogi decodes bits 3–4 as `mode`. Bit 5 is unresolved. |
| 3–4   | `effect_list`          | LE `u16` `BacklightEffectList`.                                                                    |
| 5     | `current_level`        | Stored manual level.                                                                               |
| 6–7   | `duration_hands_out`   | LE `u16`, in 5-second units.                                                                       |
| 8–9   | `duration_hands_in`    | LE `u16`, in 5-second units.                                                                       |
| 10–11 | `duration_powered`     | LE `u16`, in 5-second units.                                                                       |
| 12–13 | `duration_not_powered` | LE `u16`, in 5-second units. Options+ 2.8.                                                         |
| 14–15 | unknown                | Not modeled or read by OpenLogi.                                                                   |

### `set_backlight_config` (fn 1) [#set_backlight_config-fn-1]

The request is a 16-byte long payload.

| Byte  | Field                  | Notes                                                                                                         |
| ----- | ---------------------- | ------------------------------------------------------------------------------------------------------------- |
| 0     | `enabled`              | `0` disables and `1` enables.                                                                                 |
| 1     | options and mode       | `(options & 0x07) \| (mode << 3)`. Only `WOW`, `CROWN`, and `PWR_SAVE` are written. Bit 5 is written as zero. |
| 2     | `effect`               | Effect value, or `0xff` for `None`.                                                                           |
| 3     | `current_level`        | Requested manual level.                                                                                       |
| 4–5   | `duration_hands_out`   | LE `u16`, in 5-second units.                                                                                  |
| 6–7   | `duration_hands_in`    | LE `u16`, in 5-second units.                                                                                  |
| 8–9   | `duration_powered`     | LE `u16`, in 5-second units.                                                                                  |
| 10–11 | `duration_not_powered` | LE `u16`, in 5-second units. Options+ 2.8.                                                                    |
| 12–15 | padding                | OpenLogi writes zero. The available evidence assigns no meaning.                                              |

### `get_backlight_info` (fn 2) [#get_backlight_info-fn-2]

Request: `[0x00, 0x00, 0x00]`.

| Byte  | Field                      | Notes                                       |
| ----- | -------------------------- | ------------------------------------------- |
| 0     | `nb_levels`                | Number of selectable levels, including `0`. |
| 1     | `current_level`            | Current level.                              |
| 2     | `status`                   | `BacklightStatus` value.                    |
| 3     | `effect`                   | `BacklightEffect` value.                    |
| 4–5   | `oob_duration_hands_out`   | LE `u16`, in 5-second units.                |
| 6–7   | `oob_duration_hands_in`    | LE `u16`, in 5-second units.                |
| 8–9   | `oob_duration_powered`     | LE `u16`, in 5-second units.                |
| 10–11 | `oob_duration_not_powered` | LE `u16`, in 5-second units. Options+ 2.8.  |
| 12–15 | unknown                    | Not modeled or read by OpenLogi.            |

### `set_backlight_effect` (fn 3) [#set_backlight_effect-fn-3]

Request: `[effect, 0x00, 0x00]`. Byte 0 is a `BacklightEffect` value. The effect is temporary and stored in RAM, not persistent storage.

### Event: `backlightInfoEvent` (fn 0) [#event-backlightinfoevent-fn-0]

OpenLogi decodes event function 0 as `BacklightEvent::InfoChanged`. The available evidence establishes the first four payload bytes. OpenLogi ignores the remainder.

| Byte | Field           | Notes                           |
| ---- | --------------- | ------------------------------- |
| 0    | `nb_levels`     | Number of selectable levels.    |
| 1    | `current_level` | Current level after the update. |
| 2    | `status`        | `BacklightStatus` value.        |
| 3    | `effect`        | `BacklightEffect` value.        |
| 4–15 | unknown         | OpenLogi ignores these bytes.   |

## How OpenLogi uses it [#how-openlogi-uses-it]

OpenLogi reads configuration and live information before it shows the Backlight card or accepts a change. It derives automatic brightness from automatic and permanent-manual capability bits. It enables level selection only at feature version 3 or later, with more than one level. It enables battery saving only when `PWR_SAVE_SUPPORTED` is set.

Each accepted change uses whole-configuration read-modify-write. OpenLogi preserves the effect with `0xff`, all four durations, and other options read from the keyboard. Since v0.8.14, this includes `duration_not_powered` and rejects short configuration and information replies. It reads back the configuration and information after a write, then rejects a change that the keyboard did not apply.

When automatic brightness is turned off, OpenLogi holds the level from the live information reply. A device-layer on/off or battery-saving write maps `TemporaryManual` to `Automatic`. The configuration is in keyboard non-volatile memory, so OpenLogi has no exit-time restore step.

An inventory-owned listener recognizes function-0 broadcasts. It increments the keyboard settings revision for a refresh and forwards the current level to the on-screen indicator. The indicator is visible for `1,500 ms`; see [On-screen notices](/docs/features/notifications).

## Open questions [#open-questions]

Options+ 2.8 treats option-byte bits 3–5 as a three-bit mode. Solaar and OpenLogi decode bits 3–4 only. OpenLogi retains bit 5 in `BacklightOptions` after reading, but writes it as zero. No hardware capture or public feature specification decides which interpretation is correct.

Options+ 2.8 calls the fourth duration “not powered.” The byte ownership is verified, but the condition that applies this duration is not hardware-verified. The available evidence also does not establish the layouts of feature versions 0–2.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    feature::backlight::{BacklightFeature, SetBacklightConfig},
    protocol::v20::Hidpp20Error,
};

async fn enable(feature: &BacklightFeature) -> Result<(), Hidpp20Error> {
    let config = feature.get_backlight_config().await?;

    feature
        .set_backlight_config(SetBacklightConfig {
            enabled: true,
            options: config.options,
            mode: config.mode,
            effect: None,
            current_level: config.current_level,
            duration_hands_out: config.duration_hands_out,
            duration_hands_in: config.duration_hands_in,
            duration_powered: config.duration_powered,
            duration_not_powered: config.duration_not_powered,
        })
        .await
}
```


# 0x1990 · illumination (/hidpp/features/x1990-illumination)



Controls a device light through independent brightness and colour-temperature controls. OpenLogi uses this feature for Litra Glow and Litra Beam lights that answer HID++ `0x1990`; see [Litra lights](/docs/features/lights).

> **Spec:** Logitech HID++ 2.0 — *illumination*. &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis). &#x2A;*Used by:** [Litra lights](/docs/features/lights).

## Function reference [#function-reference]

`IlluminationFeature` (`0x1990`) exposes the following methods.

### Methods [#methods]

| Function                       | HID++ fn | Signature                    | Returns                       |
| ------------------------------ | -------- | ---------------------------- | ----------------------------- |
| `get_illumination`             | 0        | `()`                         | `IlluminationState`           |
| `set_illumination`             | 1        | `(state: IlluminationState)` | `()`                          |
| `get_brightness_info`          | 2        | `()`                         | `ControlInfo`                 |
| `get_brightness`               | 3        | `()`                         | `u16`                         |
| `set_brightness`               | 4        | `(brightness: u16)`          | `()`                          |
| `get_brightness_levels`        | 5        | `(start_index: u8)`          | `LevelConfig`                 |
| `set_brightness_levels`        | 6        | `(levels: &SetLevels)`       | `()`                          |
| `get_color_temperature_info`   | 7        | `()`                         | `ControlInfo`                 |
| `get_color_temperature`        | 8        | `()`                         | `u16`                         |
| `set_color_temperature`        | 9        | `(color_temperature: u16)`   | `()`                          |
| `get_color_temperature_levels` | 10       | `(start_index: u8)`          | `LevelConfig`                 |
| `set_color_temperature_levels` | 11       | `(levels: &SetLevels)`       | `()`                          |
| `get_brightness_effective_max` | 12       | `()`                         | `u16`                         |
| `listen`                       | event    | `()`                         | `Receiver<IlluminationEvent>` |

All methods except `listen` are `async` and return `Result<…, Hidpp20Error>`. `listen` is provided by `EmittingFeature`.

### Types [#types]

#### `ControlCapabilities` [#controlcapabilities]

`ControlInfo::capabilities` describes one control. Unknown bits are retained.

| Flag                    | Bit | Description                                      |
| ----------------------- | --- | ------------------------------------------------ |
| `HAS_EVENTS`            | 0   | The control sends change notifications.          |
| `HAS_LINEAR_LEVELS`     | 1   | The control supports a linear level table.       |
| `HAS_NON_LINEAR_LEVELS` | 2   | The control supports an explicit level table.    |
| `HAS_DYNAMIC_MAXIMUM`   | 3   | Brightness can have a dynamic effective maximum. |

#### `ControlInfo` [#controlinfo]

Values are lumens for brightness and kelvin for colour temperature.

| Field          | Type                  | Description                                                         |
| -------------- | --------------------- | ------------------------------------------------------------------- |
| `capabilities` | `ControlCapabilities` | Control capability bits.                                            |
| `min`          | `u16`                 | Lowest accepted value. A control with `min == max` has one setting. |
| `max`          | `u16`                 | Highest accepted value.                                             |
| `resolution`   | `u16`                 | Valid values follow `(value - min) % resolution == 0`.              |
| `max_levels`   | `u8`                  | Maximum non-linear levels. `0` means unsupported.                   |

#### `LevelConfig` [#levelconfig]

A getter returns one linear table or one non-linear page.

| Variant     | Fields                                               | Description                                               |
| ----------- | ---------------------------------------------------- | --------------------------------------------------------- |
| `Linear`    | `min: u16, max: u16, step: u16`                      | Inclusive range with even spacing.                        |
| `NonLinear` | `start_index: u8, level_count: u8, values: Vec<u16>` | A page of `0–7` explicit values and the total table size. |

#### `SetLevels` [#setlevels]

A setter accepts a reset, a linear table, or the whole non-linear table.

| Variant     | Fields                          | Description                                                              |
| ----------- | ------------------------------- | ------------------------------------------------------------------------ |
| `Reset`     | —                               | Restores factory level configuration.                                    |
| `Linear`    | `min: u16, max: u16, step: u16` | Writes a linear table.                                                   |
| `NonLinear` | `values: Vec<u16>`              | Writes `1–15` values. OpenLogi splits more than seven values into pages. |

`SetLevels::NonLinear` validates the count. It does not validate order, range, or spacing.

#### `IlluminationState` [#illuminationstate]

| Variant | Value | Description          |
| ------- | ----- | -------------------- |
| `Off`   | 0     | Illumination is off. |
| `On`    | 1     | Illumination is on.  |

#### `BrightnessClampedSource` [#brightnessclampedsource]

| Variant       | Value        | Description                                  |
| ------------- | ------------ | -------------------------------------------- |
| `Unknown`     | 0            | The source is unknown.                       |
| `HidPlusPlus` | 1            | A HID++ brightness request caused the clamp. |
| `Button`      | 2            | A hardware button caused the clamp.          |
| `Other(u8)`   | other values | An unmodelled source byte.                   |

### Events [#events]

| Variant                         | Payload                | Description                                              |
| ------------------------------- | ---------------------- | -------------------------------------------------------- |
| `IlluminationChanged`           | `IlluminationState`    | The light changed on or off.                             |
| `BrightnessChanged`             | `u16`                  | Brightness changed in lumens.                            |
| `ColorTemperatureChanged`       | `u16`                  | Colour temperature changed in kelvin.                    |
| `BrightnessEffectiveMaxChanged` | `u16`                  | Effective maximum changed in lumens. `0` means no limit. |
| `BrightnessClamped`             | `source`, `brightness` | Brightness was limited to the effective maximum.         |

The last two events are version-1 protocol additions.

## Wire format [#wire-format]

Short requests have a three-byte payload. All multi-byte fields are BE. `get_*_info` requires a long reply of at least eight payload bytes. Each `get_*_levels` call requires the full 16-byte long payload. OpenLogi rejects short replies for those functions.

### `get_illumination` (fn 0) [#get_illumination-fn-0]

Request: `[0x00, 0x00, 0x00]`

| Byte | Field | Notes                           |
| ---- | ----- | ------------------------------- |
| 0    | state | Bit 0: `0` = `Off`; `1` = `On`. |

### `set_illumination` (fn 1) [#set_illumination-fn-1]

Request: `[state, 0x00, 0x00]`, where `state` is `0` or `1`.

The response is an acknowledgement. OpenLogi does not inspect a response payload.

### `get_brightness_info` (fn 2) and `get_color_temperature_info` (fn 7) [#get_brightness_info-fn-2-and-get_color_temperature_info-fn-7]

Request: `[0x00, 0x00, 0x00]`

| Bytes | Field          | Notes                           |
| ----- | -------------- | ------------------------------- |
| 0     | `capabilities` | `ControlCapabilities` bitfield. |
| 1–2   | `min`          | BE `u16`.                       |
| 3–4   | `max`          | BE `u16`.                       |
| 5–6   | `resolution`   | BE `u16`.                       |
| 7     | `max_levels`   | Low nibble only.                |

### `get_brightness` (fn 3), `get_color_temperature` (fn 8), and `get_brightness_effective_max` (fn 12) [#get_brightness-fn-3-get_color_temperature-fn-8-and-get_brightness_effective_max-fn-12]

Request: `[0x00, 0x00, 0x00]`

| Bytes | Field | Notes                                              |
| ----- | ----- | -------------------------------------------------- |
| 0–1   | value | BE `u16`; for fn 12, `0` means no effective limit. |

Function 12 is a version-1 function. It applies only when brightness advertises `HAS_DYNAMIC_MAXIMUM`.

### `set_brightness` (fn 4) and `set_color_temperature` (fn 9) [#set_brightness-fn-4-and-set_color_temperature-fn-9]

Request: `[value_hi, value_lo, 0x00]`, where the value is a BE `u16`.

The response is an acknowledgement. OpenLogi does not inspect a response payload.

### `get_brightness_levels` (fn 5) and `get_color_temperature_levels` (fn 10) [#get_brightness_levels-fn-5-and-get_color_temperature_levels-fn-10]

Request: `[start_index << 4, 0x00, 0x00]`. `start_index` must be at most `0x0f`.

| Bytes | Field             | Notes                                                                |
| ----- | ----------------- | -------------------------------------------------------------------- |
| 0     | flags             | Bit 0 selects a linear table. Bits 7–5 give a non-linear page count. |
| 1     | page information  | Bits 7–4 are `start_index`. Bits 3–0 are total `level_count`.        |
| 2–3   | `min` or value 0  | BE `u16`. Linear tables use `min`.                                   |
| 4–5   | `max` or value 1  | BE `u16`. Linear tables use `max`.                                   |
| 6–7   | `step` or value 2 | BE `u16`. Linear tables use `step`.                                  |
| 8–15  | values 3–6        | Non-linear values only, as BE `u16` pairs.                           |

### `set_brightness_levels` (fn 6) and `set_color_temperature_levels` (fn 11) [#set_brightness_levels-fn-6-and-set_color_temperature_levels-fn-11]

OpenLogi sends each payload as a 16-byte long request.

| Variant     | Byte 0                       | Byte 1                                | Bytes 2–7                            | Notes                                          |
| ----------- | ---------------------------- | ------------------------------------- | ------------------------------------ | ---------------------------------------------- |
| `Reset`     | `0x02`                       | `0x00`                                | zeroes                               | Bit 1 requests reset. Other bytes are ignored. |
| `Linear`    | `0x01`                       | `0x00`                                | `min`, `max`, and `step` as BE `u16` | Bit 0 selects a linear table.                  |
| `NonLinear` | page value count in bits 7–5 | page start index and full table count | up to seven BE `u16` values          | One request is sent per page.                  |

The non-linear full table count is derived from `values.len()`. It cannot disagree with a page count.

### Events [#events-1]

| Sub-id | Event                           | Payload                                                |
| ------ | ------------------------------- | ------------------------------------------------------ |
| 0      | `IlluminationChanged`           | Byte 0, bit 0 is the state.                            |
| 1      | `BrightnessChanged`             | Bytes 0–1: BE brightness in lumens.                    |
| 2      | `ColorTemperatureChanged`       | Bytes 0–1: BE colour temperature in kelvin.            |
| 3      | `BrightnessEffectiveMaxChanged` | Bytes 0–1: BE maximum in lumens.                       |
| 4      | `BrightnessClamped`             | Byte 0 is source; bytes 1–2 are BE clamped brightness. |

## How OpenLogi uses it [#how-openlogi-uses-it]

The Litra driver resolves `0x1990` from the light node's root feature. Resolution and the first state read have a one-second deadline. It reads brightness and temperature information, power, and supported values.

The driver calls fn 12 only when the resolved feature version is at least `1` and brightness has `HAS_DYNAMIC_MAXIMUM`. A nonzero limit below the static maximum becomes the reported brightness limit. A limit of `0` or at least the static maximum has no effect.

Before a Litra write, OpenLogi reads the current state. It sends power, brightness, or temperature only when that control differs. It then reads the written control back. The agent keeps a live HID++ link, re-reads on notifications, and publishes changes made on the light.

The agent reapplies saved light settings after a reconnect, a save, and camera-use transitions. The [Litra lights](/docs/features/lights) page describes the user-facing behaviour. This reference documents the HID++ path only.

The generic `IlluminationFeature` does not retain the negotiated feature version. Its fn-12 method remains callable without an internal version or capability check. The Litra device path applies both checks.

## Open questions [#open-questions]

Logi Options+ static analysis confirms five illumination event payload shapes. It does not recover the event sub-id jump-table order. OpenLogi maps sub-ids `0–4` in the order shown above. A hardware capture should confirm that mapping.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::{
        illumination::{ControlCapabilities, IlluminationFeature, IlluminationState},
        EmittingFeature,
    },
};

// `device` is already created for a HID++ channel and device index.
device.enumerate_features().await?;
if let Some(feature) = device.get_feature::<IlluminationFeature>() {
    let info = feature.get_brightness_info().await?;
    if info.capabilities.contains(ControlCapabilities::HAS_DYNAMIC_MAXIMUM) {
        // The caller must also know that the negotiated feature version is at least 1.
        let limit = feature.get_brightness_effective_max().await?;
        println!("effective limit: {limit}");
    }

    feature.set_illumination(IlluminationState::On).await?;
    let events = feature.listen();
    while let Ok(event) = events.recv().await {
        println!("{event:?}");
    }
}
```


# 0x19b0 · hapticFeedback (/hidpp/features/x19b0-haptic-feedback)



Controls a device haptic actuator. It reports waveform support, device-wide haptic flags, intensity, and readiness. OpenLogi uses two typed waveforms for the [Actions Ring](/docs/features/actions-ring) on the [MX Master 4](/docs/devices/mx-master-4).

> **Spec:** No public Logitech specification. Layout reverse-engineered from Logi Options+ 2.8 (static analysis). &#x2A;*Cross-checked:** Solaar. &#x2A;*Used by:** [Actions Ring](/docs/features/actions-ring) on the [MX Master 4](/docs/devices/mx-master-4).

## Function reference [#function-reference]

`HapticFeedbackFeature` (`0x19b0`) exposes the following methods.

### Methods [#methods]

| Function            | HID++ fn | Signature                                                | Returns               |
| ------------------- | -------- | -------------------------------------------------------- | --------------------- |
| `get_capabilities`  | 0        | `()`                                                     | `HapticCapabilities`  |
| `get_configuration` | 1        | `()`                                                     | `HapticConfiguration` |
| `set_configuration` | 2        | `(flags: HapticConfigFlags, intensity: HapticIntensity)` | `HapticConfiguration` |
| `get_haptic_status` | 3        | `()`                                                     | `HapticStatus`        |
| `play`              | 4        | `(waveform: HapticWaveform)`                             | `()`                  |

All methods are `async` and return `Result<…, Hidpp20Error>`.

### Types [#types]

#### `SupportedWaveforms` [#supportedwaveforms]

This mask retains unknown bits. `supports` checks bit *N* for waveform ID *N*. OpenLogi names only the two waveforms it uses.

| Flag                | Bit | Description                              |
| ------------------- | --- | ---------------------------------------- |
| `DAMP_STATE_CHANGE` | 1   | Confirmation pulse after an action runs. |
| `SUBTLE_COLLISION`  | 4   | Light boundary pulse for a slot change.  |

#### `HapticWaveform` [#hapticwaveform]

| Variant           | Value | Description         |
| ----------------- | ----- | ------------------- |
| `DampStateChange` | 1     | Confirmation pulse. |
| `SubtleCollision` | 4     | Boundary pulse.     |

#### `HapticConfigFlags` [#hapticconfigflags]

`HapticConfigFlags` retains unknown bits. Build a write from `get_configuration()` so unmodelled flags survive.

| Flag         | Bit | Description                                                         |
| ------------ | --- | ------------------------------------------------------------------- |
| `ENABLED`    | 0   | Firmware haptic playback is enabled.                                |
| `POWER_SAVE` | 1   | Battery-saving haptics setting. Options+ controls it independently. |

#### `HapticConfiguration` [#hapticconfiguration]

| Field         | Type                | Description                                          |
| ------------- | ------------------- | ---------------------------------------------------- |
| `flags`       | `HapticConfigFlags` | Reported flag byte, including retained unknown bits. |
| `intensity`   | `HapticIntensity`   | Current intensity percentage.                        |
| `level_count` | `u8`                | Low nibble of response byte 2.                       |
| `level_step`  | `u8`                | High nibble of response byte 2.                      |

#### `HapticIntensity` [#hapticintensity]

`HapticIntensity::new(value)` accepts `0–100`. It returns `None` for a larger value. `get()` returns the percentage sent on the wire.

#### `HapticStatus` [#hapticstatus]

`HapticStatus` wraps the raw status byte from fn 3. `is_ready()` is true only for `0`. Other values remain raw because their meanings are not established.

### Events [#events]

`HapticFeedbackFeature` does not implement `EmittingFeature`. OpenLogi exposes no haptic report events.

## Wire format [#wire-format]

All implemented requests use a three-byte short payload. The waveform mask is a BE `u32`. The exact meaning of capability bytes 0–3 and the complete level-format definition remain unverified.

### `get_capabilities` (fn 0) [#get_capabilities-fn-0]

Request: `[0x00, 0x00, 0x00]`

| Bytes | Field            | Notes                                         |
| ----- | ---------------- | --------------------------------------------- |
| 0–3   | `unknown_prefix` | Meaning is unverified.                        |
| 4–7   | `waveforms`      | BE `u32`; bit *N* advertises waveform ID *N*. |

### `get_configuration` (fn 1) [#get_configuration-fn-1]

Request: `[0x00, 0x00, 0x00]`

| Byte | Field        | Notes                                                                                    |
| ---- | ------------ | ---------------------------------------------------------------------------------------- |
| 0    | `flags`      | Bit 0 is `ENABLED`; bit 1 is `POWER_SAVE`; higher bits are retained. Options+ 2.8        |
| 1    | `intensity`  | Percentage from `0` through `100`.                                                       |
| 2    | level format | High nibble is `level_step`; low nibble is `level_count`. Full semantics are unverified. |

### `set_configuration` (fn 2) [#set_configuration-fn-2]

Request: `[flags, intensity, 0x00]`.

The response has the fn-1 configuration layout. OpenLogi decodes and returns it. The response is device state, not an assumed request echo.

### `get_haptic_status` (fn 3) [#get_haptic_status-fn-3]

Request: `[0x00, 0x00, 0x00]`

| Byte | Field  | Notes                                                      |
| ---- | ------ | ---------------------------------------------------------- |
| 0    | status | `0` is ready. Any nonzero value is not ready. Options+ 2.8 |

### `play` (fn 4) [#play-fn-4]

Request: `[waveform_id, 0x00, 0x00]`.

Byte 0 is the waveform ID. Options+ 2.8 uses byte 1 to request a waveform-information report. OpenLogi writes `0` because it does not receive that report. The response is an acknowledgement.

### `stopHapticWaveForm` (fn 5) [#stophapticwaveform-fn-5]

Logi Options+ 2.8 calls fn 5 to stop a waveform after it checks playing state. OpenLogi does not expose this function. Its request and response layout are not verified.

### `getPlayingWaveForm` (fn 6) [#getplayingwaveform-fn-6]

Logi Options+ 2.8 calls fn 6 before it decides to stop a waveform. OpenLogi does not expose this function. Its request and response layout are not verified.

### Reports [#reports]

Options+ 2.8 static analysis identifies two unsolicited report layouts. OpenLogi does not parse either report.

| Sub-id | Payload                  | Notes                                                    |
| ------ | ------------------------ | -------------------------------------------------------- |
| 0      | Byte 0 is haptic status. | Options+ 2.8                                             |
| 1      | Two bytes.               | Waveform-information fields are unverified. Options+ 2.8 |

## How OpenLogi uses it [#how-openlogi-uses-it]

At an Actions Ring session start, OpenLogi resolves `0x19b0`, reads the capability mask, and reads haptic status. The status read has a 250 ms deadline. A timeout or read error leaves status unknown and permits playback.

OpenLogi rejects a play before transmission when the mask lacks the waveform. It also rejects a play when a successfully read status is nonzero. The two Actions Ring waveforms are `SubtleCollision` for slot changes and `DampStateChange` for activation.

The arming path reads the configuration. If `ENABLED` is clear and intensity is nonzero, it changes only that bit and writes the complete flag byte. It preserves `POWER_SAVE`, unknown flag bits, and intensity. It accepts the repair only when the returned configuration has `ENABLED` set.

A zero intensity means user-off. Since v0.8.14, OpenLogi does not replace it with `25`. Actions Ring cancellation cancels the ring session only. OpenLogi has no fn-5 stop operation or report listener.

## Open questions [#open-questions]

Options+ treats global haptics and each event source as playback conditions. OpenLogi can re-enable the device-wide `ENABLED` bit when an Actions Ring starts. Evidence from an earlier implementation says power transitions can clear that bit. Hardware testing has not shown whether it is only a transient arm state.

OpenLogi does not restore `ENABLED` when the session closes. It preserves every other configuration bit and the intensity. The product policy remains open: skip feedback when haptics are disabled, or use a session-scoped override with restore.

The complete byte-2 level format, fn-5 and fn-6 layouts, and waveform-information report fields require hardware capture. The protocol persistence boundary for the configuration flags also remains unverified.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::{
        haptic_feedback::{
            HapticConfigFlags, HapticFeedbackFeature, HapticWaveform,
        },
    },
};

// `device` is already created for a HID++ channel and device index.
device.enumerate_features().await?;
if let Some(feature) = device.get_feature::<HapticFeedbackFeature>() {
    let capabilities = feature.get_capabilities().await?;
    let status = feature.get_haptic_status().await?;

    if capabilities.waveforms.supports(HapticWaveform::SubtleCollision)
        && status.is_ready()
    {
        let configuration = feature.get_configuration().await?;
        let applied = feature
            .set_configuration(
                configuration.flags | HapticConfigFlags::ENABLED,
                configuration.intensity,
            )
            .await?;
        if applied.flags.contains(HapticConfigFlags::ENABLED) {
            feature.play(HapticWaveform::SubtleCollision).await?;
        }
    }
}
```


# 0x1a00 · presenterControl (/hidpp/features/x1a00-presenter-control)



OpenLogi v0.8.15 lists `0x1a00` as `PresenterControl`, but it has no feature wrapper. G HUB static analysis names feature information, presenter-settings read and write, and vibration operations; it does not establish their function indices or packet layouts.

> **Spec:** No public Logitech specification. Operation names reverse-engineered from G HUB (static analysis); no wire layout is established. &#x2A;*Used by:** Not used by OpenLogi.

<Callout type="warn" title="Not implemented in OpenLogi">
  OpenLogi v0.8.15 recognises this feature ID in its registry, but provides no typed wrapper. It does not read presenter settings, write them, or invoke vibration.
</Callout>

## What it does [#what-it-does]

G HUB contains operations named `GetFeatureInfo`, `GetPresenterSettings`, `SetPresenterSettings`, and `Vibrate`. These names establish those vendor-software operations only. They do not establish control enumeration, button-event routing, setting fields, vibration parameters, or event layouts.

**Status:** Not implemented in OpenLogi v0.8.15 · &#x2A;*Feature ID:** `0x1a00`


# 0x1b00 · reprogControls (/hidpp/features/x1b00-reprog-controls)



`reprogControls` is an early HID++ 2.0 control-table feature. It enumerates
programmable buttons, function keys, and hotkeys. It can also divert one
control from its native HID report to HID++ events.

`0x1b04` is the newer control feature. OpenLogi registers `0x1b04` as
`ReprogControls5` and uses it for current control capture.

<Callout type="warn" title="Not implemented in OpenLogi">
  OpenLogi registers `0x1b00` as `ReprogControls`, but `openlogi-hidpp` has no
  typed wrapper for it in v0.8.15. The description below is protocol context,
  not an OpenLogi API contract.
</Callout>

## What it does [#what-it-does]

* **Control enumeration.** A device reports its control-table count. A second
  function returns each control's ID, default task ID, and flags.
* **Control diversion.** Host software reads and changes diversion for one
  control ID. A diverted control emits HID++ events instead of its normal HID
  report.
* **Button events.** A device can broadcast the set of currently pressed
  diverted controls.
* **Earlier revision.** `0x1b00` predates the persistent diversion, raw XY,
  raw-wheel, analytics, group-mask, and capability fields of `0x1b04`.

**Status:** Not implemented in OpenLogi v0.8.15 · &#x2A;*Feature ID:** `0x1b00`
&#x2A;*See also:** [0x1b04 · specialKeysMSEButtons](/hidpp/features/x1b04-special-keys-mse-buttons).


# 0x1B04 · specialKeysMSEButtons (/hidpp/features/x1b04-special-keys-mse-buttons)



`specialKeysMSEButtons` (`0x1b04`) describes programmable physical and virtual
controls on mice and keyboards. It enumerates buttons, function keys, and
hotkeys. Host software can divert a control, set persistent diversion, change
its reporting streams, or select a remap target.

OpenLogi registers this feature as `ReprogControls5`. Its typed Rust wrapper is
`ReprogControlsFeature`. `0x1b04` is the newer counterpart to
[`0x1b00 · reprogControls`](/hidpp/features/x1b00-reprog-controls).

> **Spec:** Logitech HID++ 2.0 — *specialKeysMseButtons* (v2). &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis), Solaar. &#x2A;*Used by:** [Remap buttons](/docs/features/mouse/remap-buttons).

## Function reference [#function-reference]

`ReprogControlsFeature` implements `0x1b04`.

### Methods [#methods]

| Function                        | HID++ fn | Signature                                      | Returns                                        |
| ------------------------------- | -------- | ---------------------------------------------- | ---------------------------------------------- |
| `get_count`                     | 0        | `()`                                           | `u8`                                           |
| `get_cid_info`                  | 1        | `(index: u8)`                                  | `CidInfo`                                      |
| `get_cid_reporting`             | 2        | `(cid: ControlId)`                             | `CidReporting`                                 |
| `set_cid_reporting`             | 3        | `(cid: ControlId, change: CidReportingChange)` | `CidReportingChangeEcho`                       |
| `get_capabilities`              | 4        | `()`                                           | `ReprogControlsCapabilities`                   |
| `reset_all_cid_report_settings` | 5        | `()`                                           | `()`                                           |
| `listen`                        | event    | `()`                                           | `async_channel::Receiver<ReprogControlsEvent>` |

The first six methods are `async`. They return `Result<…, Hidpp20Error>`.
`listen` is the synchronous `EmittingFeature` trait method.

### Types [#types]

#### `ControlId`, `TaskId`, and `GroupMask` [#controlid-taskid-and-groupmask]

| Type        | Inner type | Description                                   |
| ----------- | ---------- | --------------------------------------------- |
| `ControlId` | `u16`      | HID++ control ID.                             |
| `TaskId`    | `u16`      | Default task associated with a control.       |
| `GroupMask` | `u8`       | `g1..g8` membership mask from `get_cid_info`. |

#### `CidInfo` [#cidinfo]

| Field        | Type        | Description                              |
| ------------ | ----------- | ---------------------------------------- |
| `cid`        | `ControlId` | Control ID.                              |
| `task_id`    | `TaskId`    | Default task ID.                         |
| `flags`      | `CidFlags`  | Capability and classification flags.     |
| `position`   | `u8`        | Device-reported physical-position value. |
| `group`      | `u8`        | Device-reported group number.            |
| `group_mask` | `GroupMask` | Group-membership mask.                   |

#### `CidFlags` [#cidflags]

| Flag                      | Bit       | Description                      |
| ------------------------- | --------- | -------------------------------- |
| `MOUSE`                   | `1 << 0`  | Mouse or pointer control.        |
| `FUNCTION_KEY`            | `1 << 1`  | Keyboard function key.           |
| `HOTKEY`                  | `1 << 2`  | Hotkey.                          |
| `FN_TOGGLE`               | `1 << 3`  | Fn-toggle control.               |
| `REPROGRAMMABLE`          | `1 << 4`  | Reprogrammable control.          |
| `DIVERTABLE`              | `1 << 5`  | Temporarily divertable control.  |
| `PERSISTENTLY_DIVERTABLE` | `1 << 6`  | Persistently divertable control. |
| `VIRTUAL_CONTROL`         | `1 << 7`  | Virtual, not physical, control.  |
| `RAW_XY`                  | `1 << 8`  | Supports raw XY reporting.       |
| `FORCE_RAW_XY`            | `1 << 9`  | Supports force-raw-XY reporting. |
| `ANALYTICS_KEY_EVENTS`    | `1 << 10` | Supports analytics key events.   |
| `RAW_WHEEL`               | `1 << 11` | Supports raw-wheel reporting.    |

`CidFlags::raw()` returns the combined `u16`. The `is_*` and `supports_*`
methods expose the corresponding named flags.

#### `CidReporting` [#cidreporting]

| Field                   | Type                | Description                                                 |
| ----------------------- | ------------------- | ----------------------------------------------------------- |
| `cid`                   | `ControlId`         | Control whose state was read.                               |
| `diverted`              | `bool`              | Temporary diversion state.                                  |
| `persistently_diverted` | `bool`              | Persistent diversion state.                                 |
| `force_raw_xy`          | `bool`              | Force-raw-XY state.                                         |
| `raw_xy`                | `bool`              | Raw-XY state.                                               |
| `remap`                 | `Option<ControlId>` | Target control, or `None` when the device reports `0x0000`. |
| `analytics_key_events`  | `bool`              | Analytics-key-event state.                                  |
| `raw_wheel`             | `bool`              | Raw-wheel state.                                            |

#### `CidReportingChange` [#cidreportingchange]

Each `Option<bool>` changes its setting only when it is `Some`. `None` leaves
that setting unchanged.

| Field                   | Type                | Description                                                        |
| ----------------------- | ------------------- | ------------------------------------------------------------------ |
| `diverted`              | `Option<bool>`      | Temporary-diversion change.                                        |
| `persistently_diverted` | `Option<bool>`      | Persistent-diversion change.                                       |
| `force_raw_xy`          | `Option<bool>`      | Force-raw-XY change.                                               |
| `raw_xy`                | `Option<bool>`      | Raw-XY change.                                                     |
| `remap`                 | `Option<ControlId>` | Target control. `None` sends `0x0000` and keeps the current remap. |
| `analytics_key_events`  | `Option<bool>`      | Analytics-key-event change.                                        |
| `raw_wheel`             | `Option<bool>`      | Raw-wheel change.                                                  |

To clear a remap, set `remap: Some(cid)` for the control being written. Do not
use `None`: `0x0000` means keep the prior remap for `set_cid_reporting`.

#### `CidReportingChangeEcho` [#cidreportingchangeecho]

| Field                   | Type                | Description                         |
| ----------------------- | ------------------- | ----------------------------------- |
| `cid`                   | `ControlId`         | Control ID in the response.         |
| `diverted`              | `Option<bool>`      | Echoed temporary-diversion change.  |
| `persistently_diverted` | `Option<bool>`      | Echoed persistent-diversion change. |
| `force_raw_xy`          | `Option<bool>`      | Echoed force-raw-XY change.         |
| `raw_xy`                | `Option<bool>`      | Echoed raw-XY change.               |
| `remap`                 | `Option<ControlId>` | Echoed remap target, if nonzero.    |
| `analytics_key_events`  | `Option<bool>`      | Echoed analytics-key-event change.  |
| `raw_wheel`             | `Option<bool>`      | Echoed raw-wheel change.            |

#### `ReprogControlsCapabilities` [#reprogcontrolscapabilities]

| Field                           | Type   | Description                                   |
| ------------------------------- | ------ | --------------------------------------------- |
| `reset_all_cid_report_settings` | `bool` | `reset_all_cid_report_settings` is available. |

### Events [#events]

`ReprogControlsFeature` implements `EmittingFeature<ReprogControlsEvent>`.

| Variant              | Event fn | Fields                                    | Description                    |
| -------------------- | -------- | ----------------------------------------- | ------------------------------ |
| `DivertedButtons`    | 0        | `[ControlId; 4]`                          | Current diverted controls.     |
| `DivertedRawMouseXy` | 1        | `dx: i16`, `dy: i16`                      | Raw pointer delta.             |
| `AnalyticsKeyEvents` | 2        | `[AnalyticsKeyEvent; 5]`                  | Five packed analytics entries. |
| `DivertedRawWheel`   | 4        | `resolution`, `periods`, `delta_vertical` | Raw vertical wheel delta.      |

`AnalyticsKeyEvent` contains `cid: ControlId` and a device-defined `event: u8`.
`RawWheelResolution` is `Low = 0` or `High = 1`. `periods` is `U4`, an unsigned
four-bit value.

## Wire format [#wire-format]

The tables use logical HID++ payload bytes. OpenLogi sends 3-byte short
requests except for fn 1 and fn 3, which use 16-byte long requests. Readers use
an extended 16-byte response view and ignore bytes without a modeled field.

### `get_count` (fn 0) [#get_count-fn-0]

Request: `[0x00, 0x00, 0x00]`.

| Byte | Field   | Notes                            |
| ---- | ------- | -------------------------------- |
| 0    | `count` | Number of control-table entries. |
| 1–15 | —       | No field is parsed by OpenLogi.  |

### `get_cid_info` (fn 1) [#get_cid_info-fn-1]

Request: long payload with `index` in byte 0. OpenLogi writes `0x00` in bytes
1–15.

| Byte(s) | Field            | Notes                                                      |
| ------- | ---------------- | ---------------------------------------------------------- |
| 0–1     | `cid`            | `ControlId` as BE `u16`.                                   |
| 2–3     | `task_id`        | `TaskId` as BE `u16`.                                      |
| 4       | primary flags    | `CidFlags` bits 0–7.                                       |
| 5       | `position`       | Device-reported position.                                  |
| 6       | `group`          | Device-reported group.                                     |
| 7       | `group_mask`     | `GroupMask` bits `g1..g8`.                                 |
| 8       | additional flags | `CidFlags` bits 8–11. OpenLogi shifts this byte left by 8. |
| 9–15    | —                | No documented field. OpenLogi ignores these bytes.         |

### `get_cid_reporting` (fn 2) [#get_cid_reporting-fn-2]

Request: `[cid_hi, cid_lo, 0x00]`, where `cid_hi,cid_lo` are the `ControlId` as
BE `u16`.

| Byte(s) | Field                   | Notes                                                                          |
| ------- | ----------------------- | ------------------------------------------------------------------------------ |
| 0–1     | `cid`                   | BE `u16`.                                                                      |
| 2 bit 0 | `diverted`              | `1` means temporary diversion.                                                 |
| 2 bit 2 | `persistently_diverted` | `1` means persistent diversion.                                                |
| 2 bit 4 | `raw_xy`                | `1` means raw XY.                                                              |
| 2 bit 6 | `force_raw_xy`          | `1` means force raw XY.                                                        |
| 3–4     | `remap`                 | BE `u16` target. `0x0000` means the control maps to itself and becomes `None`. |
| 5 bit 0 | `analytics_key_events`  | `1` enables analytics events.                                                  |
| 5 bit 2 | `raw_wheel`             | `1` enables raw-wheel events.                                                  |
| 6–15    | —                       | No documented field. OpenLogi ignores these bytes.                             |

### `set_cid_reporting` (fn 3) [#set_cid_reporting-fn-3]

OpenLogi sends a 16-byte long request. It sets bytes 6–15 to `0x00`.

| Byte(s)         | Field                                 | Notes                                                                           |
| --------------- | ------------------------------------- | ------------------------------------------------------------------------------- |
| 0–1             | `cid`                                 | BE `u16`.                                                                       |
| 2 bit 1 / bit 0 | `diverted` valid / value              | The value changes only when valid is `1`.                                       |
| 2 bit 3 / bit 2 | `persistently_diverted` valid / value | The value changes only when valid is `1`.                                       |
| 2 bit 5 / bit 4 | `raw_xy` valid / value                | The value changes only when valid is `1`.                                       |
| 2 bit 7 / bit 6 | `force_raw_xy` valid / value          | The value changes only when valid is `1`.                                       |
| 3–4             | `remap`                               | BE `u16`. `0x0000` keeps the previous remap. Write this `cid` to clear a remap. |
| 5 bit 1 / bit 0 | `analytics_key_events` valid / value  | The value changes only when valid is `1`.                                       |
| 5 bit 3 / bit 2 | `raw_wheel` valid / value             | The value changes only when valid is `1`.                                       |
| 6–15            | —                                     | OpenLogi writes fixed zero padding.                                             |

The response has the same modeled bytes 0–5. `CidReportingChangeEcho` exposes a
boolean value only when its valid bit is set. It maps a zero remap field to
`None` and ignores bytes 6–15.

### `get_capabilities` (fn 4) [#get_capabilities-fn-4]

Request: `[0x00, 0x00, 0x00]`. This function is available on version 6 devices.
Older firmware can return `InvalidFunctionId`.

| Byte       | Field                           | Notes                                |
| ---------- | ------------------------------- | ------------------------------------ |
| 0 bit 0    | `reset_all_cid_report_settings` | `1` permits fn 5. Options+ 2.8.      |
| 0 bits 1–7 | —                               | Unknown bits. OpenLogi ignores them. |
| 1–15       | —                               | No field is parsed by OpenLogi.      |

### `reset_all_cid_report_settings` (fn 5) [#reset_all_cid_report_settings-fn-5]

Request: `[0x00, 0x00, 0x00]`. Call this only when fn 4 reports support. It
resets every control's diversion and remap state. OpenLogi returns `()` and
does not parse a response body.

### Events [#events-1]

OpenLogi accepts unsolicited messages only when the software ID is `0`. It
does not model the unrecognized bytes shown below.

#### `DivertedButtons` (event fn 0) [#divertedbuttons-event-fn-0]

| Byte(s) | Field  | Notes                                                   |
| ------- | ------ | ------------------------------------------------------- |
| 0–1     | slot 0 | BE `ControlId`; `0x0000` means no control in this slot. |
| 2–3     | slot 1 | BE `ControlId`.                                         |
| 4–5     | slot 2 | BE `ControlId`.                                         |
| 6–7     | slot 3 | BE `ControlId`.                                         |
| 8–15    | —      | No documented field. OpenLogi ignores these bytes.      |

#### `DivertedRawMouseXy` (event fn 1) [#divertedrawmousexy-event-fn-1]

| Byte(s) | Field | Notes                                              |
| ------- | ----- | -------------------------------------------------- |
| 0–1     | `dx`  | BE `i16` horizontal delta.                         |
| 2–3     | `dy`  | BE `i16` vertical delta.                           |
| 4–15    | —     | No documented field. OpenLogi ignores these bytes. |

#### `AnalyticsKeyEvents` (event fn 2) [#analyticskeyevents-event-fn-2]

The event contains five 3-byte entries in bytes 0–14.

| Offset | Field   | Notes                      |
| ------ | ------- | -------------------------- |
| 0–1    | `cid`   | BE `ControlId`.            |
| 2      | `event` | Device-defined event code. |

Byte 15 has no documented field. OpenLogi ignores it.

#### `DivertedRawWheel` (event fn 4) [#divertedrawwheel-event-fn-4]

| Byte(s)    | Field            | Notes                                                               |
| ---------- | ---------------- | ------------------------------------------------------------------- |
| 0 bits 7–5 | —                | No public meaning found. OpenLogi ignores these bits. Options+ 2.8. |
| 0 bit 4    | `resolution`     | `0` = `Low`; `1` = `High`. Options+ 2.8.                            |
| 0 bits 3–0 | `periods`        | `U4` period count. Options+ 2.8.                                    |
| 1–2        | `delta_vertical` | BE `i16` vertical delta. Options+ 2.8.                              |
| 3–15       | —                | No documented field. OpenLogi ignores these bytes.                  |

## How OpenLogi uses it [#how-openlogi-uses-it]

OpenLogi uses `0x1b04` for mouse control capture and keyboard host switching.
It enumerates a control before arming it. It requires the relevant `CidFlags`,
such as `DIVERTABLE`, `RAW_XY`, or `RAW_WHEEL`.

Before a capture write, OpenLogi reads `CidReporting` and records it. It writes
only the reporting fields the session owns. It carries the read remap target
through its change, so it does not clear an existing remap.

OpenLogi re-arms captured controls after a reconnect. It restores them on
normal stop and rolls them back after an arming failure. A restore failure
becomes a pending restore that OpenLogi retries on the current device channel.

Since v0.8.15, a wheel-hold binding enables `raw_wheel` only for a control with
`RAW_WHEEL`. Its teardown clears `raw_wheel` on stop, reconnect failure, and
arming failure. It does not restore a pre-existing `raw_wheel: true` state. A
control without that flag remains a plain diverted click.

Raw-wheel reports contain no source CID. OpenLogi assigns a report to the
earliest pressed wheel-hold control. It drops a report with no held control or
an unscalable high-resolution multiplier.

The host-switch session consumes `DivertedButtons` and `AnalyticsKeyEvents`.
It does not treat raw XY or raw-wheel events as host changes. OpenLogi does not
call the global fn 5 reset, because that reset can clear settings it does not
own.

## Open questions [#open-questions]

Logi Options+ 2.8 handles an unmatched fn 3 response with a nonzero software
ID as a diverted-key state change. OpenLogi filters nonzero software IDs from
its event paths. It therefore does not publish that late or foreign response.

The static evidence does not show how often this response occurs on hardware.
OpenLogi still returns a normal, matched fn 3 response to its caller as
`CidReportingChangeEcho`. The conditions and frequency need a device trace.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::{
        EmittingFeature,
        reprog_controls::{CidReportingChange, ReprogControlsFeature},
    },
    protocol::v20::Hidpp20Error,
};

async fn divert_first_control(mut device: Device) -> Result<(), Hidpp20Error> {
    device.enumerate_features().await?;
    let Some(feature) = device.get_feature::<ReprogControlsFeature>() else {
        return Ok(());
    };

    for index in 0..feature.get_count().await? {
        let info = feature.get_cid_info(index).await?;
        if !info.flags.is_divertable() {
            continue;
        }

        let before = feature.get_cid_reporting(info.cid).await?;
        let _events = feature.listen();
        feature
            .set_cid_reporting(
                info.cid,
                CidReportingChange {
                    diverted: Some(true),
                    ..Default::default()
                },
            )
            .await?;

        // Restore the reporting bit this example changed before returning.
        feature
            .set_cid_reporting(
                info.cid,
                CidReportingChange {
                    diverted: Some(before.diverted),
                    ..Default::default()
                },
            )
            .await?;
        break;
    }
    Ok(())
}
```


# 0x1C00 · persistentRemappableAction (/hidpp/features/x1c00-persistent-remappable-action)



`persistentRemappableAction` (`0x1c00`) stores a control's HID action in device
non-volatile memory. It supports a separate mapping for each control and host
slot. Its `ControlId` values are shared with `0x1b04`.

When both features exist, a live `0x1b04` diversion takes precedence over this
persistent mapping. The feature is relevant to
[Keyboard](/docs/features/keyboard), but OpenLogi has no device, agent, CLI, or
GUI consumer for it in v0.8.15.

> **Spec:** No public Logitech specification. Layout reverse-engineered from Logi Options+ 2.8 (static analysis). &#x2A;*Cross-checked:** Solaar. &#x2A;*Used by:** Not used by OpenLogi.

## Function reference [#function-reference]

`PersistentRemappableActionFeature` implements `0x1c00`.

### Methods [#methods]

| Function                    | HID++ fn | Signature                                                           | Returns                  |
| --------------------------- | -------- | ------------------------------------------------------------------- | ------------------------ |
| `get_feature_info`          | 0        | `()`                                                                | `RemappableCapabilities` |
| `get_count`                 | 1        | `()`                                                                | `RemappableInfo`         |
| `get_cid_info`              | 2        | `(index: u8, host: HostIndex)`                                      | `ControlId`              |
| `get_persistent_action`     | 3        | `(cid: ControlId, host: HostIndex)`                                 | `PersistentAction`       |
| `set_persistent_action`     | 4        | `(cid: ControlId, host: HostIndex, config: PersistentActionConfig)` | `()`                     |
| `reset_persistent_action`   | 5        | `(cid: ControlId, host: HostIndex)`                                 | `()`                     |
| `reset_to_factory_settings` | 6        | `(hosts: HostMask)`                                                 | `()`                     |

All methods are `async`. They return `Result<…, Hidpp20Error>`.

### Types [#types]

#### `RemappableCapabilities` [#remappablecapabilities]

| Flag                | Bit      | Description                         |
| ------------------- | -------- | ----------------------------------- |
| `KEYBOARD_REPORT`   | `1 << 0` | Keyboard or keypad output.          |
| `MOUSE_BUTTONS`     | `1 << 1` | Mouse-button output.                |
| `X_DISPLACEMENT`    | `1 << 2` | Mouse X displacement.               |
| `Y_DISPLACEMENT`    | `1 << 3` | Mouse Y displacement.               |
| `VERTICAL_ROLLER`   | `1 << 4` | Vertical-roller output.             |
| `HORIZONTAL_ROLLER` | `1 << 5` | Horizontal-roller or AC-pan output. |
| `CONSUMER_CONTROL`  | `1 << 6` | Consumer-control output.            |
| `INTERNAL_FUNCTION` | `1 << 7` | Internal-function output.           |
| `POWER_KEY`         | `1 << 8` | Power-key output.                   |

#### `RemappableInfo` and `HostIndex` [#remappableinfo-and-hostindex]

| Type             | Field or variant | Description                             |
| ---------------- | ---------------- | --------------------------------------- |
| `RemappableInfo` | `count: u8`      | Number of control IDs.                  |
| `RemappableInfo` | `host_count: u8` | Number of device host slots.            |
| `HostIndex`      | `Current`        | Current device host. Encodes as `0xff`. |
| `HostIndex`      | `Slot(u8)`       | Zero-based host slot.                   |

#### `PersistentAction` [#persistentaction]

| Field           | Type                     | Description                                      |
| --------------- | ------------------------ | ------------------------------------------------ |
| `cid`           | `ControlId`              | Control whose action was read.                   |
| `host`          | `HostIndex`              | Host slot for this mapping.                      |
| `action_id`     | `ActionId`               | Action type.                                     |
| `value`         | `u16`                    | Usage, displacement, or internal-function index. |
| `modifier_mask` | `ModifierMask`           | Keyboard modifiers for keyboard actions.         |
| `status`        | `PersistentActionStatus` | Complete response status byte.                   |

`PersistentAction::is_remapped()` tests status bit 0.

#### `PersistentActionConfig` [#persistentactionconfig]

| Field           | Type           | Description                                      |
| --------------- | -------------- | ------------------------------------------------ |
| `action_id`     | `ActionId`     | Action to store.                                 |
| `value`         | `u16`          | Usage, displacement, or internal-function index. |
| `modifier_mask` | `ModifierMask` | Keyboard modifiers for keyboard actions.         |

#### `ActionId` [#actionid]

| Variant                   | Value  | Description                                                  |
| ------------------------- | ------ | ------------------------------------------------------------ |
| `Empty`                   | `0x00` | No output. A read response reports zero value and modifiers. |
| `SendKeyboard`            | `0x01` | Keyboard or keypad report, HID usage page 7.                 |
| `SendMouseButton`         | `0x02` | Mouse-button report, HID usage page 9.                       |
| `SendXDisplacement`       | `0x03` | Mouse X displacement, usage page 1 code `0x30`.              |
| `SendYDisplacement`       | `0x04` | Mouse Y displacement, usage page 1 code `0x31`.              |
| `SendVerticalRoller`      | `0x05` | Vertical roller, usage page 1 code `0x38`.                   |
| `SendHorizontalRoller`    | `0x06` | Horizontal roller or AC pan, usage page 12 code `0x0238`.    |
| `SendConsumerControl`     | `0x07` | Consumer-control report, HID usage page 12.                  |
| `ExecuteInternalFunction` | `0x08` | Internal function. `value` is its index.                     |
| `SendPowerKey`            | `0x09` | Power-key report, HID usage page 1.                          |

#### `ModifierMask` [#modifiermask]

| Flag          | Bit      | Description    |
| ------------- | -------- | -------------- |
| `LEFT_CTRL`   | `1 << 0` | Left Control.  |
| `LEFT_SHIFT`  | `1 << 1` | Left Shift.    |
| `LEFT_ALT`    | `1 << 2` | Left Alt.      |
| `LEFT_GUI`    | `1 << 3` | Left GUI.      |
| `RIGHT_CTRL`  | `1 << 4` | Right Control. |
| `RIGHT_SHIFT` | `1 << 5` | Right Shift.   |
| `RIGHT_ALT`   | `1 << 6` | Right Alt.     |
| `RIGHT_GUI`   | `1 << 7` | Right GUI.     |

#### `PersistentActionStatus` [#persistentactionstatus]

| Flag       | Bit      | Description                              |
| ---------- | -------- | ---------------------------------------- |
| `REMAPPED` | `1 << 0` | Action differs from its factory default. |

`PersistentActionStatus` retains bits 1–7. Their meanings are unknown.

#### `HostMask` [#hostmask]

| Flag     | Bit      | Description |
| -------- | -------- | ----------- |
| `HOST_1` | `1 << 0` | Host 1.     |
| `HOST_2` | `1 << 1` | Host 2.     |
| `HOST_3` | `1 << 2` | Host 3.     |

### Events [#events]

`PersistentRemappableActionFeature` does not implement an event listener.

## Wire format [#wire-format]

The tables use logical HID++ payload bytes. OpenLogi sends 3-byte short
requests except for fn 4, which uses a 16-byte long request. Readers use an
extended 16-byte response view and ignore bytes without a modeled field.

### `get_feature_info` (fn 0) [#get_feature_info-fn-0]

Request: `[0x00, 0x00, 0x00]`.

| Byte(s) | Field                    | Notes                           |
| ------- | ------------------------ | ------------------------------- |
| 0–1     | `RemappableCapabilities` | BE `u16` bitfield.              |
| 2–15    | —                        | No field is parsed by OpenLogi. |

### `get_count` (fn 1) [#get_count-fn-1]

Request: `[0x00, 0x00, 0x00]`.

| Byte | Field        | Notes                           |
| ---- | ------------ | ------------------------------- |
| 0    | `count`      | Number of control IDs.          |
| 1    | `host_count` | Number of host slots.           |
| 2–15 | —            | No field is parsed by OpenLogi. |

### `get_cid_info` (fn 2) [#get_cid_info-fn-2]

Request: `[index, host, 0x00]`. `host` is `HostIndex` as `u8`; `Current` is
`0xff` and `Slot(n)` is `n`.

| Byte(s) | Field | Notes                           |
| ------- | ----- | ------------------------------- |
| 0–1     | `cid` | BE `ControlId`.                 |
| 2–15    | —     | No field is parsed by OpenLogi. |

### `get_persistent_action` (fn 3) [#get_persistent_action-fn-3]

Request: `[cid_hi, cid_lo, host]`, where `cid_hi,cid_lo` are the `ControlId` as
BE `u16`.

| Byte(s)    | Field           | Notes                                                                              |
| ---------- | --------------- | ---------------------------------------------------------------------------------- |
| 0–1        | `cid`           | BE `ControlId`.                                                                    |
| 2          | `host`          | `HostIndex` byte.                                                                  |
| 3          | `action_id`     | `ActionId`, including `Empty = 0x00`.                                              |
| 4–5        | `value`         | BE `u16`.                                                                          |
| 6          | `modifier_mask` | `ModifierMask` bits.                                                               |
| 7 bit 0    | `REMAPPED`      | Known `PersistentActionStatus` bit. Options+ 2.8; Solaar.                          |
| 7 bits 1–7 | unknown status  | Retained by `PersistentActionStatus`; meaning is unverified. Options+ 2.8; Solaar. |
| 8–15       | —               | No field is parsed by OpenLogi.                                                    |

### `set_persistent_action` (fn 4) [#set_persistent_action-fn-4]

OpenLogi sends a 16-byte long request. It writes zero padding in bytes 7–15.

| Byte(s) | Field           | Notes                               |
| ------- | --------------- | ----------------------------------- |
| 0–1     | `cid`           | BE `ControlId`.                     |
| 2       | `host`          | `HostIndex` byte.                   |
| 3       | `action_id`     | `ActionId`.                         |
| 4–5     | `value`         | BE `u16`.                           |
| 6       | `modifier_mask` | `ModifierMask` bits.                |
| 7–15    | —               | OpenLogi writes fixed zero padding. |

OpenLogi returns `()` after the request succeeds. It does not parse a response
body or read the action back.

### `reset_persistent_action` (fn 5) [#reset_persistent_action-fn-5]

Request: `[cid_hi, cid_lo, host]`, with `ControlId` in BE order. OpenLogi
returns `()` and does not parse a response body.

### `reset_to_factory_settings` (fn 6) [#reset_to_factory_settings-fn-6]

Request: `[hosts_bits, 0x00, 0x00]`. `hosts_bits` is `HostMask`; bits 0, 1, and
2 select `HOST_1`, `HOST_2`, and `HOST_3`. OpenLogi returns `()` and does not
parse a response body.

## How OpenLogi uses it [#how-openlogi-uses-it]

OpenLogi registers a typed wrapper for `0x1c00`. It does not call this wrapper
from `openlogi-device`, the agent, the CLI, or the GUI in v0.8.15. OpenLogi
therefore does not write, reset, restore, or read back persistent actions.

Direct Rust callers must choose a supported action from
`RemappableCapabilities`. They must also select the intended `HostIndex`.
OpenLogi does not add a capability gate, a read-modify-write layer, or a
post-write read-back around the wrapper.

## Open questions [#open-questions]

Only status bit 0 has a recovered meaning. Options+ 2.8 and Solaar retain the
complete status byte, but they do not establish meanings for bits 1–7. OpenLogi
preserves those unknown bits in `PersistentActionStatus`.

The protocol evidence is static. No hardware trace verifies status-bit meanings,
the `0xff` current-host sentinel, or device treatment of fn 4 padding.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::{
        hosts_info::HostIndex,
        persistent_remappable_action::{
            ActionId, ModifierMask, PersistentActionConfig,
            PersistentRemappableActionFeature, RemappableCapabilities,
        },
    },
    protocol::v20::Hidpp20Error,
};

async fn map_first_control(mut device: Device) -> Result<(), Hidpp20Error> {
    device.enumerate_features().await?;
    let Some(feature) = device.get_feature::<PersistentRemappableActionFeature>() else {
        return Ok(());
    };

    let caps = feature.get_feature_info().await?;
    if !caps.contains(RemappableCapabilities::KEYBOARD_REPORT) {
        return Ok(());
    }
    let info = feature.get_count().await?;
    if info.count == 0 {
        return Ok(());
    }

    let host = HostIndex::Current;
    let cid = feature.get_cid_info(0, host).await?;
    let config = PersistentActionConfig {
        action_id: ActionId::SendKeyboard,
        value: 0x0006, // HID keycode for C.
        modifier_mask: ModifierMask::LEFT_CTRL,
    };
    feature.set_persistent_action(cid, host, config).await?;
    Ok(())
}
```


# 0x1D4B · wirelessDeviceStatus (/hidpp/features/x1d4b-wireless-device-status)



`wirelessDeviceStatus` is an event-only feature for wireless devices. OpenLogi uses its accepted broadcast as a reconnection signal, then re-arms capture sessions and schedules a configuration reapply.

> **Spec:** No public Logitech specification. Layout reverse-engineered from Logi Options+ 2.8 (static analysis). &#x2A;*Cross-checked:** G HUB (static analysis), Solaar. &#x2A;*Used by:** OpenLogi inventory and capture sessions.

## Function reference [#function-reference]

`WirelessDeviceStatusFeature` implements feature version `0`. It sends no HID++ requests. Its event source attaches to one device and feature-table index, and `listen` creates a receiver for decoded broadcasts.

### Methods [#methods]

\| Function | HID++ fn | Signature | Returns |
\| --- | --- | --- |
\| `listen` | event | `(&self)` | `async_channel::Receiver<WirelessDeviceStatusEvent>` |
\| `WirelessDeviceStatusEvent::decode` | event | `(function_id: u8, payload: &[u8; 16])` | `Option<WirelessDeviceStatusEvent>` |

`listen` comes from `EmittingFeature`. `decode` returns `None` unless `function_id` is `0`.

### Types [#types]

#### `WirelessDeviceStatusBroadcast` [#wirelessdevicestatusbroadcast]

| Field     | Type                          | Description                              |
| --------- | ----------------------------- | ---------------------------------------- |
| `status`  | `WirelessDeviceStatus`        | The status reported by the device.       |
| `request` | `WirelessDeviceStatusRequest` | The host action requested by the device. |
| `reason`  | `WirelessDeviceStatusReason`  | The reason for the broadcast.            |

#### `WirelessDeviceStatus` [#wirelessdevicestatus]

| Variant        | Value        | Description                     |
| -------------- | ------------ | ------------------------------- |
| `Unknown`      | `0x00`       | Unknown wireless-device status. |
| `Reconnection` | `0x01`       | The device is reconnecting.     |
| `Other(u8)`    | other values | An unmodelled raw value.        |

#### `WirelessDeviceStatusRequest` [#wirelessdevicestatusrequest]

| Variant                         | Value        | Description                           |
| ------------------------------- | ------------ | ------------------------------------- |
| `NoRequest`                     | `0x00`       | No host action is requested.          |
| `SoftwareReconfigurationNeeded` | `0x01`       | The host must reconfigure the device. |
| `Other(u8)`                     | other values | An unmodelled raw value.              |

#### `WirelessDeviceStatusReason` [#wirelessdevicestatusreason]

| Variant                | Value        | Description                                 |
| ---------------------- | ------------ | ------------------------------------------- |
| `Unknown`              | `0x00`       | Unknown broadcast reason.                   |
| `PowerSwitchActivated` | `0x01`       | A power-switch action caused the broadcast. |
| `Other(u8)`            | other values | An unmodelled raw value.                    |

### Events [#events]

| Variant                                      | Payload                         | Description                              |
| -------------------------------------------- | ------------------------------- | ---------------------------------------- |
| `WirelessDeviceStatusEvent::StatusBroadcast` | `WirelessDeviceStatusBroadcast` | A decoded function-`0` status broadcast. |

## Wire format [#wire-format]

The device sends an unsolicited status broadcast. OpenLogi sends no request for this feature. Its decoder receives a 16-byte extended payload, reads bytes `0` through `2`, and ignores the remaining bytes.

### `StatusBroadcast` (event, fn 0) [#statusbroadcast-event-fn-0]

| Byte | Field           | Type                          | Values                                                         | Notes                            |
| ---- | --------------- | ----------------------------- | -------------------------------------------------------------- | -------------------------------- |
| 0    | `status`        | `WirelessDeviceStatus`        | `0x00` = `Unknown`; `0x01` = `Reconnection`                    | Other values become `Other(u8)`. |
| 1    | `request`       | `WirelessDeviceStatusRequest` | `0x00` = `NoRequest`; `0x01` = `SoftwareReconfigurationNeeded` | Other values become `Other(u8)`. |
| 2    | `reason`        | `WirelessDeviceStatusReason`  | `0x00` = `Unknown`; `0x01` = `PowerSwitchActivated`            | Other values become `Other(u8)`. |
| 3–15 | unmodelled tail | —                             | No established meaning                                         | OpenLogi ignores these bytes.    |

## How OpenLogi uses it [#how-openlogi-uses-it]

Inventory records the feature-table index when it enumerates `0x1d4b`. It accepts an inventory event only when the software ID is `0`, the feature index matches, and `WirelessDeviceStatusEvent::decode` accepts function `0`. On acceptance, inventory drops cached lock-key state and requests reconciliation.

The agent treats the resulting device-wake event as a reconfiguration signal. It schedules a reapply on the next inventory snapshot, even if that snapshot never observed the device offline.

Capture sessions query the feature during setup. When present, they retain a listener. Every decoded broadcast resets captured input state, waits for the re-arm settle delay, and re-arms if I/O remains allowed. The session does not branch on `status`, `request`, or `reason`.

The feature owns no writable state. It has no read-modify-write operation, read-back check, or state restore. Dropping the feature removes its guarded channel listener.

## Open questions [#open-questions]

OpenLogi and Solaar accept only function `0`. Options+ and the Linux HID++ driver treat any report at the `0x1d4b` feature index as a reconnection signal. No capture or field report establishes a non-zero-function layout, so OpenLogi keeps the function-`0` gate.

The available evidence does not assign meanings to payload bytes `3` through `15`. OpenLogi ignores them.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::{
        EmittingFeature,
        wireless_device_status::{WirelessDeviceStatusEvent, WirelessDeviceStatusFeature},
    },
};

// `device` is `&mut Device`, created with `Device::new`.
device.enumerate_features().await?;
if let Some(feature) = device.get_feature::<WirelessDeviceStatusFeature>() {
    let events = feature.listen();
    while let Ok(WirelessDeviceStatusEvent::StatusBroadcast(status)) = events.recv().await {
        println!("{status:?}");
    }
}
```


# 0x1F20 · adcMeasurement (/hidpp/features/x1f20-adc-measurement)



`adcMeasurement` reports a headset battery voltage and link or charging flags.
Wireless G-series headsets such as G733 expose it without `0x1000`, `0x1001`, or `0x1004`.
The feature does not report a percentage.

> **Spec:** No public Logitech specification. Layout reverse-engineered from Logi Options+ 2.8 (static analysis). &#x2A;*Cross-checked:** Linux HID++ driver, Solaar, G HUB (static analysis). &#x2A;*Used by:** device inventory and `openlogi list`.

## Function reference [#function-reference]

`AdcMeasurementFeature` (`0x1F20`) implements one request method.

### Methods [#methods]

| Function              | HID++ fn     | Signature           | Returns          |
| --------------------- | ------------ | ------------------- | ---------------- |
| `get_adc_measurement` | 0            | `()`                | `AdcMeasurement` |
| `is_status_broadcast` | event helper | `(function_id: u8)` | `bool`           |

`get_adc_measurement` is `async` and returns `Result<AdcMeasurement, Hidpp20Error>`.
This wrapper does not expose the extended-settings or sleep-timeout operations found in G HUB static analysis.

### Types [#types]

#### `AdcMeasurement` [#adcmeasurement]

| Variant    | Fields                                         | Description                                                     |
| ---------- | ---------------------------------------------- | --------------------------------------------------------------- |
| `Unlinked` | /                                              | Battery-powered headset is not linked. No reading is available. |
| `Linked`   | `voltage_mv: u16`, `status: AdcChargingStatus` | Live battery reading.                                           |

#### `AdcChargingStatus` [#adcchargingstatus]

| Variant       | Value  | Description                                            |
| ------------- | ------ | ------------------------------------------------------ |
| `Discharging` | `0x01` | Headset runs on battery.                               |
| `Charging`    | `0x03` | External power is present and the headset is charging. |
| `Full`        | `0x07` | External power is present and charging is complete.    |

### Events [#events]

This wrapper does not implement `EmittingFeature` or expose a typed event.
`is_status_broadcast(function_id)` returns `true` only for function `0`.

## Wire format [#wire-format]

### `get_adc_measurement` (fn 0) [#get_adc_measurement-fn-0]

Request: `[0x00, 0x00, 0x00]` (no parameters).

Response:

| Byte | Field        | Notes                     |
| ---- | ------------ | ------------------------- |
| 0–1  | `voltage_mv` | `u16`, BE, in millivolts. |
| 2    | flags        | Link and charging state.  |

Flags:

| Bit | Meaning         | Notes                                                 |
| --- | --------------- | ----------------------------------------------------- |
| 0   | Linked          | Clear produces `Unlinked`, regardless of other flags. |
| 1   | Charging        | Set in the known charging value `0x03`.               |
| 2   | Charge complete | Set in the known full value `0x07`.                   |
| 3–7 | /               | No meaning is modeled.                                |

The known linked flag values are `0x01`, `0x03`, and `0x07`.
Other linked values return `Hidpp20Error::UnsupportedResponse`.

A `LogitechInternal` feature error (`0x05`) returns `AdcMeasurement::Unlinked`.
A G733 dongle reports that error while its headset is switched off.
Other feature errors remain errors.

### Status broadcast (event fn 0) [#status-broadcast-event-fn-0]

An unsolicited function-`0` report carries the same three bytes as `get_adc_measurement`.
It announces a changed voltage or headset link state.

## How OpenLogi uses it [#how-openlogi-uses-it]

Inventory selects ADC measurement only when `0x1004`, `0x1000`, and `0x1001` are absent.
A live sample maps through OpenLogi's 13-point single-cell Li-Po voltage curve.
The result becomes an estimated percentage and a local battery level.

PR #1601 shipped this G733 battery and power-state support in v0.8.13.
A captured G733 sample of `3,905 mV` with flags `0x01` maps to an estimated `67%`.
A `LogitechInternal` error or a clear link bit marks the headset unlinked.

The persistent inventory listener accepts an unsolicited software-ID-`0`, function-`0` report.
It queues one coalesced reconciliation even when the flags are unknown.
The subsequent read decides whether the headset is linked or unavailable.

OpenLogi only reads this feature.

## Open questions [#open-questions]

For linked flags `0x0F`, Linux treats the result as unknown or offline.
Logi Options+ preserves the individual flag bits.
OpenLogi returns `UnsupportedResponse`, and inventory treats that read as unavailable.

OpenLogi uses the same 13-point curve as `0x1001` and Solaar.
The Linux headset curve uses different values, about `3.4`–`4.03 V`.
No hardware comparison with the Logi Options+ percentage display establishes the correct headset calibration.

G HUB static analysis identifies extended-settings and sleep-timeout operations.
It does not establish their function indexes or packet layouts.
OpenLogi does not expose them.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::{
        adc_measurement::{AdcMeasurement, AdcMeasurementFeature},
    },
};

// `channel` and `device_index` identify an already opened HID++ device.
let mut device = Device::new(channel, device_index).await?;
device.enumerate_features().await?;

if let Some(feature) = device.get_feature::<AdcMeasurementFeature>() {
    match feature.get_adc_measurement().await? {
        AdcMeasurement::Linked { voltage_mv, status } => {
            println!("{} mV: {status:?}", voltage_mv);
        }
        AdcMeasurement::Unlinked => println!("headset is unlinked"),
    }
}
```

**See also:** [0x1001 batteryVoltage](/hidpp/features/x1001-battery-voltage) and [0x1004 unifiedBattery](/hidpp/features/x1004-unified-battery).


# 0x2001 · swapLeftRightButton (/hidpp/features/x2001-swap-left-right-button)



OpenLogi v0.8.15 lists `0x2001` as `SwapLeftRightButton`, but it has no feature wrapper. No public specification or static analysis of Logitech software establishes its operations or wire layout.

> **Spec:** No public Logitech specification. The available evidence does not establish a request layout. &#x2A;*Used by:** Not used by OpenLogi.

<Callout type="warn" title="Not implemented in OpenLogi">
  OpenLogi v0.8.15 recognises this feature ID in its registry, but provides no typed wrapper. It does not read or write this feature.
</Callout>

## What it does [#what-it-does]

The registry name suggests a left-right button swap. That name does not establish function indices, request or response bytes, state values, persistence, or device scope. This page therefore makes no firmware-behaviour claim.

**Status:** Not implemented in OpenLogi v0.8.15 · &#x2A;*Feature ID:** `0x2001`


# 0x2100 · verticalScrolling (/hidpp/features/x2100-vertical-scrolling)



`verticalScrolling` reports physical and preferred scrolling properties for a vertical wheel. OpenLogi v0.8.15 provides a typed, read-only wrapper, but no device-layer or user-facing feature consumes this information.

> **Spec:** Logitech HID++ 2.0 — *verticalScrolling*. &#x2A;*Cross-checked:** Solaar. &#x2A;*Used by:** Not used by OpenLogi.

## Function reference [#function-reference]

`VerticalScrollingFeature` (`0x2100`) supports feature version 0 and exposes one asynchronous read method.

### Methods [#methods]

| Function          | HID++ fn | Signature | Returns                            |
| ----------------- | -------- | --------- | ---------------------------------- |
| `get_roller_info` | 0        | `()`      | `Result<RollerInfo, Hidpp20Error>` |

### Types [#types]

#### `RollerInfo` [#rollerinfo]

| Field               | Type          | Description                               |
| ------------------- | ------------- | ----------------------------------------- |
| `roller_type`       | `RollerType`  | Mechanical roller type.                   |
| `ratchets_per_turn` | `u8`          | Physical ratchets in one full wheel turn. |
| `scroll_lines`      | `ScrollLines` | Preferred scroll amount.                  |

#### `RollerType` [#rollertype]

| Variant                  | Value  | Description                                         |
| ------------------------ | ------ | --------------------------------------------------- |
| `Standard`               | `0x01` | Standard one- or two-dimensional roller.            |
| `ThreeG`                 | `0x03` | 3G roller.                                          |
| `MicroRatchet`           | `0x04` | Micro-ratchet roller.                               |
| `Touchpad`               | `0x05` | Touchpad scrolling.                                 |
| `TouchpadNaturalDefault` | `0x06` | Touchpad with natural scrolling enabled by default. |

#### `ScrollLines` [#scrolllines]

| Variant         | Value         | Description                                  |
| --------------- | ------------- | -------------------------------------------- |
| `SystemDefault` | `0x00`        | Keep the host system setting.                |
| `Lines(u8)`     | `0x01`–`0xFE` | Scroll this many lines per movement.         |
| `Page`          | `0xFF`        | Scroll one full page or screen per movement. |

### Events [#events]

This feature defines no events.

## Wire format [#wire-format]

OpenLogi sends a three-byte zero request and expands the reply to a 16-byte buffer before decoding it. The defined fields occupy bytes 0–2.

### `get_roller_info` (fn 0) [#get_roller_info-fn-0]

**Request:** `[0x00, 0x00, 0x00]`. The three bytes are padding.

**Response:**

| Byte | Field               | Notes                                                                                           |
| ---- | ------------------- | ----------------------------------------------------------------------------------------------- |
| 0    | `roller_type`       | `RollerType` value. OpenLogi rejects an unknown value with `Hidpp20Error::UnsupportedResponse`. |
| 1    | `ratchets_per_turn` | Raw `u8` count.                                                                                 |
| 2    | `scroll_lines`      | `0x00` is `SystemDefault`; `0xFF` is `Page`; every other value is `Lines(value)`.               |
| 3–15 | Reserved            | OpenLogi ignores these bytes.                                                                   |

## How OpenLogi uses it [#how-openlogi-uses-it]

The feature registry instantiates `VerticalScrollingFeature` for feature `0x2100` at version 0 or later. The v0.8.15 device, inventory, session, agent, and desktop layers have no consumer for `RollerInfo`. OpenLogi therefore performs no write, read-back, capability gate, state restore, or user-facing action for this feature.

## Usage (Rust) [#usage-rust]

```rust
use std::sync::Arc;

use hidpp::{
    channel::HidppChannel,
    device::Device,
    feature::vertical_scrolling::VerticalScrollingFeature,
};

async fn read_roller_info(
    channel: Arc<HidppChannel>,
    device_index: u8,
) -> Result<(), Box<dyn std::error::Error>> {
    let mut device = Device::new(channel, device_index).await?;
    device.enumerate_features().await?;

    let Some(feature) = device.get_feature::<VerticalScrollingFeature>() else {
        return Ok(());
    };
    let info = feature.get_roller_info().await?;
    println!("{info:?}");
    Ok(())
}
```


# 0x2110 · smartShift (/hidpp/features/x2110-smartshift)



The legacy **smartShift** feature controls a wheel’s free-spin or ratchet mode. It is used, for example, by MX Master 2S. Newer SmartShift wheels use [`0x2111` smartShiftEnhanced](/hidpp/features/x2111-smartshift-enhanced).

> **Spec:** Logitech HID++ 2.0 — *smartShift*. &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis), Solaar. &#x2A;*Used by:** [SmartShift](/docs/features/mouse/smartshift).

## Function reference [#function-reference]

`SmartShiftFeature` is the version-0 wrapper for feature `0x2110`.

### Methods [#methods]

| Function                   | HID++ fn | Signature                                                                                                       | Returns              |
| -------------------------- | -------- | --------------------------------------------------------------------------------------------------------------- | -------------------- |
| `get_ratchet_control_mode` | 0        | `()`                                                                                                            | `RatchetControlMode` |
| `set_ratchet_control_mode` | 1        | `(wheel_mode: Option<WheelMode>, auto_disengage: Option<NonZeroU8>, auto_disengage_default: Option<NonZeroU8>)` | `RatchetControlMode` |

Both methods are `async`. Both return `Result<…, Hidpp20Error>`.

### Types [#types]

#### `RatchetControlMode` [#ratchetcontrolmode]

| Field                    | Type        | Description                                                                              |
| ------------------------ | ----------- | ---------------------------------------------------------------------------------------- |
| `wheel_mode`             | `WheelMode` | Software- or button-selected wheel mode. It does not show a transient automatic release. |
| `auto_disengage`         | `u8`        | Automatic-release threshold. `0xFF` means permanent ratchet.                             |
| `auto_disengage_default` | `u8`        | Default threshold as modelled by OpenLogi. See [Open questions](#open-questions).        |

#### `WheelMode` [#wheelmode]

| Variant    | Value | Description     |
| ---------- | ----- | --------------- |
| `Freespin` | `1`   | Free-spin mode. |
| `Ratchet`  | `2`   | Ratchet mode.   |

### Events [#events]

This wrapper declares no events.

## Wire format [#wire-format]

Every wrapper call sends a 3-byte HID++ short-report payload. `FeatureEndpoint::call()` always uses that payload size. The wrapper calls `extend_payload()` before parsing a reply. Therefore, a short reply is zero-padded to 16 bytes rather than length-validated.

### `get_ratchet_control_mode` (fn 0) [#get_ratchet_control_mode-fn-0]

The request has no parameters. OpenLogi writes `[0x00, 0x00, 0x00]`.

| Response byte | Field                    | Notes                                                                                      |
| ------------- | ------------------------ | ------------------------------------------------------------------------------------------ |
| 0             | `wheel_mode`             | `1` = `Freespin`; `2` = `Ratchet`. Other values return `UnsupportedResponse`.              |
| 1             | `auto_disengage`         | `0x01`–`0xFE` is a threshold in quarter-turns/s. `0xFF` means permanent ratchet.           |
| 2             | `auto_disengage_default` | OpenLogi parses this as the default threshold. Options+ 2.8 and Solaar use only bytes 0–1. |

### `set_ratchet_control_mode` (fn 1) [#set_ratchet_control_mode-fn-1]

The request is `[wheel_mode, auto_disengage, auto_disengage_default]`.

| Request byte | Source                   | Notes                                                                                                                            |
| ------------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| 0            | `wheel_mode`             | `None` encodes `0` to preserve the current mode. `1` selects free-spin. `2` selects ratchet.                                     |
| 1            | `auto_disengage`         | `None` encodes `0` to preserve the threshold. `0x01`–`0xFE` is a threshold. `0xFF` selects permanent ratchet.                    |
| 2            | `auto_disengage_default` | `None` encodes `0`. The device layer always supplies `None`, so it writes `0`. Direct wrapper callers can send a non-zero value. |

OpenLogi parses the reply with the getter layout and returns `RatchetControlMode`. The device layer compares bytes 0–1 with the fields it wrote. The raw wrapper returns the reply without making that comparison.

## How OpenLogi uses it [#how-openlogi-uses-it]

OpenLogi first probes `0x2111`. It repeats a missing-feature probe once before it falls back to `0x2110`. Transport and protocol errors do not trigger that fallback. The two feature wrappers have no version gate beyond version `0`.

The device layer reads the current status before a sensitivity update or a toggle. It writes the selected mode and threshold together. It always writes byte 2 as `0`, so the device retains that field under OpenLogi’s model. It accepts a write only when the echoed mode and threshold match. A transient failure retries the same target once.

The desktop performs a second read after every panel write. It marks the write confirmed only when the full returned status matches. OpenLogi stores the selected state and reapplies it after reconnect because SmartShift state is volatile. It does not restore a previous SmartShift state when the application exits.

## Open questions [#open-questions]

* OpenLogi and its cited HID++ source model byte 2 as `auto_disengage_default`. Options+ 2.8 and Solaar send and consume only two semantic bytes. No hardware capture or public source resolves whether byte 2 is a valid field, a reserved byte, or an implementation-specific extension. OpenLogi therefore exposes it in the raw wrapper, while its device layer writes `0`.
* The reviewed sources did not establish a SmartShift-specific HID++ notification layout. OpenLogi defines no event type for this feature.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::smartshift::{SmartShiftFeature, WheelMode},
};
use std::num::NonZeroU8;

// `device` is an initialized `&mut Device`.
device.enumerate_features().await?;
if let Some(feature) = device.get_feature::<SmartShiftFeature>() {
    let current = feature.get_ratchet_control_mode().await?;
    println!("current mode: {:?}", current.wheel_mode);

    let applied = feature
        .set_ratchet_control_mode(
            Some(WheelMode::Freespin),
            NonZeroU8::new(16),
            None,
        )
        .await?;
    println!("applied threshold: {}", applied.auto_disengage);
}
```


# 0x2111 · smartShiftEnhanced (/hidpp/features/x2111-smartshift-enhanced)



**smartShiftEnhanced** controls newer SmartShift wheels, including MX Master 3, 3S, and 4. OpenLogi uses this feature before it considers the legacy [`0x2110` smartShift](/hidpp/features/x2110-smartshift) fallback.

> **Spec:** Logitech HID++ 2.0 — *x2111 smartShift Enhanced*. &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis), Solaar. &#x2A;*Used by:** [SmartShift](/docs/features/mouse/smartshift).

## Function reference [#function-reference]

`SmartShiftEnhancedFeature` is the version-0 wrapper for feature `0x2111`.

### Methods [#methods]

| Function                   | HID++ fn | Signature                                  | Returns                    |
| -------------------------- | -------- | ------------------------------------------ | -------------------------- |
| `get_capabilities`         | 0        | `()`                                       | `SmartShiftEnhancedInfo`   |
| `get_ratchet_control_mode` | 1        | `()`                                       | `SmartShiftEnhancedStatus` |
| `set_ratchet_control_mode` | 2        | `(change: SmartShiftEnhancedStatusChange)` | `SmartShiftEnhancedStatus` |

All methods are `async`. All methods return `Result<…, Hidpp20Error>`.

### Types [#types]

#### `SmartShiftEnhancedInfo` [#smartshiftenhancedinfo]

| Field                    | Type                             | Description                                      |
| ------------------------ | -------------------------------- | ------------------------------------------------ |
| `capabilities`           | `SmartShiftEnhancedCapabilities` | Capability bits. Unknown bits are retained.      |
| `auto_disengage_default` | `u8`                             | Default automatic-release threshold.             |
| `default_tunable_torque` | `u8`                             | Default torque as a percentage of maximum force. |
| `max_force`              | `u8`                             | Maximum force in gram-force units.               |

#### `SmartShiftEnhancedStatus` [#smartshiftenhancedstatus]

| Field                    | Type        | Description                                      |
| ------------------------ | ----------- | ------------------------------------------------ |
| `wheel_mode`             | `WheelMode` | Current requested wheel mode.                    |
| `auto_disengage`         | `u8`        | Current automatic-release threshold.             |
| `current_tunable_torque` | `u8`        | Current torque as a percentage of maximum force. |

#### `SmartShiftEnhancedStatusChange` [#smartshiftenhancedstatuschange]

`None` encodes as the wire value `0`, which means do not change.

| Field            | Type                | Description                                     |
| ---------------- | ------------------- | ----------------------------------------------- |
| `wheel_mode`     | `Option<WheelMode>` | Wheel mode to apply, or `None` to preserve it.  |
| `auto_disengage` | `Option<NonZeroU8>` | Threshold to apply, or `None` to preserve it.   |
| `tunable_torque` | `Option<NonZeroU8>` | Torque byte to apply, or `None` to preserve it. |

#### `SmartShiftEnhancedCapabilities` [#smartshiftenhancedcapabilities]

| Flag             | Bit | Description                                   |
| ---------------- | --- | --------------------------------------------- |
| `TUNABLE_TORQUE` | 0   | The device advertises tunable ratchet torque. |

#### `WheelMode` [#wheelmode]

`WheelMode` is re-exported from `0x2110` smartShift.

| Variant    | Value | Description     |
| ---------- | ----- | --------------- |
| `Freespin` | `1`   | Free-spin mode. |
| `Ratchet`  | `2`   | Ratchet mode.   |

### Events [#events]

This wrapper declares no events.

## Wire format [#wire-format]

All three wrapper methods send a 3-byte HID++ short-report payload. Each wrapper calls `extend_payload()` before it parses the reply. A short reply is therefore zero-padded to 16 bytes. The protocol wrapper does not validate response length.

### `get_capabilities` (fn 0) [#get_capabilities-fn-0]

The request has no parameters. OpenLogi writes `[0x00, 0x00, 0x00]`.

| Response byte | Field                    | Notes                                                                                     |
| ------------- | ------------------------ | ----------------------------------------------------------------------------------------- |
| 0             | `capabilities`           | Bit 0 is `TUNABLE_TORQUE`. OpenLogi retains unknown bits.                                 |
| 1             | `auto_disengage_default` | Default automatic-release threshold.                                                      |
| 2             | `default_tunable_torque` | Default torque, expressed as a percentage of maximum force.                               |
| 3             | `max_force`              | Maximum force in gram-force units.                                                        |
| 4–15          | /                        | OpenLogi has no fields for these bytes. See [Open questions](#open-questions) for byte 4. |

### `get_ratchet_control_mode` (fn 1) [#get_ratchet_control_mode-fn-1]

The request has no parameters. OpenLogi writes `[0x00, 0x00, 0x00]`.

| Response byte | Field                    | Notes                                                                                            |
| ------------- | ------------------------ | ------------------------------------------------------------------------------------------------ |
| 0             | `wheel_mode`             | `1` = `Freespin`; `2` = `Ratchet`. Other values return `UnsupportedResponse`.                    |
| 1             | `auto_disengage`         | `0x01`–`0xFE` is a threshold in quarter-turns/s. `0xFF` means permanent ratchet.                 |
| 2             | `current_tunable_torque` | `0` means no torque level. The device layer accepts `1`–`100` percent and rejects higher values. |

### `set_ratchet_control_mode` (fn 2) [#set_ratchet_control_mode-fn-2]

The request is `[wheel_mode, auto_disengage, tunable_torque]`.

| Request byte | Source                  | Notes                                                                                                                                    |
| ------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| 0            | `change.wheel_mode`     | `None` encodes `0` to preserve the mode. `1` selects free-spin. `2` selects ratchet.                                                     |
| 1            | `change.auto_disengage` | `None` encodes `0` to preserve the threshold. `0x01`–`0xFE` is a threshold. `0xFF` selects permanent ratchet.                            |
| 2            | `change.tunable_torque` | `None` encodes `0` to preserve torque. The raw wrapper accepts any `NonZeroU8`. OpenLogi’s device layer supplies only `1`–`100` percent. |

The reply has the getter layout. The raw wrapper returns the parsed status. The device layer compares every field it wrote with the echoed value.

## How OpenLogi uses it [#how-openlogi-uses-it]

OpenLogi probes `0x2111` first. It retries a missing-feature probe once before it falls back to `0x2110`. Other feature, transport, and protocol errors propagate. Both wrappers are registered at feature version `0`.

A mode toggle reads the current status, then writes the opposite mode. A sensitivity change reads the status, then writes the current mode with the new threshold. Both operations send torque byte 2 as `0`, so they preserve torque. An explicit torque change sends bytes 0–1 as `0`.

OpenLogi reads capabilities before it writes an explicit torque value. It sends a non-zero torque only when bit 0 is `TUNABLE_TORQUE`. Without that bit, a mode or threshold write still succeeds with byte 2 set to `0`. An explicit torque request returns `FeatureUnsupported`.

`TunableTorque` limits OpenLogi’s device, configuration, and IPC values to `1`–`100` percent. A status reply of `0` becomes no torque value. A status reply above `100` returns `UnsupportedResponse`. A full saved configuration writes torque only when it differs from the current value.

The device layer accepts a write only when its reply matches each written field. It retries the same target once after a transient error. The desktop then reads the status again and compares the whole value. OpenLogi saves the chosen state and reapplies it after reconnect because SmartShift state is volatile. It does not restore a previous SmartShift state when the application exits.

## Open questions [#open-questions]

* Options+ 2.8 exposes a low-level capability getter with a possible fifth output byte. Its higher-level caller uses only bytes 0–3. OpenLogi also uses bytes 0–3. No public specification or hardware trace establishes a meaning for byte 4.
* The reviewed sources did not establish a SmartShift-specific HID++ notification layout. OpenLogi defines no event type for this feature.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::{
        smartshift::WheelMode,
        smartshift_enhanced::{
            SmartShiftEnhancedFeature,
            SmartShiftEnhancedStatusChange,
        },
    },
};
use std::num::NonZeroU8;

// `device` is an initialized `&mut Device`.
device.enumerate_features().await?;
if let Some(feature) = device.get_feature::<SmartShiftEnhancedFeature>() {
    let status = feature.get_ratchet_control_mode().await?;
    println!("current torque byte: {}", status.current_tunable_torque);

    let applied = feature
        .set_ratchet_control_mode(SmartShiftEnhancedStatusChange {
            wheel_mode: Some(WheelMode::Ratchet),
            auto_disengage: NonZeroU8::new(16),
            tunable_torque: None,
        })
        .await?;
    println!("applied mode: {:?}", applied.wheel_mode);
}
```


# 0x2120 · highResolutionScrolling (/hidpp/features/x2120-high-resolution-scrolling)



`highResolutionScrolling` (`0x2120`) is listed in OpenLogi's HID++ feature registry. OpenLogi v0.8.15 has no typed wrapper, device operation, event decoder, or user-facing control for it.

<Callout type="warn" title="Not implemented in OpenLogi">
  The registry names `0x2120` `HighResolutionScrolling`, but registers no implementation. OpenLogi code cannot verify its methods, payload layout, or events.
</Callout>

## What it does [#what-it-does]

The feature name identifies `0x2120` as `HighResolutionScrolling`. Neither OpenLogi's code nor static analysis of Logitech software establishes a wire layout for this feature. This reference therefore does not infer functions or behavior from the later [`0x2121 hiResWheel`](/hidpp/features/x2121-hires-wheel) feature.

**Status:** Not implemented in OpenLogi v0.8.15 · &#x2A;*Feature ID:** `0x2120`


# 0x2121 · hiResWheel (/hidpp/features/x2121-hires-wheel)



`hiResWheel` configures high-resolution wheel reports and their native or diverted route. It also reports wheel movement and ratchet-switch changes. OpenLogi v0.8.15 preserves the complete three-byte mode when it changes the documented settings.

> **Spec:** Logitech HID++ 2.0 — *x2121 hiResWheel* (v1). &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis), Solaar. &#x2A;*Used by:** [Scrolling](/docs/features/mouse/scrolling).

## Function reference [#function-reference]

`HiResWheelFeature` (`0x2121`) supports feature version 0 and exposes the following methods.

### Methods [#methods]

| Function                   | HID++ fn | Signature           | Returns                                    |
| -------------------------- | -------- | ------------------- | ------------------------------------------ |
| `get_wheel_capabilities`   | 0        | `()`                | `Result<WheelCapabilities, Hidpp20Error>`  |
| `get_wheel_mode`           | 1        | `()`                | `Result<WheelMode, Hidpp20Error>`          |
| `set_wheel_mode`           | 2        | `(mode: WheelMode)` | `Result<WheelMode, Hidpp20Error>`          |
| `get_ratchet_switch_state` | 3        | `()`                | `Result<WheelRatchetState, Hidpp20Error>`  |
| `listen`                   | event    | `()`                | `async_channel::Receiver<HiResWheelEvent>` |

The first four methods are `async`. `listen` is the `EmittingFeature` trait method. OpenLogi has no `get_analytics_data` method for function 4.

### Types [#types]

#### `WheelCapabilities` [#wheelcapabilities]

| Field                  | Type   | Description                                                                                |
| ---------------------- | ------ | ------------------------------------------------------------------------------------------ |
| `multiplier`           | `u8`   | Hi-res movement units per ratchet distance.                                                |
| `has_priority`         | `bool` | The mode has byte 1, named `priority` by Options+ 2.8.                                     |
| `has_rate`             | `bool` | The mode has byte 2, named `rate` by Options+ 2.8.                                         |
| `has_switch`           | `bool` | The wheel has a physical ratchet-mode switch.                                              |
| `has_invert`           | `bool` | The device supports native-HID direction inversion. It does not apply to diverted reports. |
| `has_analytics_data`   | `bool` | The feature reports analytics data. OpenLogi does not read it.                             |
| `ratches_per_rotation` | `u8`   | Ratchets in one full rotation.                                                             |
| `wheel_diameter`       | `u8`   | Nominal wheel diameter in millimeters.                                                     |

#### `WheelMode` [#wheelmode]

| Field             | Type               | Description                                                                                      |
| ----------------- | ------------------ | ------------------------------------------------------------------------------------------------ |
| `target`          | `WheelEventTarget` | Native HID or diverted HID++ movement route.                                                     |
| `resolution`      | `WheelResolution`  | Current reporting resolution.                                                                    |
| `inverted`        | `bool`             | Native-HID direction inversion.                                                                  |
| private extension | private            | Byte 0 bits outside 0–2, plus bytes 1–2. OpenLogi preserves these firmware-owned bytes verbatim. |

`WheelMode` has no public constructor. Read it with `get_wheel_mode`, edit only its public fields, then pass it to `set_wheel_mode`.

#### `WheelResolution` [#wheelresolution]

| Variant | Value | Description                |
| ------- | ----- | -------------------------- |
| `Low`   | `0`   | Low-resolution reporting.  |
| `High`  | `1`   | High-resolution reporting. |

#### `WheelEventTarget` [#wheeleventtarget]

| Variant    | Value | Description                           |
| ---------- | ----- | ------------------------------------- |
| `Native`   | `0`   | Movement goes to the native HID path. |
| `Diverted` | `1`   | Movement arrives as HID++ events.     |

#### `WheelRatchetState` [#wheelratchetstate]

| Variant    | Value | Description                     |
| ---------- | ----- | ------------------------------- |
| `Freespin` | `0`   | The wheel is in free-spin mode. |
| `Ratchet`  | `1`   | The wheel is in ratchet mode.   |

### Events [#events]

#### `HiResWheelEvent` [#hireswheelevent]

| Variant         | Payload             | Description              |
| --------------- | ------------------- | ------------------------ |
| `WheelMovement` | `WheelMovementData` | Diverted wheel movement. |
| `RatchetSwitch` | `WheelRatchetState` | Ratchet-mode change.     |

#### `WheelMovementData` [#wheelmovementdata]

| Field              | Type              | Description                                                                     |
| ------------------ | ----------------- | ------------------------------------------------------------------------------- |
| `resolution`       | `WheelResolution` | Resolution reported by this movement event.                                     |
| `periods`          | `U4`              | Sampling-period count, from 0 to 15.                                            |
| `delta_vertical`   | `i16`             | BE signed vertical delta. Moving away from the user is positive.                |
| `delta_horizontal` | `i16`             | BE signed horizontal delta. A vertically-only wheel reports zero. Options+ 2.8. |

## Wire format [#wire-format]

Every OpenLogi function request uses a three-byte payload. OpenLogi expands replies and events to a 16-byte buffer before decoding. Multi-byte movement deltas are BE signed `i16` values.

### `get_wheel_capabilities` (fn 0) [#get_wheel_capabilities-fn-0]

**Request:** `[0x00, 0x00, 0x00]`.

**Response:**

| Byte       | Field                  | Notes                        |
| ---------- | ---------------------- | ---------------------------- |
| 0          | `multiplier`           | Raw `u8`.                    |
| 1 bit 0    | `has_priority`         | Options+ 2.8.                |
| 1 bit 1    | `has_rate`             | Options+ 2.8.                |
| 1 bit 2    | `has_switch`           | Ratchet-switch capability.   |
| 1 bit 3    | `has_invert`           | Native-inversion capability. |
| 1 bit 4    | `has_analytics_data`   | Options+ 2.8.                |
| 1 bits 5–7 | Reserved or unknown    | OpenLogi ignores these bits. |
| 2          | `ratches_per_rotation` | Raw `u8`.                    |
| 3          | `wheel_diameter`       | Raw `u8` millimeters.        |

### `get_wheel_mode` (fn 1) [#get_wheel_mode-fn-1]

**Request:** `[0x00, 0x00, 0x00]`.

**Response:**

| Byte       | Field               | Notes                                                                   |
| ---------- | ------------------- | ----------------------------------------------------------------------- |
| 0 bit 0    | `target`            | `0` is `Native`; `1` is `Diverted`.                                     |
| 0 bit 1    | `resolution`        | `0` is `Low`; `1` is `High`.                                            |
| 0 bit 2    | `inverted`          | Applies to native HID reporting.                                        |
| 0 bit 3    | `analytics`         | Options+ 2.8. OpenLogi preserves it but does not expose it.             |
| 0 bits 4–7 | Reserved or unknown | OpenLogi preserves them verbatim in the private mode extension.         |
| 1          | `priority`          | Options+ 2.8. OpenLogi preserves it but does not expose or validate it. |
| 2          | `rate`              | Options+ 2.8. OpenLogi preserves it but does not expose or validate it. |

### `set_wheel_mode` (fn 2) [#set_wheel_mode-fn-2]

**Request:** the three bytes of a previously read `WheelMode`.

| Byte | Fields written                                          | Notes                                                                           |
| ---- | ------------------------------------------------------- | ------------------------------------------------------------------------------- |
| 0    | `target`, `resolution`, `inverted`, and extension flags | OpenLogi replaces bits 0–2 from public fields. It preserves bit 3 and bits 4–7. |
| 1    | `priority`                                              | Preserved verbatim from the mode read. Options+ 2.8.                            |
| 2    | `rate`                                                  | Preserved verbatim from the mode read. Options+ 2.8.                            |

**Response:** the same three-byte mode layout as `get_wheel_mode`.

### `get_ratchet_switch_state` (fn 3) [#get_ratchet_switch_state-fn-3]

**Request:** `[0x00, 0x00, 0x00]`.

**Response:**

| Byte       | Field               | Notes                                |
| ---------- | ------------------- | ------------------------------------ |
| 0 bit 0    | `WheelRatchetState` | `0` is `Freespin`; `1` is `Ratchet`. |
| 0 bits 1–7 | Reserved            | OpenLogi ignores these bits.         |

### `get_analytics_data` (fn 4) [#get_analytics_data-fn-4]

OpenLogi does not implement this function. Options+ 2.8 reads function 4 only when analytics data is available and accepts a response of at least 16 raw bytes. The request layout and the 16-byte data semantics remain unverified.

### Events [#events-1]

#### `WheelMovement` (event fn 0) [#wheelmovement-event-fn-0]

| Byte       | Field              | Notes                                                                |
| ---------- | ------------------ | -------------------------------------------------------------------- |
| 0 bit 4    | `resolution`       | `0` is `Low`; `1` is `High`.                                         |
| 0 bits 3–0 | `periods`          | `U4`, from 0 to 15.                                                  |
| 1–2        | `delta_vertical`   | BE signed `i16`.                                                     |
| 3–4        | `delta_horizontal` | BE signed `i16`. Options+ 2.8 requires a five-byte movement payload. |

#### `RatchetSwitch` (event fn 1) [#ratchetswitch-event-fn-1]

| Byte    | Field               | Notes                                |
| ------- | ------------------- | ------------------------------------ |
| 0 bit 0 | `WheelRatchetState` | `0` is `Freespin`; `1` is `Ratchet`. |

## How OpenLogi uses it [#how-openlogi-uses-it]

OpenLogi inventories `0x2121` as the `hires_wheel` capability. It separately reads `has_invert` before it exposes native wheel inversion. Normal resolution and inversion writes first read the full mode. They modify only `target`, `resolution`, or `inverted`, send the original extension bytes, check the setter echo, then read back and check the documented fields. Native inversion writes require `has_invert`.

The per-device `smooth_wheel` option defaults to `false`. It arms only when the device has `0x2121`, the device is enabled, mouse capture is enabled, and the OS mouse hook can post input. On macOS, the last condition requires Accessibility access. The capture session reads capabilities and mode, then read-modify-writes `target = Diverted` with configured resolution or inversion when available. The normal native-mode reapply path stands down while this session owns the wheel.

The session routes vertical `WheelMovement` reports to the agent. High-resolution movement is divided by `multiplier`; low-resolution movement uses one unit per notch. The agent applies the configured inversion and macOS natural-scrolling direction, then posts eased scrolling even when the global smooth-scroll option is off. If its scroll runtime cannot accept input, it posts the already scaled vertical delta directly. OpenLogi currently drops `delta_horizontal` and only logs `RatchetSwitch`; neither becomes a user-facing event.

On a normal stop, disconnect, capture-plan change, or arming failure, OpenLogi read-modify-writes only `target = Native`. This preserves resolution, inversion, analytics, priority, rate, and unknown mode bits. A failed restoration remains pending and is retried on the current channel. If the agent exits abnormally, the wheel can remain diverted until OpenLogi reconnects or runs again.

## Open questions [#open-questions]

Options+ 2.8 names byte 0 bit 3 `analytics` and bytes 1–2 `priority` and `rate`. Its static analysis does not establish their valid ranges or device behavior. OpenLogi preserves all three fields without exposing them as settings.

Options+ 2.8 requires a five-byte `WheelMovement` payload. OpenLogi decodes an expanded 16-byte buffer and does not reject a shorter original payload. Hardware behavior for a truncated event remains unverified.

Options+ 2.8 accepts at least 16 bytes from function 4. The request payload and the meaning of those bytes remain unverified. OpenLogi exposes the analytics capability flag but has no raw function-4 API.

## Usage (Rust) [#usage-rust]

```rust
use std::sync::Arc;

use hidpp::{
    channel::HidppChannel,
    device::Device,
    feature::{
        EmittingFeature,
        hires_wheel::{HiResWheelFeature, WheelEventTarget, WheelResolution},
    },
};

async fn read_one_diverted_event(
    channel: Arc<HidppChannel>,
    device_index: u8,
) -> Result<(), Box<dyn std::error::Error>> {
    let mut device = Device::new(channel, device_index).await?;
    device.enumerate_features().await?;

    let Some(feature) = device.get_feature::<HiResWheelFeature>() else {
        return Ok(());
    };
    let mut mode = feature.get_wheel_mode().await?;
    let original_target = mode.target;
    mode.target = WheelEventTarget::Diverted;
    mode.resolution = WheelResolution::High;
    feature.set_wheel_mode(mode).await?;

    let events = feature.listen();
    if let Ok(event) = events.recv().await {
        println!("{event:?}");
    }

    let mut restore = feature.get_wheel_mode().await?;
    restore.target = original_target;
    feature.set_wheel_mode(restore).await?;
    Ok(())
}
```


# 0x2150 · thumbwheel (/hidpp/features/x2150-thumbwheel)



`0x2150` controls the horizontal thumb wheel on supported MX mice. It reports wheel capabilities and current reporting state. Diverted reporting sends rotation and touch updates through HID++ instead of native HID.

> **Spec:** Logitech HID++ 2.0 — *x2150 thumbwheel* (v0). &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis), Solaar. &#x2A;*Used by:** [Scrolling](/docs/features/mouse/scrolling) and [Remap buttons](/docs/features/mouse/remap-buttons).

## Function reference [#function-reference]

`ThumbwheelFeature` implements feature version `0`.

### Methods [#methods]

| Function                   | HID++ fn | Signature                                                 | Returns                                    |
| -------------------------- | -------- | --------------------------------------------------------- | ------------------------------------------ |
| `get_thumbwheel_info`      | 0        | `()`                                                      | `ThumbwheelInfo`                           |
| `get_thumbwheel_status`    | 1        | `()`                                                      | `ThumbwheelStatus`                         |
| `set_thumbwheel_reporting` | 2        | `(mode: ThumbwheelReportingMode, invert_direction: bool)` | `()`                                       |
| `listen`                   | event    | `()`                                                      | `async_channel::Receiver<ThumbwheelEvent>` |

The first three methods are `async` and return `Result<…, Hidpp20Error>`. `listen` is synchronous.

### Types [#types]

#### `ThumbwheelInfo` [#thumbwheelinfo]

| Field                 | Type                     | Description                                                          |
| --------------------- | ------------------------ | -------------------------------------------------------------------- |
| `native_resolution`   | `u16`                    | Ratchets per revolution in native HID mode.                          |
| `diverted_resolution` | `u16`                    | Rotation increments per revolution in diverted HID++ mode.           |
| `time_unit`           | `u16`                    | `time_elapsed` unit in microseconds. It is `0` without `TIME_STAMP`. |
| `default_direction`   | `ThumbwheelDirection`    | Device-specific positive rotation direction.                         |
| `capabilities`        | `ThumbwheelCapabilities` | Advertised capability bits. Unknown bits are retained.               |

#### `ThumbwheelCapabilities` [#thumbwheelcapabilities]

| Field        | Value    | Description                        |
| ------------ | -------- | ---------------------------------- |
| `TIME_STAMP` | `1 << 0` | The event reports elapsed time.    |
| `TOUCH`      | `1 << 1` | The wheel reports touch state.     |
| `PROXY`      | `1 << 2` | The wheel reports proximity state. |
| `SINGLE_TAP` | `1 << 3` | The wheel reports single taps.     |

#### `ThumbwheelDirection` [#thumbwheeldirection]

| Variant                    | Value | Description                                               |
| -------------------------- | ----- | --------------------------------------------------------- |
| `PositiveWhenLeftOrBack`   | `0`   | Positive rotation is left or back for this orientation.   |
| `PositiveWhenRightOrFront` | `1`   | Positive rotation is right or front for this orientation. |

#### `ThumbwheelStatus` [#thumbwheelstatus]

| Field                | Type                      | Description                             |
| -------------------- | ------------------------- | --------------------------------------- |
| `reporting_mode`     | `ThumbwheelReportingMode` | Native HID or diverted HID++ reporting. |
| `direction_inverted` | `bool`                    | The default direction is inverted.      |
| `touch`              | `bool`                    | Current touch state when supported.     |
| `proxy`              | `bool`                    | Current proximity state when supported. |

#### `ThumbwheelReportingMode` [#thumbwheelreportingmode]

| Variant    | Value | Description                                              |
| ---------- | ----- | -------------------------------------------------------- |
| `Native`   | `0`   | Reports go only to native HID.                           |
| `Diverted` | `1`   | Reports go only to HID++. This is required for `listen`. |

### Events [#events]

`ThumbwheelFeature` implements `EmittingFeature<ThumbwheelEvent>`. It accepts only event sub-ID `0`.

#### `ThumbwheelEvent` [#thumbwheelevent]

| Variant                                | Description                     |
| -------------------------------------- | ------------------------------- |
| `StatusUpdate(ThumbwheelStatusUpdate)` | A diverted wheel status update. |

#### `ThumbwheelStatusUpdate` [#thumbwheelstatusupdate]

| Field             | Type                       | Description                                               |
| ----------------- | -------------------------- | --------------------------------------------------------- |
| `rotation`        | `i16`                      | Signed rotation delta in the active reporting resolution. |
| `time_elapsed`    | `u16`                      | Elapsed time in `ThumbwheelInfo::time_unit` units.        |
| `rotation_status` | `ThumbwheelRotationStatus` | Rotation lifecycle state.                                 |
| `touch`           | `bool`                     | Current touch state.                                      |
| `proxy`           | `bool`                     | Current proximity state.                                  |
| `single_tap`      | `bool`                     | A single-tap indication.                                  |

#### `ThumbwheelRotationStatus` [#thumbwheelrotationstatus]

| Variant     | Value | Description                                      |
| ----------- | ----- | ------------------------------------------------ |
| `Inactive`  | `0`   | The wheel is not rotating.                       |
| `Start`     | `1`   | A rotation starts.                               |
| `Active`    | `2`   | A rotation continues.                            |
| `Stop`      | `3`   | A rotation stops.                                |
| `Other(u8)` | other | The wrapper preserves an unmodelled status byte. |

## Wire format [#wire-format]

Getters send a three-byte zero request. OpenLogi reads their extended response payload. It ignores response bytes not listed below.

### `get_thumbwheel_info` (fn 0) [#get_thumbwheel_info-fn-0]

| Bytes         | Field                 | Notes                                                                                                                    |
| ------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| 0–1           | `native_resolution`   | `u16`, BE.                                                                                                               |
| 2–3           | `diverted_resolution` | `u16`, BE.                                                                                                               |
| 4 `bit[0]`    | `default_direction`   | `0` = `PositiveWhenLeftOrBack`; `1` = `PositiveWhenRightOrFront`. Bits `7:1` are ignored.                                |
| 5 `bits[3:0]` | `capabilities`        | `TIME_STAMP`, `TOUCH`, `PROXY`, and `SINGLE_TAP`. Bits `7:4` are retained in the bitflags value without named semantics. |
| 6–7           | `time_unit`           | `u16`, BE.                                                                                                               |
| 8–15          | —                     | OpenLogi ignores these bytes.                                                                                            |

### `get_thumbwheel_status` (fn 1) [#get_thumbwheel_status-fn-1]

| Bytes               | Field                | Notes                                                                                      |
| ------------------- | -------------------- | ------------------------------------------------------------------------------------------ |
| 0                   | `reporting_mode`     | `0` = `Native`; `1` = `Diverted`. Other values return `Hidpp20Error::UnsupportedResponse`. |
| 1 `bit[0]`          | `direction_inverted` | Set when the default direction is inverted.                                                |
| 1 `bit[1]`          | `touch`              | Current touch state.                                                                       |
| 1 `bit[2]`          | `proxy`              | Current proximity state.                                                                   |
| 1 `bits[7:3]`, 2–15 | —                    | OpenLogi ignores these bits and bytes.                                                     |

### `set_thumbwheel_reporting` (fn 2) [#set_thumbwheel_reporting-fn-2]

| Byte | Field              | Notes                                              |
| ---- | ------------------ | -------------------------------------------------- |
| 0    | `mode`             | `0` = `Native`; `1` = `Diverted`.                  |
| 1    | `invert_direction` | `0` = default direction; `1` = inverted direction. |
| 2    | —                  | OpenLogi always writes `0x00`.                     |

OpenLogi accepts a successful transport reply and does not decode its payload.

### `StatusUpdate` event (fn 0) [#statusupdate-event-fn-0]

| Bytes                         | Field             | Notes                                                                  |
| ----------------------------- | ----------------- | ---------------------------------------------------------------------- |
| 0–1                           | `rotation`        | `i16`, BE.                                                             |
| 2–3                           | `time_elapsed`    | `u16`, BE.                                                             |
| 4                             | `rotation_status` | Values `0`–`3` map to named variants. Other values become `Other(u8)`. |
| 5 `bit[1]`                    | `touch`           | Touch state.                                                           |
| 5 `bit[2]`                    | `proxy`           | Proximity state.                                                       |
| 5 `bit[3]`                    | `single_tap`      | Single-tap indication.                                                 |
| 5 `bit[0]`, `bits[7:4]`, 6–15 | —                 | OpenLogi ignores these bits and bytes.                                 |

## How OpenLogi uses it [#how-openlogi-uses-it]

The device layer uses its own `0x2150` accessor for capture. It reads `getThumbwheelInfo` and `getThumbwheelStatus` before it diverts the wheel. It then normalizes rotation polarity from `default_direction` and scales diverted increments to native resolution.

Since v0.8.14, PR [#1753](https://github.com/AprilNEA/OpenLogi/pull/1753) reads the previous status before diversion. On exit, it restores native reporting and preserves the previous inversion only when the previous mode was native. A failed restore remains pending and retries on the current device route.

The capture path emits nonzero rotation as scroll. It suppresses a zero-rotation tap when the current status is rolling, or the previous status was `Start` or `Active`. It sends a settled `single_tap` as a `Thumbwheel` button pulse.

## Open questions [#open-questions]

The typed wrapper preserves an unknown event status as `Other(u8)`. The device capture accessor instead classifies an unknown status as `Inactive`. Therefore, a zero-rotation unknown-status update with `single_tap` can still become a button pulse. No hardware capture has verified that combination.

If `getThumbwheelInfo` fails, the capture path still diverts the wheel. It then uses the default polarity and an unscaled `1.0` resolution ratio. This fallback is not hardware-verified as safe.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::{
        EmittingFeature,
        thumbwheel::{ThumbwheelFeature, ThumbwheelReportingMode},
    },
};

// device: &mut Device, already created via Device::new(channel, index)
device.enumerate_features().await?;
if let Some(feature) = device.get_feature::<ThumbwheelFeature>() {
    let info = feature.get_thumbwheel_info().await?;
    println!("{} / {}", info.native_resolution, info.diverted_resolution);

    feature
        .set_thumbwheel_reporting(ThumbwheelReportingMode::Diverted, false)
        .await?;
    let receiver = feature.listen();
    while let Ok(event) = receiver.recv().await {
        println!("{event:?}");
    }
}
```


# 0x2200 · mousePointer (/hidpp/features/x2200-mouse-pointer)



`mousePointer` reports a mouse's optical-sensor properties and pointer-tuning hints. Devices that expose `0x2200` return a typical resolution and three host-advisory values. This feature does not change pointer speed.

> **Spec:** Logitech HID++ 2.0 — *mousePointer*. &#x2A;*Cross-checked:** Solaar. &#x2A;*Used by:** Not used by OpenLogi.

## Function reference [#function-reference]

The `MousePointerFeature` wrapper (`0x2200`, version 0+) exposes one read operation.

### Methods [#methods]

| Function                 | HID++ fn | Signature | Returns            |
| ------------------------ | -------- | --------- | ------------------ |
| `get_mouse_pointer_info` | 0        | `()`      | `MousePointerInfo` |

All methods are `async` and return `Result<…, Hidpp20Error>`.

### Types [#types]

#### `MousePointerInfo` [#mousepointerinfo]

`get_mouse_pointer_info` returns this sensor and advisory record.

| Field                     | Type                  | Description                                                                                    |
| ------------------------- | --------------------- | ---------------------------------------------------------------------------------------------- |
| `sensor_resolution`       | `u16`                 | Typical resolution on a standard surface, in 1-DPI steps. Surface variation can be up to ±20%. |
| `pointer_acceleration`    | `PointerAcceleration` | The device's acceleration-curve hint.                                                          |
| `suggest_os_ballistics`   | `bool`                | `true` asks the host to keep OS-native ballistics.                                             |
| `suggest_vertical_tuning` | `bool`                | `true` suggests that the host offer X/Y orientation tuning.                                    |

#### `PointerAcceleration` [#pointeracceleration]

This enum is the device's acceleration-curve hint.

| Variant  | Value | Description                               |
| -------- | ----- | ----------------------------------------- |
| `None`   | 0     | No acceleration is suggested.             |
| `Low`    | 1     | A low acceleration curve is suggested.    |
| `Medium` | 2     | A medium acceleration curve is suggested. |
| `High`   | 3     | A high acceleration curve is suggested.   |

### Events [#events]

`MousePointerFeature` exposes no event type.

## Wire format [#wire-format]

`getMousePointerInfo` sends a 3-byte short request. OpenLogi reads its reply as a 16-byte extended payload.

### `get_mouse_pointer_info` (fn 0) [#get_mouse_pointer_info-fn-0]

Request: `[0x00, 0x00, 0x00]`. All three bytes are padding.

| Byte or bits | Field                     | Notes                                                     |
| ------------ | ------------------------- | --------------------------------------------------------- |
| 0–1          | `sensor_resolution`       | Unsigned `u16` in BE order.                               |
| 2, bits 1–0  | `pointer_acceleration`    | `0`=`None`, `1`=`Low`, `2`=`Medium`, `3`=`High`.          |
| 2, bit 2     | `suggest_os_ballistics`   | Set when the device suggests OS-native ballistics.        |
| 2, bit 3     | `suggest_vertical_tuning` | Set when the device suggests vertical-orientation tuning. |
| 2, bits 7–4  | Reserved                  | OpenLogi ignores these bits.                              |
| 3–15         | Reserved                  | OpenLogi ignores these bytes.                             |

## How OpenLogi uses it [#how-openlogi-uses-it]

OpenLogi registers `MousePointerFeature` during feature enumeration. It decodes the three modeled response bytes in the typed wrapper. No device-layer, host, inventory, agent, CLI, or UI path calls the wrapper in v0.8.15.

OpenLogi has no user-facing setting for `0x2200`. The feature has no version-specific method gate beyond its version-0 wrapper registration.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::mouse_pointer::MousePointerFeature,
    protocol::v20::Hidpp20Error,
};

async fn show_mouse_pointer_info(device: &mut Device) -> Result<(), Hidpp20Error> {
    device.enumerate_features().await?;

    if let Some(feature) = device.get_feature::<MousePointerFeature>() {
        let info = feature.get_mouse_pointer_info().await?;
        println!("Sensor resolution: {} DPI", info.sensor_resolution);
        println!("Acceleration hint: {:?}", info.pointer_acceleration);
        println!("Use OS ballistics: {}", info.suggest_os_ballistics);
    }

    Ok(())
}
```


# 0x2201 · adjustableDpi (/hidpp/features/x2201-adjustable-dpi)



`0x2201` controls one DPI value for each motion sensor. It reports the sensor count, supported DPI values, and the current DPI. Feature version 1 also reports a default DPI value.

> **Spec:** Logitech HID++ 2.0 — *x2201 adjustableDpi*. &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis), Solaar. &#x2A;*Used by:** [DPI](/docs/features/mouse/dpi).

## Function reference [#function-reference]

`AdjustableDpiFeature` is the OpenLogi wrapper for `0x2201`. Its methods are asynchronous and return `Result&lt;T, Hidpp20Error&gt;`.

### Methods [#methods]

| Function              | HID++ fn | Signature                      | Returns    |
| --------------------- | -------- | ------------------------------ | ---------- |
| `get_sensor_count`    | 0        | `()`                           | `u8`       |
| `get_sensor_dpi_list` | 1        | `(sensor_index: u8)`           | `Vec<u16>` |
| `get_sensor_dpi`      | 2        | `(sensor_index: u8)`           | `u16`      |
| `set_sensor_dpi`      | 3        | `(sensor_index: u8, dpi: u16)` | `()`       |

### Types [#types]

The wrapper uses a `u8` sensor index and `u16` DPI values. `get_sensor_dpi_list` returns values in ascending order without duplicates.

### Events [#events]

OpenLogi v0.8.15 declares no `0x2201` event type.

## Wire format [#wire-format]

Functions 0–2 use three-byte short payloads. `set_sensor_dpi` uses that form in feature versions 0 and 1. Every multi-byte DPI value uses BE byte order. The wrapper calls `extend_payload()` for replies, so it does not require a long reply.

### `get_sensor_count` (fn 0) [#get_sensor_count-fn-0]

Request: `[0x00, 0x00, 0x00]`.

| Byte | Field          | Notes                         |
| ---- | -------------- | ----------------------------- |
| 0    | `sensor_count` | Number of motion sensors.     |
| 1–15 | /              | OpenLogi ignores these bytes. |

### `get_sensor_dpi_list` (fn 1) [#get_sensor_dpi_list-fn-1]

Request: `[sensor_index, 0x00, 0x00]`.

| Bytes | Field                 | Notes                                                                                                      |
| ----- | --------------------- | ---------------------------------------------------------------------------------------------------------- |
| 0     | echoed `sensor_index` | OpenLogi requires this value to match the request. A mismatch returns `Hidpp20Error::UnsupportedResponse`. |
| 1–14  | DPI words             | Up to seven BE `u16` words, decoded in order.                                                              |
| 15    | trailing byte         | It cannot form a complete word. OpenLogi ignores it.                                                       |

`0x0000` terminates the list. A full list has no terminator. A literal DPI word has top three bits other than `0b111`. A word with top three bits `0b111` is a range marker. Its low 13 bits are the positive step.

The preceding literal is the range start. The following literal is the range end. OpenLogi expands the range, keeps an off-step end value, then sorts and deduplicates the result. A leading marker, a zero step, a missing end, or a descending end returns `Hidpp20Error::UnsupportedResponse`.

### `get_sensor_dpi` (fn 2) [#get_sensor_dpi-fn-2]

Request: `[sensor_index, 0x00, 0x00]`.

| Bytes | Field                 | Notes                                                                                       |
| ----- | --------------------- | ------------------------------------------------------------------------------------------- |
| 0     | echoed `sensor_index` | The protocol supplies this echo. OpenLogi ignores it for this function.                     |
| 1–2   | `current_dpi`         | BE `u16`. OpenLogi returns this value.                                                      |
| 3–4   | `default_dpi`         | BE `u16` in feature version 1 and later. Version 0 uses zero. OpenLogi ignores these bytes. |
| 5–15  | /                     | OpenLogi ignores these bytes.                                                               |

### `set_sensor_dpi` (fn 3) [#set_sensor_dpi-fn-3]

| Bytes | Field          | Notes                                                                                                       |
| ----- | -------------- | ----------------------------------------------------------------------------------------------------------- |
| 0     | `sensor_index` | Target sensor.                                                                                              |
| 1–2   | `dpi`          | BE `u16`.                                                                                                   |
| 3     | `dpi_level`    | Present for feature version 2 and later in Logi Options+ 2.8 static analysis. Its meaning is not confirmed. |

For feature versions before 2, the request ends after byte 2. OpenLogi v0.8.15 always sends the three-byte form. It ignores the acknowledgment payload.

## How OpenLogi uses it [#how-openlogi-uses-it]

The device layer resolves `0x2201` before `0x2202`. It uses only sensor 0. It rejects a device that reports zero sensors. For `0x2201`, it reads the current DPI and the supported DPI list.

Before a write, OpenLogi checks that the requested DPI exactly matches a device-reported value. Otherwise it returns `WriteError::DpiUnsupported` without sending a packet. It reads back the DPI after a write and retries once. A second different value returns `WriteError::DpiNotApplied`. A failed read-back leaves the acknowledged write in place.

The desktop saves a requested DPI before its asynchronous device write. Therefore, a rejected or unapplied write can leave the requested value in desktop state until a later live read. The agent re-applies the saved DPI after reconnect because the sensor setting is volatile. OpenLogi has no feature-specific reset when it exits.

OpenLogi exposes neither `default_dpi` nor a version-2 `dpi_level`. The user-facing control is the [DPI page](/docs/features/mouse/dpi).

## Open questions [#open-questions]

Logi Options+ 2.8 writes a fourth `dpi_level` byte for `0x2201` version 2 and later. The public specification stops at version 1. Static analysis does not establish the field's meaning or hardware availability. OpenLogi sends the version-0/1 form for every version.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::adjustable_dpi::AdjustableDpiFeature,
    protocol::v20::Hidpp20Error,
};

async fn set_first_other_dpi(device: &mut Device) -> Result<(), Hidpp20Error> {
    device.enumerate_features().await?;
    if let Some(feature) = device.get_feature::<AdjustableDpiFeature>() {
        let sensor_count = feature.get_sensor_count().await?;
        let supported = feature.get_sensor_dpi_list(0).await?;
        let current = feature.get_sensor_dpi(0).await?;

        println!("sensors: {sensor_count}, current DPI: {current}");
        if let Some(&next) = supported.iter().find(|&&dpi| dpi != current) {
            feature.set_sensor_dpi(0, next).await?;
        }
    }

    Ok(())
}
```


# 0x2202 · extendedAdjustableDpi (/hidpp/features/x2202-extended-adjustable-dpi)



`0x2202` controls DPI parameters for each motion sensor. It adds optional X/Y DPI, lift-off distance (LOD), DPI-status LED control, profiles, and calibration to [`0x2201` adjustableDpi](/hidpp/features/x2201-adjustable-dpi).

> **Spec:** Logitech HID++ 2.0 — *x2202 extendedAdjustableDpi*. &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis), Solaar. &#x2A;*Used by:** [DPI](/docs/features/mouse/dpi).

## Function reference [#function-reference]

`ExtendedDpiFeature` is the OpenLogi wrapper for `0x2202`. Its asynchronous methods return `Result&lt;T, Hidpp20Error&gt;`. `listen` is synchronous and returns an `async_channel::Receiver&lt;ExtendedDpiEvent&gt;`.

### Methods [#methods]

| Function                    | HID++ fn | Signature                                                                           | Returns                      |
| --------------------------- | -------- | ----------------------------------------------------------------------------------- | ---------------------------- |
| `get_sensor_count`          | 0        | `()`                                                                                | `u8`                         |
| `get_sensor_capabilities`   | 1        | `(sensor_index: u8)`                                                                | `SensorCapabilitiesInfo`     |
| `get_sensor_dpi_ranges`     | 2        | `(sensor_index: u8, direction: DpiDirection)`                                       | `Vec<DpiRange>`              |
| `get_sensor_dpi_list`       | 3        | `(sensor_index: u8, direction: DpiDirection)`                                       | `Vec<u16>`                   |
| `get_sensor_lod_list`       | 4        | `(sensor_index: u8, dpi_level_count: u8)`                                           | `Vec<Lod>`                   |
| `get_sensor_dpi_parameters` | 5        | `(sensor_index: u8)`                                                                | `DpiParameters`              |
| `set_sensor_dpi_parameters` | 6        | `(sensor_index: u8, params: SetDpiParameters)`                                      | `()`                         |
| `show_sensor_dpi_status`    | 7        | `(sensor_index: u8, params: ShowDpiStatus)`                                         | `()`                         |
| `get_dpi_calibration_info`  | 8        | `(sensor_index: u8)`                                                                | `DpiCalibrationInfo`         |
| `start_dpi_calibration`     | 9        | `(sensor_index: u8, params: StartDpiCalibration)`                                   | `()`                         |
| `set_dpi_calibration`       | 10       | `(sensor_index: u8, direction: DpiDirection, correction: DpiCalibrationCorrection)` | `()`                         |
| `listen`                    | event    | `()`                                                                                | `Receiver<ExtendedDpiEvent>` |

### Types [#types]

#### `DpiDirection` [#dpidirection]

| Variant | Value | Description      |
| ------- | ----- | ---------------- |
| `X`     | 0     | Horizontal axis. |
| `Y`     | 1     | Vertical axis.   |

#### `Lod` [#lod]

LOD is the height above the surface where a sensor stops tracking motion.

| Variant        | Value | Description                    |
| -------------- | ----- | ------------------------------ |
| `NotSupported` | 0     | The sensor has no LOD control. |
| `Low`          | 1     | Low lift-off distance.         |
| `Medium`       | 2     | Medium lift-off distance.      |
| `High`         | 3     | High lift-off distance.        |

#### `LedHoldType` [#ledholdtype]

| Variant        | Value | Description                                      |
| -------------- | ----- | ------------------------------------------------ |
| `TimerBased`   | 0     | Turn the LED off after a device-defined timeout. |
| `EventBased`   | 1     | Turn the LED off after a device-defined event.   |
| `SwControlOn`  | 2     | Turn the LED on under software control.          |
| `SwControlOff` | 3     | Turn the LED off under software control.         |

#### `CalibrationType` [#calibrationtype]

| Variant    | Value | Description                               |
| ---------- | ----- | ----------------------------------------- |
| `Hardware` | 0     | The sensor firmware computes calibration. |
| `Software` | 1     | Host software computes calibration.       |

#### `SensorCapabilities` [#sensorcapabilities]

| Flag          | Bit      | Description                                 |
| ------------- | -------- | ------------------------------------------- |
| `DPI_Y`       | `1 << 0` | The sensor supports independent Y-axis DPI. |
| `LOD`         | `1 << 1` | The sensor supports LOD control.            |
| `CALIBRATION` | `1 << 2` | The sensor supports calibration.            |
| `PROFILE`     | `1 << 3` | The sensor supports DPI profiles.           |

`get_sensor_capabilities` retains unknown capability bits. `SetDpiParameters::for_sensor` uses only `DPI_Y` and `LOD` to prepare a write.

#### `SensorCapabilitiesInfo` [#sensorcapabilitiesinfo]

| Field             | Type                 | Description                                             |
| ----------------- | -------------------- | ------------------------------------------------------- |
| `sensor_index`    | `u8`                 | Sensor index from the reply.                            |
| `dpi_level_count` | `u8`                 | Selectable DPI level count. `0` means unmanaged levels. |
| `capabilities`    | `SensorCapabilities` | Capability bits for this sensor.                        |

#### `DpiRange` [#dpirange]

| Variant      | Description                                                                        |
| ------------ | ---------------------------------------------------------------------------------- |
| `Fixed(u16)` | One selectable DPI value.                                                          |
| `Stepped`    | An inclusive DPI range with public `from: u16`, `to: u16`, and `step: u16` fields. |

Adjacent stepped ranges can share an endpoint.

#### `DpiParameters` [#dpiparameters]

| Field           | Type  | Description                                 |
| --------------- | ----- | ------------------------------------------- |
| `sensor_index`  | `u8`  | Sensor index.                               |
| `dpi_x`         | `u16` | Current X-axis DPI.                         |
| `default_dpi_x` | `u16` | Default X-axis DPI.                         |
| `dpi_y`         | `u16` | Current Y-axis DPI, or `0` without `DPI_Y`. |
| `default_dpi_y` | `u16` | Default Y-axis DPI, or `0` without `DPI_Y`. |
| `lod`           | `Lod` | Current lift-off distance.                  |

#### `SetDpiParameters` [#setdpiparameters]

| Field   | Type  | Description                                        |
| ------- | ----- | -------------------------------------------------- |
| `dpi_x` | `u16` | New X-axis DPI. The protocol range is `1..=57343`. |
| `dpi_y` | `u16` | New Y-axis DPI, or `0` without `DPI_Y`.            |
| `lod`   | `Lod` | New lift-off distance.                             |

`SetDpiParameters::for_sensor(capabilities, dpi_x, dpi_y, lod)` returns values that send Y only with `DPI_Y` and LOD only with `LOD`. The raw setter does not perform a capability or DPI-range check.

#### `ShowDpiStatus` [#showdpistatus]

| Field           | Type          | Description                                                    |
| --------------- | ------------- | -------------------------------------------------------------- |
| `dpi_level`     | `u8`          | Level to display. The protocol range is `1..=dpi_level_count`. |
| `led_hold_type` | `LedHoldType` | How the LED remains on.                                        |
| `button_num`    | `u8`          | HID button number that started the change. It starts at `1`.   |

#### `DpiCalibrationInfo` [#dpicalibrationinfo]

| Field          | Type  | Description                                         |
| -------------- | ----- | --------------------------------------------------- |
| `sensor_index` | `u8`  | Sensor index.                                       |
| `mouse_width`  | `u8`  | Device width in millimetres.                        |
| `mouse_length` | `u16` | Device length in millimetres.                       |
| `calib_dpi_x`  | `u16` | X-axis DPI for calibration.                         |
| `calib_dpi_y`  | `u16` | Y-axis DPI for calibration, or `0` without `DPI_Y`. |

#### `StartDpiCalibration` [#startdpicalibration]

| Field                | Type              | Description                                                            |
| -------------------- | ----------------- | ---------------------------------------------------------------------- |
| `direction`          | `DpiDirection`    | Axis to calibrate.                                                     |
| `expected_count`     | `u16`             | Expected movement in pixels. Software calibration ignores it.          |
| `calib_type`         | `CalibrationType` | Where calibration runs.                                                |
| `start_timeout`      | `u8`              | Start timeout in seconds. The protocol allows at most `60`.            |
| `hw_process_timeout` | `u8`              | Hardware process timeout in seconds. The protocol allows at most `60`. |
| `sw_process_timeout` | `u8`              | Software process timeout in seconds. The protocol allows at most `60`. |

#### `DpiCalibrationCorrection` [#dpicalibrationcorrection]

| Variant           | Description                                                                                |
| ----------------- | ------------------------------------------------------------------------------------------ |
| `Adjust(i16)`     | Scale resolution by `(1024 + value) / 1024`. Values from `-1023` through `1023` are valid. |
| `RevertToOob`     | Restore the out-of-box profile setting. Its wire value is `0x0000`.                        |
| `RevertToProfile` | Restore the current profile setting. Its wire value is `0x8000`.                           |

`Adjust(0)` has the same wire value as `RevertToOob`. Out-of-range adjustments return `Hidpp20Error::Feature(ErrorType::InvalidArgument)`.

### Events [#events]

`ExtendedDpiFeature` implements `EmittingFeature<ExtendedDpiEvent>`. Call `listen()` before the device sends an unsolicited report.

#### `DpiParametersChanged` [#dpiparameterschanged]

`ExtendedDpiEvent::ParametersChanged` reports a device-originated DPI parameter change.

| Field          | Type          | Description                                    |
| -------------- | ------------- | ---------------------------------------------- |
| `sensor_index` | `u8`          | Sensor that changed.                           |
| `dpi_x`        | `u16`         | New X-axis DPI.                                |
| `dpi_y`        | `u16`         | New Y-axis DPI, or `0` without `DPI_Y`.        |
| `lod`          | `Option<Lod>` | New LOD. An unknown wire value becomes `None`. |

#### `DpiCalibrationCompleted` [#dpicalibrationcompleted]

`ExtendedDpiEvent::CalibrationCompleted` reports a calibration completion or timeout.

| Field          | Type                   | Description                                            |
| -------------- | ---------------------- | ------------------------------------------------------ |
| `sensor_index` | `u8`                   | Sensor that was calibrated.                            |
| `direction`    | `Option<DpiDirection>` | Calibrated axis. An unknown wire value becomes `None`. |
| `correction`   | `i16`                  | BE correction. `i16::MIN` means sensor-level failure.  |
| `delta`        | `i16`                  | BE perpendicular-axis displacement in pixels.          |

Use `DpiCalibrationCompleted::failed()` to test the `i16::MIN` failure sentinel.

## Wire format [#wire-format]

Functions 0–5 and 8 use short three-byte requests. Functions 6, 7, 9, and 10 use long 16-byte requests. OpenLogi zero-pads every unspecified byte in a long request. Every multi-byte field below uses BE byte order.

### `get_sensor_count` (fn 0) [#get_sensor_count-fn-0]

Request: `[0x00, 0x00, 0x00]`.

| Byte | Field          | Notes                         |
| ---- | -------------- | ----------------------------- |
| 0    | `sensor_count` | Number of motion sensors.     |
| 1–15 | /              | OpenLogi ignores these bytes. |

### `get_sensor_capabilities` (fn 1) [#get_sensor_capabilities-fn-1]

Request: `[sensor_index, 0x00, 0x00]`.

| Byte | Field                 | Notes                                                                                 |
| ---- | --------------------- | ------------------------------------------------------------------------------------- |
| 0    | echoed `sensor_index` | OpenLogi requires a match. A mismatch returns `Hidpp20Error::UnsupportedResponse`.    |
| 1    | `dpi_level_count`     | `0` means unmanaged levels.                                                           |
| 2    | `capabilities`        | Bits 0–3 are `DPI_Y`, `LOD`, `CALIBRATION`, and `PROFILE`. Unknown bits are retained. |
| 3–15 | /                     | OpenLogi ignores these bytes.                                                         |

### `get_sensor_dpi_ranges` (fn 2) [#get_sensor_dpi_ranges-fn-2]

Request: `[sensor_index, direction, page]`. `direction` is `0` for X and `1` for Y. `page` starts at `0`.

| Bytes | Field                 | Notes                                                       |
| ----- | --------------------- | ----------------------------------------------------------- |
| 0     | echoed `sensor_index` | OpenLogi requires a match.                                  |
| 1     | echoed `direction`    | OpenLogi requires a match.                                  |
| 2     | echoed `page`         | OpenLogi requires a match.                                  |
| 3–15  | range stream          | Thirteen stream bytes. A BE word can cross a page boundary. |

A literal word has top three bits other than `0b111`. A word with top three bits `0b111` carries the positive step between its surrounding literal endpoints. `0x0000` ends the complete stream. OpenLogi fetches no more than 16 pages, ignores bytes after the first terminator, and rejects malformed ranges or an unterminated stream.

### `get_sensor_dpi_list` (fn 3) [#get_sensor_dpi_list-fn-3]

Request: `[sensor_index, direction, 0x00]`.

| Bytes | Field                 | Notes                                                 |
| ----- | --------------------- | ----------------------------------------------------- |
| 0     | echoed `sensor_index` | OpenLogi requires a match.                            |
| 1     | echoed `direction`    | OpenLogi requires a match.                            |
| 2–15  | DPI words             | Up to seven BE `u16` entries. `0x0000` ends the list. |

This function is meaningful only with `SensorCapabilities::PROFILE`. A full list can omit the terminator. OpenLogi preserves the listed order and duplicate values.

### `get_sensor_lod_list` (fn 4) [#get_sensor_lod_list-fn-4]

Request: `[sensor_index, 0x00, 0x00]`.

| Bytes    | Field                 | Notes                                                        |
| -------- | --------------------- | ------------------------------------------------------------ |
| 0        | echoed `sensor_index` | OpenLogi requires a match.                                   |
| 1 onward | LOD entries           | One byte per DPI level: `0`, `1`, `2`, or `3` maps to `Lod`. |

The caller supplies `dpi_level_count`, normally from fn 1. The list has no terminator. OpenLogi reads exactly that many entries and rejects an unknown LOD or an impossible length. It ignores the remaining payload bytes.

### `get_sensor_dpi_parameters` (fn 5) [#get_sensor_dpi_parameters-fn-5]

Request: `[sensor_index, 0x00, 0x00]`.

| Bytes | Field                 | Notes                                           |
| ----- | --------------------- | ----------------------------------------------- |
| 0     | echoed `sensor_index` | OpenLogi requires a match.                      |
| 1–2   | `dpi_x`               | BE `u16`.                                       |
| 3–4   | `default_dpi_x`       | BE `u16`.                                       |
| 5–6   | `dpi_y`               | BE `u16`. It is `0` without `DPI_Y`.            |
| 7–8   | `default_dpi_y`       | BE `u16`. It is `0` without `DPI_Y`.            |
| 9     | `lod`                 | `Lod` value. OpenLogi rejects an unknown value. |
| 10–15 | /                     | OpenLogi ignores these bytes.                   |

OpenLogi requires a long reply for this function. A short reply returns `Hidpp20Error::UnsupportedResponse`.

### `set_sensor_dpi_parameters` (fn 6) [#set_sensor_dpi_parameters-fn-6]

| Bytes | Field          | Notes                                                  |
| ----- | -------------- | ------------------------------------------------------ |
| 0     | `sensor_index` | Target sensor.                                         |
| 1–2   | `dpi_x`        | BE `u16`.                                              |
| 3–4   | `dpi_y`        | BE `u16`. The protocol requires zero without `DPI_Y`.  |
| 5     | `lod`          | `Lod` value. The protocol requires zero without `LOD`. |
| 6–15  | /              | OpenLogi writes zero.                                  |

OpenLogi requires the reply to echo `sensor_index` at byte 0. `SetDpiParameters::for_sensor` produces the required zero values for unsupported Y and LOD fields.

### `show_sensor_dpi_status` (fn 7) [#show_sensor_dpi_status-fn-7]

| Bytes | Field           | Notes                                 |
| ----- | --------------- | ------------------------------------- |
| 0     | `sensor_index`  | Target sensor.                        |
| 1     | `dpi_level`     | Displayed DPI level.                  |
| 2     | `led_hold_type` | `0` through `3` map to `LedHoldType`. |
| 3     | `button_num`    | HID button number.                    |
| 4–15  | /               | OpenLogi writes zero.                 |

This function is valid only in host mode. OpenLogi requires the reply to echo `sensor_index` at byte 0.

### `get_dpi_calibration_info` (fn 8) [#get_dpi_calibration_info-fn-8]

Request: `[sensor_index, 0x00, 0x00]`.

| Bytes | Field                 | Notes                                  |
| ----- | --------------------- | -------------------------------------- |
| 0     | echoed `sensor_index` | OpenLogi requires a match.             |
| 1     | `mouse_width`         | Device width in millimetres.           |
| 2–3   | `mouse_length`        | BE `u16` device length in millimetres. |
| 4–5   | `calib_dpi_x`         | BE `u16`.                              |
| 6–7   | `calib_dpi_y`         | BE `u16`. It is `0` without `DPI_Y`.   |
| 8–15  | /                     | OpenLogi ignores these bytes.          |

OpenLogi requires a long reply for this function. A short reply returns `Hidpp20Error::UnsupportedResponse`.

### `start_dpi_calibration` (fn 9) [#start_dpi_calibration-fn-9]

| Bytes | Field                | Notes                                      |
| ----- | -------------------- | ------------------------------------------ |
| 0     | `sensor_index`       | Target sensor.                             |
| 1     | `direction`          | `0` for X or `1` for Y.                    |
| 2–3   | `expected_count`     | BE `u16`. Software calibration ignores it. |
| 4     | `calib_type`         | `0` for hardware or `1` for software.      |
| 5     | `start_timeout`      | Seconds. The protocol allows at most `60`. |
| 6     | `hw_process_timeout` | Seconds. The protocol allows at most `60`. |
| 7     | `sw_process_timeout` | Seconds. The protocol allows at most `60`. |
| 8–15  | /                    | OpenLogi writes zero.                      |

This function requires `SensorCapabilities::CALIBRATION`. The device reports its result with the `CalibrationCompleted` event. Software calibration then uses fn 10 to apply a correction. OpenLogi requires the reply to echo `sensor_index` at byte 0.

### `set_dpi_calibration` (fn 10) [#set_dpi_calibration-fn-10]

| Bytes | Field          | Notes                                                                 |
| ----- | -------------- | --------------------------------------------------------------------- |
| 0     | `sensor_index` | Target sensor.                                                        |
| 1     | `direction`    | `0` for X or `1` for Y.                                               |
| 2–3   | `correction`   | BE signed `i16`. `0x0000` is out-of-box. `0x8000` is profile restore. |
| 4–15  | /              | OpenLogi writes zero.                                                 |

OpenLogi validates the `Adjust` range before it sends this request. It requires the reply to echo `sensor_index` at byte 0.

### Events [#events-1]

Unsolicited reports use sub-id `0` or `1`. OpenLogi decodes the first six payload bytes. It ignores bytes 6–15.

#### `ParametersChanged` (sub-id 0) [#parameterschanged-sub-id-0]

| Bytes | Field          | Notes                                         |
| ----- | -------------- | --------------------------------------------- |
| 0     | `sensor_index` | Sensor that changed.                          |
| 1–2   | `dpi_x`        | BE `u16`.                                     |
| 3–4   | `dpi_y`        | BE `u16`. It is `0` without `DPI_Y`.          |
| 5     | `lod`          | `Lod` value. An unknown value becomes `None`. |

#### `CalibrationCompleted` (sub-id 1) [#calibrationcompleted-sub-id-1]

| Bytes | Field          | Notes                                                      |
| ----- | -------------- | ---------------------------------------------------------- |
| 0     | `sensor_index` | Sensor that completed calibration.                         |
| 1     | `direction`    | `0` for X or `1` for Y. An unknown value becomes `None`.   |
| 2–3   | `correction`   | BE signed `i16`. `0x8000` is the failure sentinel.         |
| 4–5   | `delta`        | BE signed `i16` perpendicular-axis displacement in pixels. |

Unknown event sub-ids produce no `ExtendedDpiEvent`.

## How OpenLogi uses it [#how-openlogi-uses-it]

The device layer uses `0x2202` only when the device does not expose `0x2201`. It operates sensor 0. It reads fn 5 for X DPI and fn 2 for the X-axis range. It does not use the profile list, LOD list, LED, or calibration functions.

Before a DPI write, OpenLogi flattens the device-reported X ranges and rejects a value outside that list with `WriteError::DpiUnsupported`. It reads the sensor capabilities and current parameters. `SetDpiParameters::for_sensor` writes Y equal to X only with `DPI_Y`. It preserves the current LOD only with `LOD`; otherwise it writes zero. The fn 6 acknowledgment must echo the sensor.

OpenLogi reads back X DPI after each acknowledged write and retries once. A second different value returns `WriteError::DpiNotApplied`. A failed read-back leaves the acknowledged write in place. The desktop saves the request before its asynchronous write, so a rejected or unapplied write can remain in desktop state until a later live read.

Since v0.8.14, the inventory listener recognizes an unsolicited fn 0 DPI-parameter broadcast. It increments the device settings revision, so clients re-read the authoritative setting. The listener does not decode the event payload or persist the device-originated value. It ignores fn 1 calibration-completed broadcasts.

The agent re-applies saved DPI after reconnect because the sensor setting is volatile. OpenLogi has no feature-specific reset on exit. The user-facing control is the [DPI page](/docs/features/mouse/dpi).

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::{
        EmittingFeature,
        extended_dpi::{DpiDirection, ExtendedDpiFeature, SetDpiParameters},
    },
    protocol::v20::Hidpp20Error,
};

async fn inspect_extended_dpi(device: &mut Device) -> Result<(), Hidpp20Error> {
    device.enumerate_features().await?;
    if let Some(feature) = device.get_feature::<ExtendedDpiFeature>() {
        let capabilities = feature.get_sensor_capabilities(0).await?;
        let ranges = feature.get_sensor_dpi_ranges(0, DpiDirection::X).await?;
        let current = feature.get_sensor_dpi_parameters(0).await?;

        println!("X ranges: {ranges:?}");
        let write = SetDpiParameters::for_sensor(
            capabilities.capabilities,
            1600,
            1600,
            current.lod,
        );
        feature.set_sensor_dpi_parameters(0, write).await?;

        let events = feature.listen();
        if let Ok(event) = events.recv().await {
            println!("DPI event: {event:?}");
        }
    }

    Ok(())
}
```


# 0x2205 · PointerMotionScaling (/hidpp/features/x2205-pointer-motion-scaling)



`PointerMotionScaling` applies a device-specific multiplier to pointer movement. The raw value is unsigned 8.8 fixed point. `0x0100` is 1×, and `0x0180` is 1.5×.

> **Spec:** Logitech HID++ 2.0 — *x2205 PointerMotionScaling*. &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis). &#x2A;*Used by:** `openlogi diag pointer-speed`; [Report rate](/docs/features/mouse/report-rate).

## Function reference [#function-reference]

The `PointerMotionScalingFeature` wrapper (`0x2205`, version 0+) reads and writes one scalar value.

### Methods [#methods]

| Function              | HID++ fn | Signature                   | Returns          |
| --------------------- | -------- | --------------------------- | ---------------- |
| `get_pointer_scaling` | 0        | `()`                        | `PointerScaling` |
| `set_pointer_scaling` | 1        | `(scaling: PointerScaling)` | `PointerScaling` |

All methods are `async` and return `Result<…, Hidpp20Error>`.

### Types [#types]

`PointerScaling` is a nonzero raw `u16`. A zero raw value returns `None` from `from_raw` and causes a wire reply to fail with `Hidpp20Error::UnsupportedResponse`.

| API                          | Signature or value | Returns                  | Description                               |
| ---------------------------- | ------------------ | ------------------------ | ----------------------------------------- |
| `PointerScaling::ONE`        | `0x0100`           | `PointerScaling`         | The unscaled 1× value.                    |
| `PointerScaling::from_raw`   | `(raw: u16)`       | `Option<PointerScaling>` | Constructs a value when `raw` is nonzero. |
| `PointerScaling::raw`        | `(self)`           | `u16`                    | Returns the raw unsigned 8.8 value.       |
| `PointerScaling::multiplier` | `(self)`           | `f32`                    | Returns `raw / 256.0`.                    |

### Events [#events]

`PointerMotionScalingFeature` exposes no event type.

## Wire format [#wire-format]

Both functions send a 3-byte short request. OpenLogi reads each reply as a 16-byte extended payload.

### `get_pointer_scaling` (fn 0) [#get_pointer_scaling-fn-0]

Request: `[0x00, 0x00, 0x00]`. All three bytes are padding.

| Bytes | Field                      | Notes                                           |
| ----- | -------------------------- | ----------------------------------------------- |
| 0–1   | `PointerScaling` raw value | Unsigned 8.8 value in BE order. `0x0100` is 1×. |
| 2–15  | Reserved                   | OpenLogi ignores these bytes.                   |

### `set_pointer_scaling` (fn 1) [#set_pointer_scaling-fn-1]

| Request bytes | Field               | Notes                                      |
| ------------- | ------------------- | ------------------------------------------ |
| 0–1           | requested raw value | `scaling.raw().to_be_bytes()` in BE order. |
| 2             | Padding             | OpenLogi writes `0x00`.                    |

| Response bytes | Field            | Notes                                                                    |
| -------------- | ---------------- | ------------------------------------------------------------------------ |
| 0–1            | echoed raw value | Unsigned 8.8 value in BE order. OpenLogi returns it as `PointerScaling`. |
| 2–15           | Reserved         | OpenLogi ignores these bytes.                                            |

## How OpenLogi uses it [#how-openlogi-uses-it]

The device layer opens `0x2205` on the selected route for `get_pointer_scaling` and `set_pointer_scaling`. A write has no unrelated fields to preserve. OpenLogi writes the scalar, then performs an independent read and returns that applied value.

`openlogi diag pointer-speed` is the only product caller in v0.8.15. It reads the original value, skips an equal target, and always attempts a restore before it judges the test write. It verifies that the restored value equals the original value.

The diagnostic accepts every nonzero raw `u16`. It has no GUI, agent configuration, or IPC setting. The related [Report rate](/docs/features/mouse/report-rate) page documents this separation.

## Open questions [#open-questions]

The HID++ specification recommends `0x0010`–`0x1000` and says firmware clips out-of-range writes. An observed MX Ergo firmware version, `MPM06.03_B0022`, stored `0x0001` and `0xffff` unchanged. OpenLogi therefore does not assume clipping and leaves user-facing bounds to a future product control.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::pointer_motion_scaling::{PointerMotionScalingFeature, PointerScaling},
    protocol::v20::Hidpp20Error,
};

async fn set_scaling(device: &mut Device) -> Result<(), Hidpp20Error> {
    device.enumerate_features().await?;

    if let Some(feature) = device.get_feature::<PointerMotionScalingFeature>() {
        let requested = PointerScaling::from_raw(0x0180).expect("value is nonzero");
        let applied = feature.set_pointer_scaling(requested).await?;
        println!("Applied multiplier: {:.2}×", applied.multiplier());
    }

    Ok(())
}
```


# 0x40a0 · fnInversion (/hidpp/features/x40a0-fn-inversion)



`0x40a0` is the oldest Fn-inversion feature. It controls whether bare F-row keys
send F1–F12 or their printed media and shortcut functions on keyboards that
report it. It has one global state, no default-state field, and no host selector.

> **Spec:** No public Logitech specification. Layout reverse-engineered from Logi Options+ 2.8 (static analysis). &#x2A;*Cross-checked:** Solaar. &#x2A;*Used by:** [Keyboard](/docs/features/keyboard).

## Function reference [#function-reference]

`FnInversionFeature` implements feature `0x40a0` from feature version 0.

### Methods [#methods]

| Function                  | HID++ fn | Signature                   | Returns            |
| ------------------------- | -------- | --------------------------- | ------------------ |
| `get_global_fn_inversion` | 0        | `()`                        | `FnInversionState` |
| `set_global_fn_inversion` | 1        | `(state: FnInversionState)` | `()`               |

Both methods are `async` and return `Result<…, Hidpp20Error>`.

### Types [#types]

#### `FnInversionState` [#fninversionstate]

The non-exhaustive state enum has these defined values:

| Variant | Value | Description                                                       |
| ------- | ----- | ----------------------------------------------------------------- |
| `Off`   | `0`   | Bare F-row keys send F1–F12.                                      |
| `On`    | `1`   | Bare F-row keys perform their printed media or shortcut function. |

### Events [#events]

The wrapper exposes no typed event or broadcast-recognition helper for `0x40a0`.

## Wire format [#wire-format]

All request payloads contain three bytes. No multi-byte field occurs in this
feature.

### `get_global_fn_inversion` (fn 0) [#get_global_fn_inversion-fn-0]

Request payload: `[0x00, 0x00, 0x00]`.

| Byte | Bits | Field   | Notes                                                                 |
| ---- | ---- | ------- | --------------------------------------------------------------------- |
| 0    | 0–3  | `state` | `0` = `Off`; `1` = `On`. Options+ 2.8                                 |
| 0    | 4–7  | /       | Available evidence does not assign these bits. OpenLogi ignores them. |
| 1–15 | /    | /       | Available evidence defines no fields. OpenLogi ignores these bytes.   |

OpenLogi accepts either HID++ response size for this getter. It masks byte 0
with `0x0f` and rejects any resulting value except `0` or `1`.

### `set_global_fn_inversion` (fn 1) [#set_global_fn_inversion-fn-1]

| Byte | Value             | Notes                                 |
| ---- | ----------------- | ------------------------------------- |
| 0    | `u8::from(state)` | `0` = `Off`; `1` = `On`. Options+ 2.8 |
| 1–2  | `0x00`            | OpenLogi writes fixed zero padding.   |

The reply is an acknowledgement. OpenLogi does not decode any reply field from
this function. Callers that need the stored state must read it again.

## How OpenLogi uses it [#how-openlogi-uses-it]

OpenLogi advertises Fn-lock capability when a device reports `0x40a0`, `0x40a2`,
or `0x40a3`. It opens `0x40a3`, then `0x40a2`, then `0x40a0`. It falls back only
when the newer feature is unsupported.

The device layer maps `fn_lock = true` to inversion `Off`. This makes bare keys
send F1–F12. It maps `fn_lock = false` to inversion `On`, which selects printed
functions. The write is not read-modify-write because the request carries one
complete state value.

The legacy setter returns no state. OpenLogi reads the value after every legacy
write and rejects a mismatched result. Since v0.8.14, PR [#1792](https://github.com/OpenLogi/OpenLogi/pull/1792) supports this fallback and reports `default_fn_lock` as `None`.

OpenLogi has no legacy state-change broadcast path. It replays a configured
`fn_lock` value after reconnect, and it does not restore a prior value at session
exit.

## Open questions [#open-questions]

* No hardware capture verifies the high nibble of byte 0, the remaining reply
  bytes, or the setter acknowledgement. Options+ 2.8 masks the low nibble.
  OpenLogi ignores the high nibble and rejects unknown low-nibble values.
* No available evidence establishes a default state or a device-side state-change
  broadcast for `0x40a0`. OpenLogi exposes no default for this variant and
  returns no event.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::fn_inversion::{FnInversionFeature, FnInversionState},
};

async fn inspect(device: &mut Device) -> Result<(), hidpp::protocol::v20::Hidpp20Error> {
    let _ = device.enumerate_features().await?;
    let Some(feature) = device.get_feature::<FnInversionFeature>() else {
        return Ok(());
    };

    let state = feature.get_global_fn_inversion().await?;
    println!("current inversion: {state:?}");

    feature
        .set_global_fn_inversion(FnInversionState::Off)
        .await?;
    Ok(())
}
```


# 0x40A2 · fnInversionWithDefaultState (/hidpp/features/x40a2-fn-inversion)



`0x40a2` controls one global Fn-inversion state on keyboards that report it.
`Off` makes bare F-row keys send F1–F12. `On` makes them perform their printed
media or shortcut functions. The feature also reports a default state.

> **Spec:** Logitech HID++ 2.0 — *fnInversionWithDefaultState*. &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis). &#x2A;*Used by:** [Keyboard](/docs/features/keyboard).

## Function reference [#function-reference]

`FnInversionWithDefaultStateFeature` implements feature `0x40a2` from feature
version 0.

### Methods [#methods]

| Function                  | HID++ fn     | Signature                   | Returns             |
| ------------------------- | ------------ | --------------------------- | ------------------- |
| `get_global_fn_inversion` | 0            | `()`                        | `GlobalFnInversion` |
| `set_global_fn_inversion` | 1            | `(state: FnInversionState)` | `GlobalFnInversion` |
| `is_state_broadcast`      | 1, broadcast | `(function_id: u8)`         | `bool`              |

The get and set methods are `async` and return `Result<…, Hidpp20Error>`.
`is_state_broadcast` is a synchronous recognition helper. It does not send I/O.

### Types [#types]

#### `GlobalFnInversion` [#globalfninversion]

| Field           | Type               | Description                       |
| --------------- | ------------------ | --------------------------------- |
| `state`         | `FnInversionState` | Current inversion state.          |
| `default_state` | `FnInversionState` | Reported default inversion state. |

#### `FnInversionState` [#fninversionstate]

The non-exhaustive state enum has these defined values:

| Variant | Value | Description                                                       |
| ------- | ----- | ----------------------------------------------------------------- |
| `Off`   | `0`   | Bare F-row keys send F1–F12.                                      |
| `On`    | `1`   | Bare F-row keys perform their printed media or shortcut function. |

### Events [#events]

The crate exposes no typed event. `is_state_broadcast(1)` identifies an
unsolicited function-1 report as a state-change signal.

## Wire format [#wire-format]

All request payloads contain three bytes. No multi-byte field occurs in this
feature.

### `get_global_fn_inversion` (fn 0) [#get_global_fn_inversion-fn-0]

Request payload: `[0x00, 0x00, 0x00]`.

| Byte | Field           | Notes                                           |
| ---- | --------------- | ----------------------------------------------- |
| 0    | `state`         | `0` = `Off`; `1` = `On`.                        |
| 1    | `default_state` | `0` = `Off`; `1` = `On`.                        |
| 2–15 | /               | No defined field. OpenLogi ignores these bytes. |

OpenLogi accepts a HID++ short or long response. It reads bytes 0–1 and
zero-fills a short response internally before decoding them. Other state values
return `Hidpp20Error::UnsupportedResponse`.

### `set_global_fn_inversion` (fn 1) [#set_global_fn_inversion-fn-1]

| Byte | Value             | Notes                               |
| ---- | ----------------- | ----------------------------------- |
| 0    | `u8::from(state)` | `0` = `Off`; `1` = `On`.            |
| 1–2  | `0x00`            | OpenLogi writes fixed zero padding. |

The response has the same decoded fields as function 0. OpenLogi reads bytes
0–1 and ignores all other bytes.

### State-change broadcast (fn 1) [#state-change-broadcast-fn-1]

Logi Options+ 2.8 treats a non-empty unsolicited function-1 report as a
setting change. Its payload layout is not established by the available evidence.
OpenLogi only checks the function ID and does not decode the broadcast payload.

The inventory listener only considers broadcasts with software ID `0` that
target this feature index. It then re-reads the authoritative state.

## How OpenLogi uses it [#how-openlogi-uses-it]

OpenLogi uses `0x40a2` when `0x40a3` is unsupported. A feature-table entry for
`0x40a2` enables the Fn-lock capability. The fallback continues to `0x40a0` only
when `0x40a2` itself is unsupported.

The device setting maps `fn_lock = true` to inversion `Off`. It maps
`fn_lock = false` to inversion `On`. The request is a complete one-byte state,
so OpenLogi does not use read-modify-write. It compares the returned state with
the requested Fn-lock state after a write.

Since v0.8.14, PR [#1791](https://github.com/OpenLogi/OpenLogi/pull/1791) records
a recognised state-change broadcast and re-reads Fn-lock in the desktop app.
The broadcast does not update `config.toml`. OpenLogi replays a configured
`fn_lock` value after reconnect, rather than restoring a prior value at exit.

## Open questions [#open-questions]

* No hardware trace confirms that Fn+Esc emits this function-1 broadcast. The
  Options+ 2.8 static analysis identifies a non-empty report only. OpenLogi
  re-reads after every recognised function-1 broadcast.
* The unsolicited report payload has no verified field layout. OpenLogi does not
  interpret it, so this uncertainty cannot change the displayed state directly.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::fn_inversion::{FnInversionState, FnInversionWithDefaultStateFeature},
};

async fn inspect(device: &mut Device) -> Result<(), hidpp::protocol::v20::Hidpp20Error> {
    let _ = device.enumerate_features().await?;
    let Some(feature) = device.get_feature::<FnInversionWithDefaultStateFeature>() else {
        return Ok(());
    };

    let info = feature.get_global_fn_inversion().await?;
    println!("state={:?}, default={:?}", info.state, info.default_state);

    let updated = feature
        .set_global_fn_inversion(FnInversionState::Off)
        .await?;
    println!("new state={:?}", updated.state);
    Ok(())
}
```


# 0x40A3 · fnInversionMultiHost (/hidpp/features/x40a3-fn-inversion-multi-host)



`0x40a3` controls Fn inversion for a selected host slot on multi-host keyboards
that report it. `Off` makes bare F-row keys send F1–F12. `On` makes them perform
their printed media or shortcut functions. Each reply carries the selected host,
a default state, and capability bits.

> **Spec:** Logitech HID++ 2.0 — *fnInversionForMultiHostDevices*. &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis), Solaar. &#x2A;*Used by:** [Keyboard](/docs/features/keyboard).

## Function reference [#function-reference]

`FnInversionMultiHostFeature` implements feature `0x40a3` from feature version
0\.

### Methods [#methods]

| Function                  | HID++ fn       | Signature                                    | Returns           |
| ------------------------- | -------------- | -------------------------------------------- | ----------------- |
| `get_global_fn_inversion` | 0              | `(host: HostIndex)`                          | `FnInversionInfo` |
| `set_global_fn_inversion` | 1              | `(host: HostIndex, state: FnInversionState)` | `FnInversionInfo` |
| `is_state_broadcast`      | Any, broadcast | `(message: &v20::Message)`                   | `bool`            |

The get and set methods are `async` and return `Result<…, Hidpp20Error>`.
`is_state_broadcast` is a synchronous recognition helper. It does not send I/O.

### Types [#types]

#### `FnInversionInfo` [#fninversioninfo]

| Field           | Type                      | Description                           |
| --------------- | ------------------------- | ------------------------------------- |
| `host_index`    | `HostIndex`               | Host selector returned by the device. |
| `state`         | `FnInversionState`        | Current inversion state.              |
| `default_state` | `FnInversionState`        | Reported default inversion state.     |
| `capabilities`  | `FnInversionCapabilities` | Reported capability bits.             |

#### `FnInversionState` [#fninversionstate]

The non-exhaustive state enum has these defined values:

| Variant | Value | Description                                                       |
| ------- | ----- | ----------------------------------------------------------------- |
| `Off`   | `0`   | Bare F-row keys send F1–F12.                                      |
| `On`    | `1`   | Bare F-row keys perform their printed media or shortcut function. |

#### `FnInversionCapabilities` [#fninversioncapabilities]

| Flag             | Bit | Description                                                                    |
| ---------------- | --- | ------------------------------------------------------------------------------ |
| `MANUAL_FN_LOCK` | 0   | Device reports manual Fn-lock capability.                                      |
| Unknown bits     | 1–7 | OpenLogi retains them in the bitflags value. It does not name or gate on them. |

OpenLogi also does not use `MANUAL_FN_LOCK` as a write gate.

#### `HostIndex` [#hostindex]

The non-exhaustive host selector has these defined forms:

| Variant    | Wire value    | Description                                  |
| ---------- | ------------- | -------------------------------------------- |
| `Current`  | `0xff`        | The slot currently selected by the keyboard. |
| `Slot(u8)` | `0x00`–`0xfe` | A zero-based host slot.                      |

### Events [#events]

The crate exposes no typed event. `is_state_broadcast` returns `true` for every
HID++ long message. It does not check the function ID or decode the payload.

## Wire format [#wire-format]

All request payloads contain three bytes. No multi-byte field occurs in this
feature. A reply must be a HID++ long message; OpenLogi rejects a short reply.

### `get_global_fn_inversion` (fn 0) [#get_global_fn_inversion-fn-0]

| Byte | Value            | Notes                                                                    |
| ---- | ---------------- | ------------------------------------------------------------------------ |
| 0    | `u8::from(host)` | `0xff` selects the current slot; `0x00`–`0xfe` select a zero-based slot. |
| 1–2  | `0x00`           | OpenLogi writes fixed zero padding.                                      |

| Reply byte | Field           | Notes                                                  |
| ---------- | --------------- | ------------------------------------------------------ |
| 0          | `host_index`    | `0xff` = `Current`; every other value = `Slot(value)`. |
| 1          | `state`         | `0` = `Off`; `1` = `On`.                               |
| 2          | `default_state` | `0` = `Off`; `1` = `On`.                               |
| 3          | `capabilities`  | Bit 0 = `MANUAL_FN_LOCK`. Unknown bits are retained.   |
| 4–15       | /               | No defined field. OpenLogi ignores these bytes.        |

Unknown `state` or `default_state` values return
`Hidpp20Error::UnsupportedResponse`.

### `set_global_fn_inversion` (fn 1) [#set_global_fn_inversion-fn-1]

| Byte | Value             | Notes                                     |
| ---- | ----------------- | ----------------------------------------- |
| 0    | `u8::from(host)`  | Current-slot or zero-based slot selector. |
| 1    | `u8::from(state)` | `0` = `Off`; `1` = `On`.                  |
| 2    | `0x00`            | OpenLogi writes fixed zero padding.       |

The reply uses the same four decoded fields as function 0. OpenLogi requires a
long reply for this function too.

### State-change broadcast [#state-change-broadcast]

Logi Options+ 2.8 decodes every unsolicited `0x40a3` report with four fields
and no function-ID filter. OpenLogi recognises every long message for this
feature as a state-change signal. It does not decode the broadcast payload.

The inventory listener only considers messages with software ID `0` that target
this feature index. It increments the device settings revision, then the desktop
app re-reads Fn-lock.

## How OpenLogi uses it [#how-openlogi-uses-it]

OpenLogi prefers `0x40a3` over the two older variants. It reads with
`HostIndex::Current`, which writes `0xff` in the request. The user-facing
setting reads and writes only the current Easy-Switch slot.

For a write, OpenLogi reads `0x1815 HostsInfo` and addresses the current slot
explicitly. This avoids a known MX Keys S firmware case that ignores `0xff`
writes. If `HostsInfo` is unavailable, OpenLogi uses `0xff`. If it exists but
cannot be read, OpenLogi fails the write instead of guessing.

The request writes a full state value, so OpenLogi does not use read-modify-write.
It compares the returned Fn-lock state with the requested state. Since v0.8.14,
PR [#1782](https://github.com/OpenLogi/OpenLogi/pull/1782) rejects short replies
instead of treating missing capability bytes as zero.

Since v0.8.14, PR [#1791](https://github.com/OpenLogi/OpenLogi/pull/1791) re-reads
the setting after a recognised device-side change. The change does not update
`config.toml`. OpenLogi replays a configured `fn_lock` value after reconnect,
rather than restoring a prior value at session exit.

The wrapper can address any `HostIndex`. OpenLogi's device and desktop layers do
not expose a read-all or write-other-slot operation. This matches the visible
Options+ Fn-lock control, which uses only the current slot.

## Open questions [#open-questions]

* Hardware captures have not confirmed the exact unsolicited report trigger or
  function ID. Options+ 2.8 static analysis uses no function-ID filter. OpenLogi
  therefore re-reads after every matching long broadcast.
* Hardware captures have not confirmed fields added by feature versions above the
  base layout. OpenLogi registers this wrapper from version 0 and ignores reply
  bytes 4–15.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::{
        fn_inversion::{FnInversionMultiHostFeature, FnInversionState},
        hosts_info::HostIndex,
    },
};

async fn inspect(device: &mut Device) -> Result<(), hidpp::protocol::v20::Hidpp20Error> {
    let _ = device.enumerate_features().await?;
    let Some(feature) = device.get_feature::<FnInversionMultiHostFeature>() else {
        return Ok(());
    };

    let info = feature
        .get_global_fn_inversion(HostIndex::Current)
        .await?;
    println!("state={:?}, default={:?}", info.state, info.default_state);

    let updated = feature
        .set_global_fn_inversion(HostIndex::Slot(0), FnInversionState::Off)
        .await?;
    println!("slot {:?} now has {:?}", updated.host_index, updated.state);
    Ok(())
}
```


# 0x4220 · LockKeyState (/hidpp/features/x4220-lock-key-state)



`LockKeyState` reports the engaged lock keys on keyboards that expose feature `0x4220`. OpenLogi reads the state and uses later broadcasts for [lock-key notices](/docs/features/notifications).

> **Spec:** No public Logitech specification. Layout reverse-engineered from Logi Options+ 2.8 (static analysis). &#x2A;*Used by:** [Lock-key notices](/docs/features/notifications).

## Function reference [#function-reference]

`LockKeyStateFeature` is creatable for feature version `0`.

### Methods [#methods]

| Function           | HID++ fn      | Signature     | Returns    |
| ------------------ | ------------- | ------------- | ---------- |
| `get_lock_state`   | 0             | `()`          | `LockKeys` |
| `decode_broadcast` | event decoder | `(&[u8; 16])` | `LockKeys` |

`get_lock_state` is `async` and returns `Result<LockKeys, Hidpp20Error>`. `decode_broadcast` is an infallible associated function.

### Types [#types]

#### `LockKeys` [#lockkeys]

`LockKeys` is a retained `u8` bitfield. OpenLogi keeps unnamed bits rather than rejecting a newer keyboard state.

| Field         | Bit      | Description             |
| ------------- | -------- | ----------------------- |
| `NUM_LOCK`    | `1 << 0` | Num Lock is engaged.    |
| `CAPS_LOCK`   | `1 << 1` | Caps Lock is engaged.   |
| `SCROLL_LOCK` | `1 << 2` | Scroll Lock is engaged. |

### Events [#events]

OpenLogi has no `listen()` method for this feature. The inventory path filters software ID `0`, then calls `decode_broadcast` for reports at this feature index.

| Event          | Payload    | Description                           |
| -------------- | ---------- | ------------------------------------- |
| Lock-key state | `LockKeys` | The complete current lock-key bitmap. |

## Wire format [#wire-format]

`get_lock_state` uses a 3-byte short request payload. OpenLogi normalizes its reply to 16 bytes before decoding byte `0`.

### `get_lock_state` (fn 0) [#get_lock_state-fn-0]

Request:

| Byte | Field | Notes                                             |
| ---- | ----- | ------------------------------------------------- |
| 0–2  | —     | No parameters. OpenLogi sends `0x00, 0x00, 0x00`. |

Response:

| Byte | Field       | Notes                         |
| ---- | ----------- | ----------------------------- |
| 0    | `lock_keys` | `LockKeys` bitmap.            |
| 1–15 | —           | OpenLogi ignores these bytes. |

### Lock-key broadcast [#lock-key-broadcast]

Logi Options+ 2.8 reads byte `0` from any unsolicited report on this feature. OpenLogi follows that rule and does not validate the report function.

| Byte | Field       | Notes                                                    |
| ---- | ----------- | -------------------------------------------------------- |
| 0    | `lock_keys` | Same `LockKeys` bitmap as `get_lock_state`. Options+ 2.8 |
| 1–15 | —           | OpenLogi ignores these bytes.                            |

## How OpenLogi uses it [#how-openlogi-uses-it]

Since v0.8.15, OpenLogi reads the lock state once per online connection. That baseline lets it identify each named bit that changed in a later broadcast.

OpenLogi queues one notice update for each changed named key. It does not reconcile inventory or increase a settings revision for these transient states.

The agent honors `lock_key_notifications`, which defaults to `true`. It shows Caps Lock on macOS, Linux, and Windows, but Num Lock and Scroll Lock only on Linux and Windows.

OpenLogi does not write `0x4220` or restore state at exit. See [On-screen notices](/docs/features/notifications) for the visible behavior.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::lock_key_state::{LockKeyStateFeature, LockKeys},
};

// `device` is a discovered HID++ device.
device.enumerate_features().await?;
if let Some(feature) = device.get_feature::<LockKeyStateFeature>() {
    let keys = feature.get_lock_state().await?;
    if keys.contains(LockKeys::CAPS_LOCK) {
        println!("Caps Lock is on");
    }
}
```


# 0x4301 · solarKeyboardDashboard (/hidpp/features/x4301-solar-keyboard-dashboard)



`SolarKeyboardDashboard` serves Logitech solar keyboards, including the K750. It schedules light reports, controls the CheckLight LED, and decodes battery and light broadcasts.

> **Spec:** Logitech HID++ 2.0 — *solarKeyboardDashboard*. &#x2A;*Used by:** Not used by OpenLogi outside the `openlogi-hidpp` wrapper.

## Function reference [#function-reference]

`SolarDashboardFeature` is creatable for feature version `0`.

### Methods [#methods]

| Function            | HID++ fn | Signature                              | Returns                               |
| ------------------- | -------- | -------------------------------------- | ------------------------------------- |
| `set_light_measure` | 0        | `(max_reports: u8, report_period: u8)` | `()`                                  |
| `set_led`           | 1        | `(led: LedId)`                         | `()`                                  |
| `listen`            | event    | `()`                                   | `async_channel::Receiver<SolarEvent>` |

`set_light_measure` and `set_led` are `async`. They return `Result<(), Hidpp20Error>`. `listen` comes from `EmittingFeature<SolarEvent>`.

### Types [#types]

#### `LedId` [#ledid]

`LedId` selects the CheckLight LED color.

| Variant  | Value | Description                   |
| -------- | ----- | ----------------------------- |
| `Off`    | 0     | Turn all CheckLight LEDs off. |
| `Red`    | 1     | Show red.                     |
| `Orange` | 2     | Show orange.                  |
| `Green`  | 3     | Show green.                   |

#### `SolarStatus` [#solarstatus]

Every `SolarEvent` contains a `SolarStatus` value.

| Field           | Type  | Description                                  |
| --------------- | ----- | -------------------------------------------- |
| `battery_level` | `u8`  | Remaining battery capacity, as a percentage. |
| `light_level`   | `u16` | Ambient light in lux, `0..=511`.             |

### Events [#events]

| Variant                         | Description                                         |
| ------------------------------- | --------------------------------------------------- |
| `Battery(SolarStatus)`          | Spontaneous battery report. The light level is `0`. |
| `LightMeasure(SolarStatus)`     | Report scheduled by `set_light_measure`.            |
| `CheckLightButton(SolarStatus)` | The user pressed the CheckLight button.             |

The firmware sends `Battery` about every 90 seconds, at power-up, and on reconnect. Call `set_led` within 250 ms of `CheckLightButton` to override the firmware display.

## Wire format [#wire-format]

`set_light_measure` and `set_led` use 3-byte short requests. OpenLogi waits for a HID++ reply, but it does not read reply bytes for either setter.

### `set_light_measure` (fn 0) [#set_light_measure-fn-0]

| Byte | Field           | Notes                                                    |
| ---- | --------------- | -------------------------------------------------------- |
| 0    | `max_reports`   | Number of `LightMeasure` reports. `0` cancels reporting. |
| 1    | `report_period` | Interval in seconds. `0` cancels reporting.              |
| 2    | —               | Padding. OpenLogi sends `0x00`.                          |

### `set_led` (fn 1) [#set_led-fn-1]

| Byte | Field | Notes                                                     |
| ---- | ----- | --------------------------------------------------------- |
| 0    | `led` | `LedId`: `Off` = 0, `Red` = 1, `Orange` = 2, `Green` = 3. |
| 1–2  | —     | Padding. OpenLogi sends `0x00`.                           |

### Events (fn 0–2) [#events-fn-02]

Each event uses this payload. `light_level` is a BE `u16`.

| Byte | Field           | Notes                           |
| ---- | --------------- | ------------------------------- |
| 0    | `battery_level` | Remaining battery percentage.   |
| 1–2  | `light_level`   | Ambient light in lux, BE `u16`. |
| 3–15 | —               | OpenLogi ignores these bytes.   |

| HID++ fn | Variant                        |
| -------- | ------------------------------ |
| 0        | `SolarEvent::Battery`          |
| 1        | `SolarEvent::LightMeasure`     |
| 2        | `SolarEvent::CheckLightButton` |

## How OpenLogi uses it [#how-openlogi-uses-it]

The typed wrapper attaches its broadcast listener when OpenLogi creates the feature. It exposes scheduling, LED control, and decoded events to Rust callers.

OpenLogi v0.8.15 has no device-layer, agent, CLI, or user-interface caller for this feature. It does not schedule reports, override the LED, track the results, or restore feature state at exit.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::{
        EmittingFeature,
        solar_dashboard::{LedId, SolarDashboardFeature, SolarEvent},
    },
};

// `device` is a discovered HID++ device.
device.enumerate_features().await?;
if let Some(feature) = device.get_feature::<SolarDashboardFeature>() {
    feature.set_light_measure(10, 30).await?;

    let events = feature.listen();
    while let Ok(event) = events.recv().await {
        if let SolarEvent::CheckLightButton(status) = event {
            println!("battery={}%, light={} lux", status.battery_level, status.light_level);
            feature.set_led(LedId::Green).await?;
        }
    }
}
```


# 0x4520 · keyboardLayout (/hidpp/features/x4520-keyboard-layout)



The feature registry identifies `0x4520` as `KeyboardLayout`. OpenLogi v0.8.15 has no `KeyboardLayoutFeature`, device-layer caller, or verified packet definition for it.

<Callout type="warn" title="Not implemented in OpenLogi">
  OpenLogi does not provide a typed wrapper for `0x4520`. No public specification or static analysis of Logitech software establishes its function indices or packet layout.
</Callout>

## What it does [#what-it-does]

The feature name indicates that `0x4520` concerns a keyboard layout. That name alone does not establish whether the feature reads a physical layout, writes a host layout, or changes HID output.

`0x4521` controls a fixed disabled-key bitmap. `0x4522` controls disabled HID usages. Those implemented features do not establish the wire format for `0x4520`.

Because no source gives a function table for `0x4520`, this page lists no
functions and infers no protocol details.

**Status:** Not implemented in OpenLogi v0.8.15 · &#x2A;*Feature ID:** `0x4520`


# 0x4521 · disableKeys (/hidpp/features/x4521-disable-keys)



`DisableKeys` controls a fixed set of lock and system keys. It reads the supported and active masks, then replaces the complete active mask.

> **Spec:** Logitech HID++ 2.0 — *disableKeys*. &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis), Solaar. &#x2A;*Used by:** Not used by OpenLogi outside the `openlogi-hidpp` wrapper.

## Function reference [#function-reference]

`DisableKeysFeature` is creatable for feature version `0`.

### Methods [#methods]

| Function            | HID++ fn | Signature                 | Returns           |
| ------------------- | -------- | ------------------------- | ----------------- |
| `get_capabilities`  | 0        | `()`                      | `DisableableKeys` |
| `get_disabled_keys` | 1        | `()`                      | `DisableableKeys` |
| `set_disabled_keys` | 2        | `(keys: DisableableKeys)` | `DisableableKeys` |

Every method is `async` and returns `Result<…, Hidpp20Error>`.

### Types [#types]

#### `DisableableKeys` [#disableablekeys]

`DisableableKeys` is a retained `u8` bitfield. OpenLogi uses it for both supported and currently disabled keys.

| Field         | Bit      | Description           |
| ------------- | -------- | --------------------- |
| `CAPS_LOCK`   | `1 << 0` | Caps Lock.            |
| `NUM_LOCK`    | `1 << 1` | Num Lock.             |
| `SCROLL_LOCK` | `1 << 2` | Scroll Lock.          |
| `INSERT`      | `1 << 3` | Insert.               |
| `WINDOWS`     | `1 << 4` | Windows or Start key. |

### Events [#events]

OpenLogi exposes no event type or `listen()` method for `0x4521`.

| Event               | Payload           | Description                                                     |
| ------------------- | ----------------- | --------------------------------------------------------------- |
| Disabled-key change | `DisableableKeys` | Logi Options+ 2.8 treats byte `0` as the new mask. Options+ 2.8 |

## Wire format [#wire-format]

The three functions use 3-byte short requests. OpenLogi normalizes each reply to 16 bytes and reads only byte `0`.

### `get_capabilities` (fn 0) [#get_capabilities-fn-0]

Request: `0x00, 0x00, 0x00`.

| Byte | Field  | Notes                                                  |
| ---- | ------ | ------------------------------------------------------ |
| 0    | `keys` | `DisableableKeys` bitmap of keys software may disable. |
| 1–15 | —      | OpenLogi ignores these bytes.                          |

### `get_disabled_keys` (fn 1) [#get_disabled_keys-fn-1]

Request: `0x00, 0x00, 0x00`.

| Byte | Field  | Notes                         |
| ---- | ------ | ----------------------------- |
| 0    | `keys` | Active disabled-key bitmap.   |
| 1–15 | —      | OpenLogi ignores these bytes. |

### `set_disabled_keys` (fn 2) [#set_disabled_keys-fn-2]

Request:

| Byte | Field  | Notes                                                               |
| ---- | ------ | ------------------------------------------------------------------- |
| 0    | `keys` | Complete replacement bitmap. `0x00` re-enables every supported key. |
| 1–2  | —      | Padding. OpenLogi sends `0x00`.                                     |

Response:

| Byte | Field  | Notes                                        |
| ---- | ------ | -------------------------------------------- |
| 0    | `keys` | The device echo parsed as `DisableableKeys`. |
| 1–15 | —      | OpenLogi ignores these bytes.                |

### Disabled-key change report (fn 2) [#disabled-key-change-report-fn-2]

Logi Options+ 2.8 handles a nonempty asynchronous report at function `2`. It interprets byte `0` as the new disabled-key bitmap.

| Byte | Field  | Notes                                      |
| ---- | ------ | ------------------------------------------ |
| 0    | `keys` | New `DisableableKeys` bitmap. Options+ 2.8 |
| 1–15 | —      | Layout beyond byte `0` is not established. |

## How OpenLogi uses it [#how-openlogi-uses-it]

OpenLogi v0.8.15 exposes only the typed wrapper. No device-layer, agent, CLI, or user-interface path reads or writes this setting.

`set_disabled_keys` is a whole-set replacement, not a per-key patch. A caller that changes one key should read the current mask, modify it, write the complete mask, and inspect the returned mask.

OpenLogi does not subscribe to the asynchronous change report. It does not maintain state or restore this setting at exit.

## Open questions [#open-questions]

Logi Options+ 2.8 handles an asynchronous function-`2` report, but no public specification or hardware capture confirms the full report layout. OpenLogi currently ignores that report, so a change from another host can leave an external caller's cached mask stale.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::disable_keys::{DisableKeysFeature, DisableableKeys},
};

// `device` is a discovered HID++ device.
device.enumerate_features().await?;
if let Some(feature) = device.get_feature::<DisableKeysFeature>() {
    let current = feature.get_disabled_keys().await?;
    let wanted = current | DisableableKeys::CAPS_LOCK;
    let accepted = feature.set_disabled_keys(wanted).await?;

    println!("accepted disabled-key mask: {accepted:?}");
}
```


# 0x4522 · disableKeysByUsage (/hidpp/features/x4522-disable-keys-by-usage)



`DisableKeysByUsage` controls arbitrary 8-bit keyboard HID usages. Unlike `0x4521`, each disable or enable request adds to or removes from the existing disabled set.

> **Spec:** Logitech HID++ 2.0 — *disableKeysByUsage*. &#x2A;*Used by:** Not used by OpenLogi outside the `openlogi-hidpp` wrapper.

## Function reference [#function-reference]

`DisableKeysByUsageFeature` is creatable for feature version `0`.

### Methods [#methods]

| Function           | HID++ fn | Signature         | Returns |
| ------------------ | -------- | ----------------- | ------- |
| `get_capabilities` | 0        | `()`              | `u8`    |
| `disable_keys`     | 1        | `(usages: &[u8])` | `()`    |
| `enable_keys`      | 2        | `(usages: &[u8])` | `()`    |
| `enable_all_keys`  | 3        | `()`              | `()`    |

Every method is `async` and returns `Result<…, Hidpp20Error>`.

### Types [#types]

This feature has no public feature-specific type. Each `usages` element is an 8-bit keyboard HID usage.

### Events [#events]

OpenLogi v0.8.15 exposes no event type or `listen()` method for this feature.

## Wire format [#wire-format]

`get_capabilities` and `enable_all_keys` use 3-byte short requests. `disable_keys` and `enable_keys` use 16-byte long requests.

OpenLogi rejects `0x00` in `usages` with `Hidpp20Error::Feature(ErrorType::InvalidArgument)`. The value is the wire list terminator and cannot represent a disabled key.

### `get_capabilities` (fn 0) [#get_capabilities-fn-0]

Request: `0x00, 0x00, 0x00`.

| Byte | Field        | Notes                                                  |
| ---- | ------------ | ------------------------------------------------------ |
| 0    | `max_usages` | Maximum number of usages the device can hold disabled. |
| 1–15 | —            | OpenLogi ignores these bytes.                          |

### `disable_keys` (fn 1) [#disable_keys-fn-1]

OpenLogi splits the input into sequential packets of 16 usages. The device accumulates every packet into its disabled set.

| Byte | Field    | Notes                                                                                        |
| ---- | -------- | -------------------------------------------------------------------------------------------- |
| 0–15 | `usages` | Up to 16 usage bytes. A partial packet is zero-padded, which supplies the `0x00` terminator. |

A full 16-byte packet has no terminator. OpenLogi waits for each reply but ignores all reply bytes.

### `enable_keys` (fn 2) [#enable_keys-fn-2]

This function uses the same 16-byte packet layout as `disable_keys`. Each usage removes that key from the disabled set; enabling an already enabled key is a device no-op.

| Byte | Field    | Notes                                                         |
| ---- | -------- | ------------------------------------------------------------- |
| 0–15 | `usages` | Up to 16 usage bytes. Partial packets are padded with `0x00`. |

OpenLogi waits for each reply but ignores all reply bytes.

### `enable_all_keys` (fn 3) [#enable_all_keys-fn-3]

Request: `0x00, 0x00, 0x00`.

The function clears the complete disabled-usage set. OpenLogi waits for the reply and ignores its bytes.

## How OpenLogi uses it [#how-openlogi-uses-it]

OpenLogi v0.8.15 exposes only the typed wrapper. No device-layer, agent, CLI, or user-interface path calls this feature.

An empty usage slice is valid and sends no requests. The wrapper does not cache the disabled set, so it has no state to restore at exit.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::disable_keys_by_usage::DisableKeysByUsageFeature,
};

// `device` is a discovered HID++ device.
device.enumerate_features().await?;
if let Some(feature) = device.get_feature::<DisableKeysByUsageFeature>() {
    let capacity = feature.get_capabilities().await?;
    println!("maximum disabled usages: {capacity}");

    feature.disable_keys(&[0x3a, 0x3b, 0x3c]).await?;
    feature.enable_keys(&[0x3b]).await?;
    feature.enable_all_keys().await?;
}
```


# 0x4530 · dualPlatform (/hidpp/features/x4530-dual-platform)



`DualPlatform` selects the HID key-code family for a two-platform device. `IosOrMac` is value `0`, and `AndroidOrWindows` is value `1`.

> **Spec:** No public Logitech specification. Layout reverse-engineered from Logi Options+ 2.8 (static analysis). &#x2A;*Cross-checked:** Solaar. &#x2A;*Used by:** Not used by OpenLogi outside the `openlogi-hidpp` wrapper.

## Function reference [#function-reference]

`DualPlatformFeature` is creatable for feature version `0`.

### Methods [#methods]

| Function       | HID++ fn | Signature                           | Returns                                      |
| -------------- | -------- | ----------------------------------- | -------------------------------------------- |
| `get_platform` | 0        | `()`                                | `DualPlatformSelection`                      |
| `set_platform` | 2        | `(platform: DualPlatformSelection)` | `DualPlatformSelection`                      |
| `listen`       | event    | `()`                                | `async_channel::Receiver<DualPlatformEvent>` |

`get_platform` and `set_platform` are `async` and return `Result<…, Hidpp20Error>`. `listen` comes from `EmittingFeature<DualPlatformEvent>`.

### Types [#types]

#### `DualPlatformSelection` [#dualplatformselection]

| Variant            | Value | Description                             |
| ------------------ | ----- | --------------------------------------- |
| `IosOrMac`         | 0     | iOS or macOS HID key-code family.       |
| `AndroidOrWindows` | 1     | Android or Windows HID key-code family. |

The selection is persistent. The user can select it during pairing or with a short press of the OS-selection button.

### Events [#events]

| Variant           | Payload                 | Description                                                |
| ----------------- | ----------------------- | ---------------------------------------------------------- |
| `PlatformChanged` | `DualPlatformSelection` | The user changed the platform with an OS-selection button. |

`set_platform` does not emit this event. OpenLogi accepts only event function `0`.

## Wire format [#wire-format]

Each request uses a 3-byte short payload. OpenLogi normalizes the reply to 16 bytes, then parses byte `0` as `DualPlatformSelection`.

### `get_platform` (fn 0) [#get_platform-fn-0]

Request:

| Byte | Field | Notes                                             |
| ---- | ----- | ------------------------------------------------- |
| 0–2  | —     | No parameters. OpenLogi sends `0x00, 0x00, 0x00`. |

Response:

| Byte | Field      | Notes                                       |
| ---- | ---------- | ------------------------------------------- |
| 0    | `platform` | `0` = `IosOrMac`; `1` = `AndroidOrWindows`. |
| 1–15 | —          | OpenLogi ignores these bytes.               |

### `set_platform` (fn 2) [#set_platform-fn-2]

Request:

| Byte | Field      | Notes                            |
| ---- | ---------- | -------------------------------- |
| 0    | `platform` | `DualPlatformSelection` as `u8`. |
| 1–2  | —          | Padding. OpenLogi sends `0x00`.  |

Response:

| Byte | Field      | Notes                                         |
| ---- | ---------- | --------------------------------------------- |
| 0    | `platform` | OpenLogi parses this byte as the device echo. |
| 1–15 | —          | OpenLogi ignores these bytes.                 |

### `PlatformChanged` event (fn 0) [#platformchanged-event-fn-0]

| Byte | Field      | Notes                                       |
| ---- | ---------- | ------------------------------------------- |
| 0    | `platform` | `0` = `IosOrMac`; `1` = `AndroidOrWindows`. |
| 1–15 | —          | OpenLogi ignores these bytes.               |

## How OpenLogi uses it [#how-openlogi-uses-it]

OpenLogi v0.8.15 exposes this typed wrapper and its broadcast listener. No device-layer, agent, CLI, or user-interface path reads or writes `0x4530`.

The wrapper does not cache the selection. It does not restore this persistent setting at exit.

## Open questions [#open-questions]

Logi Options+ 2.8 uses function `0` to read and function `1` to write. Solaar uses function `0` to read and function `2` to write.

OpenLogi v0.8.15 uses function `0` for reads and function `2` for writes. PR #1766 corrected the read in v0.8.14, so a read no longer sends a possible setter request.

No public function table or hardware capture resolves the write index. Logi Options+ also does not parse a setter echo, so the meaning of OpenLogi's returned byte remains unverified.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::{
        EmittingFeature,
        dual_platform::{DualPlatformEvent, DualPlatformFeature, DualPlatformSelection},
    },
};

// `device` is a discovered HID++ device.
device.enumerate_features().await?;
if let Some(feature) = device.get_feature::<DualPlatformFeature>() {
    let current = feature.get_platform().await?;
    println!("current platform: {current:?}");

    let echoed = feature
        .set_platform(DualPlatformSelection::AndroidOrWindows)
        .await?;
    println!("returned platform: {echoed:?}");

    let events = feature.listen();
    if let Ok(DualPlatformEvent::PlatformChanged(platform)) = events.recv().await {
        println!("hardware platform change: {platform:?}");
    }
}
```


# 0x4531 · multiPlatform (/hidpp/features/x4531-multi-platform)



`MultiPlatform` maps host slots to keyboard OS platforms. It reports platform descriptors, reads each host selection, and can persist a selected platform for a host.

> **Spec:** Logitech HID++ 2.0 — *multiPlatform* (v1). &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis), Solaar. &#x2A;*Used by:** [Keyboard features](/docs/features/keyboard).

## Function reference [#function-reference]

`MultiPlatformFeature` is creatable only for feature version `1`.

### Methods [#methods]

| Function                  | HID++ fn | Signature                               | Returns              |
| ------------------------- | -------- | --------------------------------------- | -------------------- |
| `get_feature_infos`       | 0        | `()`                                    | `MultiPlatformInfo`  |
| `get_platform_descriptor` | 1        | `(descriptor_index: u8)`                | `PlatformDescriptor` |
| `get_host_platform`       | 2        | `(host: HostIndex)`                     | `HostPlatform`       |
| `set_host_platform`       | 3        | `(host: HostIndex, platform_index: u8)` | `()`                 |

Every method is `async` and returns `Result<…, Hidpp20Error>`.

### Types [#types]

#### `MultiPlatformInfo` [#multiplatforminfo]

| Field                   | Type                        | Description                                  |
| ----------------------- | --------------------------- | -------------------------------------------- |
| `capabilities`          | `MultiPlatformCapabilities` | Feature capabilities.                        |
| `platform_count`        | `u8`                        | Number of platform IDs.                      |
| `descriptor_count`      | `u8`                        | Number of descriptor rows.                   |
| `host_count`            | `u8`                        | Number of host slots.                        |
| `current_host`          | `HostIndex`                 | Current host slot.                           |
| `current_host_platform` | `Option<u8>`                | Current slot platform, or `None` for `0xff`. |

#### `MultiPlatformCapabilities` [#multiplatformcapabilities]

The capability word is a BE `u16`.

| Field               | Value               | Description                         |
| ------------------- | ------------------- | ----------------------------------- |
| `OS_DETECTION`      | `1 << 8` (`0x0100`) | The device can detect the host OS.  |
| `SET_HOST_PLATFORM` | `1 << 9` (`0x0200`) | Host software can set the platform. |

#### `PlatformDescriptor` [#platformdescriptor]

| Field              | Type     | Description                         |
| ------------------ | -------- | ----------------------------------- |
| `platform_index`   | `u8`     | Platform that owns this descriptor. |
| `descriptor_index` | `u8`     | Descriptor row index.               |
| `os_mask`          | `OsMask` | Covered operating systems.          |
| `from_version`     | `u8`     | First supported OS major version.   |
| `from_revision`    | `u8`     | First supported OS revision.        |
| `to_version`       | `u8`     | Last supported OS major version.    |
| `to_revision`      | `u8`     | Last supported OS revision.         |

#### `OsMask` [#osmask]

`OsMask` is the BE `u16` in descriptor bytes `2–3`. OpenLogi retains unnamed bits.

| Field              | Value                | Description        |
| ------------------ | -------------------- | ------------------ |
| `TIZEN`            | `1 << 0` (`0x0001`)  | Tizen.             |
| `WINDOWS`          | `1 << 8` (`0x0100`)  | Microsoft Windows. |
| `WINDOWS_EMBEDDED` | `1 << 9` (`0x0200`)  | Windows Embedded.  |
| `LINUX`            | `1 << 10` (`0x0400`) | Linux.             |
| `CHROME`           | `1 << 11` (`0x0800`) | ChromeOS.          |
| `ANDROID`          | `1 << 12` (`0x1000`) | Android.           |
| `MACOS`            | `1 << 13` (`0x2000`) | macOS.             |
| `IOS`              | `1 << 14` (`0x4000`) | iOS.               |
| `WEBOS`            | `1 << 15` (`0x8000`) | webOS.             |

#### `HostPlatform` [#hostplatform]

| Field                   | Type             | Description                                                    |
| ----------------------- | ---------------- | -------------------------------------------------------------- |
| `host_index`            | `HostIndex`      | Host slot returned by the device.                              |
| `status`                | `u8`             | Raw host status byte.                                          |
| `platform_index`        | `Option<u8>`     | Selected platform, or `None` for `0xff`.                       |
| `source`                | `PlatformSource` | Selection source. Unknown values return `UnsupportedResponse`. |
| `auto_platform_index`   | `Option<u8>`     | Detected platform, or `None` for `0xff`.                       |
| `auto_descriptor_index` | `Option<u8>`     | Detected descriptor, or `None` for `0xff`.                     |

#### `PlatformSource` [#platformsource]

| Variant    | Value | Description                |
| ---------- | ----- | -------------------------- |
| `Default`  | 0     | Device default.            |
| `Auto`     | 1     | Device-detected.           |
| `Manual`   | 2     | Selected on the device.    |
| `Software` | 3     | Selected by host software. |

#### `HostIndex` [#hostindex]

| Variant    | Value      | Description                       |
| ---------- | ---------- | --------------------------------- |
| `Current`  | `0xff`     | The current device-selected host. |
| `Slot(u8)` | `0..=0xfe` | A zero-based host slot index.     |

### Events [#events]

`MultiPlatformFeature` has no `listen()` method. `PlatformChange::decode` decodes a raw report for the inventory event path.

| Field            | Type                     | Description                                 |
| ---------------- | ------------------------ | ------------------------------------------- |
| `host_index`     | `HostIndex`              | Host whose platform changed.                |
| `platform_index` | `Option<u8>`             | New platform, or `None` for `0xff`.         |
| `source`         | `Option<PlatformSource>` | New source, or `None` for an unknown value. |

## Wire format [#wire-format]

Each function uses a 3-byte short request. OpenLogi normalizes each reply to 16 bytes before decoding it.

### `get_feature_infos` (fn 0) [#get_feature_infos-fn-0]

Request: `0x00, 0x00, 0x00`.

| Byte | Field                   | Notes                             |
| ---- | ----------------------- | --------------------------------- |
| 0–1  | `capabilities`          | BE `u16`.                         |
| 2    | `platform_count`        | Number of platform IDs.           |
| 3    | `descriptor_count`      | Number of descriptor rows.        |
| 4    | `host_count`            | Number of host slots.             |
| 5    | `current_host`          | `HostIndex`; `0xff` is `Current`. |
| 6    | `current_host_platform` | `0xff` is `None`.                 |
| 7–15 | —                       | OpenLogi ignores these bytes.     |

### `get_platform_descriptor` (fn 1) [#get_platform_descriptor-fn-1]

Request: `[descriptor_index, 0x00, 0x00]`.

| Byte | Field              | Notes                               |
| ---- | ------------------ | ----------------------------------- |
| 0    | `platform_index`   | Platform that owns this descriptor. |
| 1    | `descriptor_index` | Descriptor row index.               |
| 2–3  | `os_mask`          | BE `u16`.                           |
| 4    | `from_version`     | First supported OS major version.   |
| 5    | `from_revision`    | First supported OS revision.        |
| 6    | `to_version`       | Last supported OS major version.    |
| 7    | `to_revision`      | Last supported OS revision.         |
| 8–15 | —                  | OpenLogi ignores these bytes.       |

### `get_host_platform` (fn 2) [#get_host_platform-fn-2]

Request: `[u8::from(host), 0x00, 0x00]`.

| Byte | Field                   | Notes                                                    |
| ---- | ----------------------- | -------------------------------------------------------- |
| 0    | `host_index`            | `HostIndex` returned by the device.                      |
| 1    | `status`                | Raw status byte.                                         |
| 2    | `platform_index`        | `0xff` is `None`.                                        |
| 3    | `source`                | `0` = Default, `1` = Auto, `2` = Manual, `3` = Software. |
| 4    | `auto_platform_index`   | `0xff` is `None`.                                        |
| 5    | `auto_descriptor_index` | `0xff` is `None`.                                        |
| 6–15 | —                       | OpenLogi ignores these bytes.                            |

### `set_host_platform` (fn 3) [#set_host_platform-fn-3]

Request: `[u8::from(host), platform_index, 0x00]`.

OpenLogi waits for the HID++ reply but does not parse reply bytes. Call `get_host_platform` after this setter to confirm the persistent selection.

### `PlatformChange` event [#platformchange-event]

The public v1 specification lists this as event `0`. Logi Options+ and OpenLogi's inventory path accept any unsolicited function for this feature after the feature-index and software-ID checks.

| Byte | Field            | Notes                                         |
| ---- | ---------------- | --------------------------------------------- |
| 0    | `host_index`     | `HostIndex`.                                  |
| 1    | `platform_index` | `0xff` becomes `None`.                        |
| 2    | `source`         | Unknown values become `None`.                 |
| 3–15 | —                | `PlatformChange::decode` ignores these bytes. |

## How OpenLogi uses it [#how-openlogi-uses-it]

Since v0.8.14, the **Keep OS layout** setting uses this feature. The setting defaults to on when a keyboard advertises `SET_HOST_PLATFORM`.

OpenLogi reads feature information and requires a current numbered slot within `host_count`. It reads every descriptor, selects a platform that covers the host OS, and rejects indices outside `platform_count`.

OpenLogi reads the current slot before it writes. It writes only when the selected platform differs, then reads the slot again and reports a mismatch as an error.

The host OS is macOS, Windows, or Linux. When its version is unavailable, OpenLogi accepts only a descriptor with no upper version bound.

The inventory path recognizes a software-ID-`0` platform-change report, increments `settings_revision`, and requests reconciliation. If Keep OS layout remains enabled, the agent rechecks and reasserts the host layout after a device-side change.

This selection is persistent. OpenLogi does not restore the previous platform at exit; turning the setting off stops future writes without reverting the current selection.

## Open questions [#open-questions]

The v1 specification identifies event `0`, while Options+ accepts reports regardless of their function. OpenLogi uses the permissive Options+ rule in its inventory path.

No hardware capture establishes whether keyboards emit nonzero-function reports. OpenLogi treats any valid three-byte report as a change hint and re-reads authoritative state.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::{
        hosts_info::HostIndex,
        multi_platform::{MultiPlatformCapabilities, MultiPlatformFeature},
    },
};

// `device` is a discovered HID++ device.
device.enumerate_features().await?;
if let Some(feature) = device.get_feature::<MultiPlatformFeature>() {
    let info = feature.get_feature_infos().await?;
    let host = HostIndex::Current;
    let before = feature.get_host_platform(host).await?;

    if info
        .capabilities
        .contains(MultiPlatformCapabilities::SET_HOST_PLATFORM)
    {
        // The caller must obtain this index from a compatible descriptor.
        feature.set_host_platform(host, 0).await?;
        let after = feature.get_host_platform(host).await?;
        println!("{before:?} -> {after:?}");
    }
}
```


# 0x4600 · crown (/hidpp/features/x4600-crown)



`0x4600` controls the rotary crown on supported MX Master devices. It exposes capability and current-mode reads. It can change reporting, ratchet mode, and three timing values.

> **Spec:** Logitech HID++ 2.0 — *x4600 crown* (v0). &#x2A;*Used by:** Not used by OpenLogi outside the typed `openlogi-hidpp` wrapper.

## Function reference [#function-reference]

`CrownFeature` implements feature version `0`.

### Methods [#methods]

| Function   | HID++ fn | Signature              | Returns                               |
| ---------- | -------- | ---------------------- | ------------------------------------- |
| `get_info` | 0        | `()`                   | `CrownInfo`                           |
| `get_mode` | 1        | `()`                   | `CrownMode`                           |
| `set_mode` | 2        | `(mode: SetCrownMode)` | `()`                                  |
| `listen`   | event    | `()`                   | `async_channel::Receiver<CrownEvent>` |

The first three methods are `async` and return `Result<…, Hidpp20Error>`. `listen` is synchronous.

### Types [#types]

#### `CrownInfo` [#crowninfo]

| Field      | Type                       | Description              |
| ---------- | -------------------------- | ------------------------ |
| `controls` | `CrownControlCapabilities` | Control capability bits. |
| `sensors`  | `CrownSensorCapabilities`  | Sensor capability bits.  |
| `slots`    | `u16`                      | Slots per revolution.    |
| `ratchets` | `u16`                      | Ratchets per revolution. |

#### `CrownControlCapabilities` [#crowncontrolcapabilities]

| Field                             | Value    | Description                         |
| --------------------------------- | -------- | ----------------------------------- |
| `BUTTON`                          | `1 << 0` | The crown has a button.             |
| `BUTTON_LONG_PRESS`               | `1 << 1` | The button reports long presses.    |
| `MECHANIZED_RATCHET`              | `1 << 2` | The ratchet is mechanized.          |
| `ROTATION_TIMEOUT_CONFIGURABLE`   | `1 << 3` | Rotation timeout is configurable.   |
| `SHORT_LONG_TIMEOUT_CONFIGURABLE` | `1 << 4` | Short-long timeout is configurable. |
| `DOUBLE_TAP_SPEED_CONFIGURABLE`   | `1 << 5` | Double-tap speed is configurable.   |

#### `CrownSensorCapabilities` [#crownsensorcapabilities]

| Field                | Value    | Description                       |
| -------------------- | -------- | --------------------------------- |
| `PROXIMITY`          | `1 << 0` | The crown has a proximity sensor. |
| `TOUCH`              | `1 << 1` | The crown has a touch sensor.     |
| `TAP_GESTURE`        | `1 << 2` | The crown detects a tap.          |
| `DOUBLE_TAP_GESTURE` | `1 << 3` | The crown detects a double tap.   |

#### `CrownMode` and `SetCrownMode` [#crownmode-and-setcrownmode]

| Field                | `CrownMode` type | `SetCrownMode` type     | Description                      |
| -------------------- | ---------------- | ----------------------- | -------------------------------- |
| `diverting`          | `ReportingMode`  | `Option<ReportingMode>` | Event reporting destination.     |
| `ratchet_mode`       | `RatchetMode`    | `Option<RatchetMode>`   | Free or ratchet rotation.        |
| `rotation_timeout`   | `u8`             | `Option<NonZeroU8>`     | Timeout in 10 ms steps.          |
| `short_long_timeout` | `u8`             | `Option<NonZeroU8>`     | Press threshold in 10 ms steps.  |
| `double_tap_speed`   | `u8`             | `Option<NonZeroU8>`     | Double-tap speed in 10 ms steps. |

`None` in `SetCrownMode` writes the protocol's zero no-change sentinel. The device clips `rotation_timeout` to `0x40`. `CrownMode` rejects that sentinel and other unknown mode values.

#### `ReportingMode` and `RatchetMode` [#reportingmode-and-ratchetmode]

| Type            | Variant    | Value | Description              |
| --------------- | ---------- | ----- | ------------------------ |
| `ReportingMode` | `Hid`      | `1`   | Events go to native HID. |
| `ReportingMode` | `Diverted` | `2`   | Events go to HID++.      |
| `RatchetMode`   | `Free`     | `1`   | Free rotation.           |
| `RatchetMode`   | `Ratchet`  | `2`   | Detented rotation.       |

### Events [#events]

`CrownFeature` implements `EmittingFeature<CrownEvent>`. It accepts only event sub-ID `0`.

#### `CrownEvent` and `CrownUpdate` [#crownevent-and-crownupdate]

| Variant or field            | Type            | Description                                 |
| --------------------------- | --------------- | ------------------------------------------- |
| `Update(CrownUpdate)`       | `CrownEvent`    | A crown update while reporting is diverted. |
| `rotation_state`            | `RotationState` | Rotation lifecycle state.                   |
| `relative_slot_rotation`    | `i8`            | Signed slot delta.                          |
| `relative_ratchet_rotation` | `i8`            | Signed ratchet delta.                       |
| `proximity`                 | `ActivityState` | Proximity lifecycle state.                  |
| `touch`                     | `ActivityState` | Touch lifecycle state.                      |
| `gesture`                   | `CrownGesture`  | Touch gesture.                              |
| `button`                    | `ButtonState`   | Button lifecycle state.                     |
| `speed`                     | `i16`           | Signed slots per second.                    |

| Type            | Variants                                                                                        | Values         |
| --------------- | ----------------------------------------------------------------------------------------------- | -------------- |
| `RotationState` | `Inactive`, `Start`, `Active`, `Stop`, `Other(u8)`                                              | `0`–`3`, other |
| `ActivityState` | `Inactive`, `Start`, `Active`, `Stop`, `Other(u8)`                                              | `0`–`3`, other |
| `CrownGesture`  | `None`, `Tap`, `DoubleTap`, `Other(u8)`                                                         | `0`–`2`, other |
| `ButtonState`   | `Inactive`, `Press`, `ShortPressActive`, `LongPress`, `LongPressActive`, `Release`, `Other(u8)` | `0`–`5`, other |

Event enums preserve an unmodelled byte as `Other(u8)`.

## Wire format [#wire-format]

`get_info` and `get_mode` send three zero bytes. `set_mode` sends a 16-byte long payload. OpenLogi ignores response bytes not listed below.

### `get_info` (fn 0) [#get_info-fn-0]

| Bytes | Field      | Notes                                                  |
| ----- | ---------- | ------------------------------------------------------ |
| 0     | `controls` | `CrownControlCapabilities`; unknown bits are retained. |
| 1     | `sensors`  | `CrownSensorCapabilities`; unknown bits are retained.  |
| 2–3   | `slots`    | `u16`, BE.                                             |
| 4–5   | `ratchets` | `u16`, BE.                                             |
| 6–15  | —          | OpenLogi ignores these bytes.                          |

### `get_mode` (fn 1) [#get_mode-fn-1]

| Byte | Field                | Notes                                                             |
| ---- | -------------------- | ----------------------------------------------------------------- |
| 0    | `diverting`          | `1` = `Hid`; `2` = `Diverted`. `0` and other values are rejected. |
| 1    | `ratchet_mode`       | `1` = `Free`; `2` = `Ratchet`. `0` and other values are rejected. |
| 2    | `rotation_timeout`   | `u8`, 10 ms steps.                                                |
| 3    | `short_long_timeout` | `u8`, 10 ms steps.                                                |
| 4    | `double_tap_speed`   | `u8`, 10 ms steps.                                                |
| 5–15 | —                    | OpenLogi ignores these bytes.                                     |

### `set_mode` (fn 2) [#set_mode-fn-2]

| Byte | Field                | Notes                                                    |
| ---- | -------------------- | -------------------------------------------------------- |
| 0    | `diverting`          | `Some(Hid)` = `1`; `Some(Diverted)` = `2`; `None` = `0`. |
| 1    | `ratchet_mode`       | `Some(Free)` = `1`; `Some(Ratchet)` = `2`; `None` = `0`. |
| 2    | `rotation_timeout`   | `NonZeroU8` value, or `0` for `None`.                    |
| 3    | `short_long_timeout` | `NonZeroU8` value, or `0` for `None`.                    |
| 4    | `double_tap_speed`   | `NonZeroU8` value, or `0` for `None`.                    |
| 5–15 | —                    | OpenLogi writes `0x00`.                                  |

The protocol reply echoes the request bytes. OpenLogi does not decode it. Call `get_mode` to read the resulting mode.

### `CrownEvent::Update` event (fn 0) [#crowneventupdate-event-fn-0]

| Bytes | Field                       | Notes                                   |
| ----- | --------------------------- | --------------------------------------- |
| 0     | `rotation_state`            | `RotationState`, including `Other(u8)`. |
| 1     | `relative_slot_rotation`    | `i8`.                                   |
| 2     | `relative_ratchet_rotation` | `i8`.                                   |
| 3     | `proximity`                 | `ActivityState`, including `Other(u8)`. |
| 4     | `touch`                     | `ActivityState`, including `Other(u8)`. |
| 5     | `gesture`                   | `CrownGesture`, including `Other(u8)`.  |
| 6     | `button`                    | `ButtonState`, including `Other(u8)`.   |
| 7–13  | —                           | OpenLogi ignores these bytes.           |
| 14–15 | `speed`                     | `i16`, BE.                              |

## How OpenLogi uses it [#how-openlogi-uses-it]

No device-layer or agent path uses `CrownFeature` in v0.8.15. The typed wrapper exposes the feature for library consumers. It does not apply capability gates, read-modify-write logic, read-back checks, or session restoration.

## Usage (Rust) [#usage-rust]

```rust
use std::num::NonZeroU8;

use hidpp::{
    device::Device,
    feature::{
        EmittingFeature,
        crown::{CrownFeature, RatchetMode, ReportingMode, SetCrownMode},
    },
};

// device: &mut Device, already created via Device::new(channel, index)
device.enumerate_features().await?;
if let Some(feature) = device.get_feature::<CrownFeature>() {
    feature
        .set_mode(SetCrownMode {
            diverting: Some(ReportingMode::Diverted),
            ratchet_mode: Some(RatchetMode::Ratchet),
            rotation_timeout: Some(NonZeroU8::new(10).unwrap()),
            short_long_timeout: None,
            double_tap_speed: None,
        })
        .await?;
    println!("{:?}", feature.get_mode().await?);

    let receiver = feature.listen();
    while let Ok(event) = receiver.recv().await {
        println!("{event:?}");
    }
}
```


# 0x6020 · tapEnable (/hidpp/features/x6020-tap-enable)



OpenLogi v0.8.15 lists `0x6020` as `TapEnable`, but it has no feature wrapper. No public specification or static analysis of Logitech software establishes its operations or wire layout.

> **Spec:** No public Logitech specification. The available evidence does not establish a request layout. &#x2A;*Used by:** Not used by OpenLogi.

<Callout type="warn" title="Not implemented in OpenLogi">
  OpenLogi v0.8.15 recognises this feature ID in its registry, but provides no typed wrapper. It does not read or write this feature.
</Callout>

## What it does [#what-it-does]

The registry name suggests a tap-enable setting. That name does not establish function indices, request or response bytes, state values, persistence, or device scope. This page therefore makes no touchpad-behaviour claim.

**Status:** Not implemented in OpenLogi v0.8.15 · &#x2A;*Feature ID:** `0x6020`


# 0x6100 · touchpadRawXY (/hidpp/features/x6100-touchpad-raw-xy)



`0x6100` exposes raw multi-touch frames from an integrated touchpad. It reports touchpad geometry, sensor properties, and the active raw-report flags. A frame contains two touch points, so a frame with more contacts spans multiple reports with one timestamp.

> **Spec:** Logitech HID++ 2.0 — *touchpadRawXY* (v0). &#x2A;*Used by:** Not used by OpenLogi outside the typed `openlogi-hidpp` wrapper.

## Function reference [#function-reference]

`TouchpadRawXyFeature` implements feature version `0`.

### Methods [#methods]

| Function               | HID++ fn | Signature                 | Returns                                     |
| ---------------------- | -------- | ------------------------- | ------------------------------------------- |
| `get_touchpad_info`    | 0        | `()`                      | `TouchpadInfo`                              |
| `get_raw_report_state` | 1        | `()`                      | `RawReportFlags`                            |
| `set_raw_report_state` | 2        | `(flags: RawReportFlags)` | `()`                                        |
| `listen`               | event    | `()`                      | `async_channel::Receiver<TouchpadRawEvent>` |

The first three methods are `async` and return `Result<…, Hidpp20Error>`. `listen` is synchronous.

### Types [#types]

#### `TouchpadInfo` [#touchpadinfo]

| Field                        | Type     | Description                                              |
| ---------------------------- | -------- | -------------------------------------------------------- |
| `x_size`                     | `u16`    | Pad width in native coordinate units.                    |
| `y_size`                     | `u16`    | Pad height in native coordinate units.                   |
| `z_data_range`               | `u8`     | Z-data range. `0x00` means none and `0x0f` means 16-bit. |
| `area_data_range`            | `u8`     | Area-data range. `0x0f` means 16-bit.                    |
| `timestamp_units`            | `u8`     | Timestamp increment in 0.1 ms units.                     |
| `max_finger_count`           | `u8`     | Maximum tracked contacts.                                |
| `origin`                     | `Origin` | Coordinate origin.                                       |
| `pen_support`                | `bool`   | Pen input support.                                       |
| `raw_report_mapping_version` | `u8`     | Raw-report mapping version.                              |
| `dpi`                        | `u16`    | Native sensor DPI.                                       |

#### `RawReportFlags` [#rawreportflags]

| Field               | Value    | Description                                          |
| ------------------- | -------- | ---------------------------------------------------- |
| `RAW`               | `1 << 0` | Enable raw reporting.                                |
| `FORCE_ADD`         | `1 << 1` | Add force data to 16-bit reporting. Deprecated.      |
| `ENHANCED`          | `1 << 2` | Enable enhanced reporting.                           |
| `WIDTH_HEIGHT`      | `1 << 3` | Report width and height instead of area.             |
| `NATIVE_GESTURE`    | `1 << 4` | Report native gestures.                              |
| `MAJOR_MINOR`       | `1 << 5` | Report major, minor, and orientation data.           |
| `WIDTH_HEIGHT_8BIT` | `1 << 6` | Report 8-bit width and height bytes instead of area. |

Some combinations are mutually exclusive. Common valid bitmaps are `0x00`, `0x05`, `0x09`, `0x21`, and `0x41`. The wrapper retains an unknown bit in a read value. It writes every bit in a caller-supplied `RawReportFlags` value unchanged.

#### `Origin` [#origin]

| Variant      | Value | Description         |
| ------------ | ----- | ------------------- |
| `LowerLeft`  | `1`   | Lower-left corner.  |
| `LowerRight` | `2`   | Lower-right corner. |
| `UpperLeft`  | `3`   | Upper-left corner.  |
| `UpperRight` | `4`   | Upper-right corner. |

#### `DualXyData` and `TouchPoint` [#dualxydata-and-touchpoint]

| Field            | Type         | Description                                                            |
| ---------------- | ------------ | ---------------------------------------------------------------------- |
| `timestamp`      | `u16`        | Running frame timestamp.                                               |
| `touch1`         | `TouchPoint` | First point in the report.                                             |
| `touch2`         | `TouchPoint` | Second point in the report.                                            |
| `button`         | `bool`       | Physical surface switch state.                                         |
| `end_of_frame`   | `bool`       | This report completes the frame.                                       |
| `finger_count`   | `u8`         | Total contacts in the frame.                                           |
| `contact_type`   | `u8`         | Two-bit type. `0` is a finger; other values are reserved.              |
| `contact_status` | `u8`         | Two-bit status. `0` is hover; `1` is touch; other values are reserved. |
| `x`              | `u16`        | 14-bit X coordinate.                                                   |
| `y`              | `u16`        | 14-bit Y coordinate.                                                   |
| `finger_id`      | `u8`         | Four-bit contact ID.                                                   |
| `z`              | `u8`         | Z distance or force MSB, depending on flags.                           |
| `area`           | `u8`         | Area, force LSB, or other flag-selected data.                          |

### Events [#events]

`TouchpadRawXyFeature` implements `EmittingFeature<TouchpadRawEvent>`. It accepts only event sub-ID `0`.

| Variant              | Description                              |
| -------------------- | ---------------------------------------- |
| `DualXy(DualXyData)` | A raw report with two touch-point slots. |

## Wire format [#wire-format]

All three functions send three-byte requests. OpenLogi reads extended response payloads and ignores response bytes not listed below.

### `get_touchpad_info` (fn 0) [#get_touchpad_info-fn-0]

| Bytes | Field                        | Notes                                                                                    |
| ----- | ---------------------------- | ---------------------------------------------------------------------------------------- |
| 0–1   | `x_size`                     | `u16`, BE.                                                                               |
| 2–3   | `y_size`                     | `u16`, BE.                                                                               |
| 4     | `z_data_range`               | Raw `u8`.                                                                                |
| 5     | `area_data_range`            | Raw `u8`.                                                                                |
| 6     | `timestamp_units`            | Raw `u8`, in 0.1 ms units.                                                               |
| 7     | `max_finger_count`           | Raw `u8`.                                                                                |
| 8     | `origin`                     | Values `1`–`4` map to `Origin`. Other values return `Hidpp20Error::UnsupportedResponse`. |
| 9     | `pen_support`                | Nonzero is `true`.                                                                       |
| 10–11 | —                            | OpenLogi ignores these bytes.                                                            |
| 12    | `raw_report_mapping_version` | Raw `u8`.                                                                                |
| 13–14 | `dpi`                        | `u16`, BE.                                                                               |
| 15    | —                            | OpenLogi ignores this byte.                                                              |

### `get_raw_report_state` (fn 1) [#get_raw_report_state-fn-1]

| Bytes | Field            | Notes                                           |
| ----- | ---------------- | ----------------------------------------------- |
| 0     | `RawReportFlags` | All bits, including unknown bits, are retained. |
| 1–15  | —                | OpenLogi ignores these bytes.                   |

### `set_raw_report_state` (fn 2) [#set_raw_report_state-fn-2]

| Byte | Field   | Notes                                 |
| ---- | ------- | ------------------------------------- |
| 0    | `flags` | Exact `RawReportFlags::bits()` value. |
| 1–2  | —       | OpenLogi writes `0x00`.               |

OpenLogi accepts a successful transport reply and does not decode its payload.

### `DualXy` event (fn 0) [#dualxy-event-fn-0]

The event payload is 16 bytes. `coord14(high, low)` is `(high & 0x3f) << 8 | low`.

| Bytes              | Field                               | Notes                           |
| ------------------ | ----------------------------------- | ------------------------------- |
| 0–1                | `timestamp`                         | `u16`, BE.                      |
| 2 `bits[7:6]`, 3   | `touch1.contact_type`, `touch1.x`   | Type and 14-bit X coordinate.   |
| 4 `bits[7:6]`, 5   | `touch1.contact_status`, `touch1.y` | Status and 14-bit Y coordinate. |
| 6, 7               | `touch1.z`, `touch1.area`           | Raw, flag-dependent bytes.      |
| 8 `bits[7:4]`      | `touch1.finger_id`                  | Four-bit ID.                    |
| 8 `bit[2]`         | `button`                            | Physical switch state.          |
| 8 `bit[0]`         | `end_of_frame`                      | Frame completion state.         |
| 8 `bits[3,1]`      | —                                   | OpenLogi ignores these bits.    |
| 9 `bits[7:6]`, 10  | `touch2.contact_type`, `touch2.x`   | Type and 14-bit X coordinate.   |
| 11 `bits[7:6]`, 12 | `touch2.contact_status`, `touch2.y` | Status and 14-bit Y coordinate. |
| 13, 14             | `touch2.z`, `touch2.area`           | Raw, flag-dependent bytes.      |
| 15 `bits[7:4]`     | `touch2.finger_id`                  | Four-bit ID.                    |
| 15 `bits[3:0]`     | `finger_count`                      | Four-bit contact count.         |

## How OpenLogi uses it [#how-openlogi-uses-it]

No device-layer or agent path uses `TouchpadRawXyFeature` in v0.8.15. The typed wrapper does not change report flags, read them back, or restore them for OpenLogi users.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::{
        EmittingFeature,
        touchpad_raw_xy::{RawReportFlags, TouchpadRawXyFeature},
    },
};

// device: &mut Device, already created via Device::new(channel, index)
device.enumerate_features().await?;
if let Some(feature) = device.get_feature::<TouchpadRawXyFeature>() {
    println!("{:?}", feature.get_touchpad_info().await?);
    feature.set_raw_report_state(RawReportFlags::RAW).await?;

    let receiver = feature.listen();
    while let Ok(event) = receiver.recv().await {
        println!("{event:?}");
    }
}
```


# 0x6110 · touchMouseRawTouchPoints (/hidpp/features/x6110-touch-mouse-raw)



`0x6110` exposes raw touch points from a touch mouse surface. It reports surface geometry, resolution, origin, and touch-point limits. A selected raw mode enables raw data, while status events remain a separate event type.

> **Spec:** Logitech HID++ 2.0 — *touchMouseRawTouchPoints* (v0). &#x2A;*Used by:** Not used by OpenLogi outside the typed `openlogi-hidpp` wrapper.

## Function reference [#function-reference]

`TouchMouseRawFeature` implements feature version `0`.

### Methods [#methods]

| Function            | HID++ fn | Signature         | Returns                                       |
| ------------------- | -------- | ----------------- | --------------------------------------------- |
| `get_touchpad_info` | 0        | `()`              | `TouchMouseInfo`                              |
| `get_raw_mode`      | 1        | `()`              | `RawMode`                                     |
| `set_raw_mode`      | 2        | `(mode: RawMode)` | `()`                                          |
| `listen`            | event    | `()`              | `async_channel::Receiver<TouchMouseRawEvent>` |

The first three methods are `async` and return `Result<…, Hidpp20Error>`. `listen` is synchronous.

### Types [#types]

#### `TouchMouseInfo` [#touchmouseinfo]

| Field                     | Type     | Description                                               |
| ------------------------- | -------- | --------------------------------------------------------- |
| `x_max_count`             | `u16`    | Maximum X count in dots.                                  |
| `y_max_count`             | `u16`    | Maximum Y count in dots.                                  |
| `resolution_dpi`          | `u16`    | Sensor DPI. The wrapper assumes equal X and Y resolution. |
| `origin`                  | `Origin` | Coordinate origin.                                        |
| `max_finger_count`        | `u8`     | Maximum reported contacts.                                |
| `width_height_data_range` | `u8`     | Maximum width or height data value.                       |

#### `RawMode` [#rawmode]

| Variant                    | Value | Description                                                      |
| -------------------------- | ----- | ---------------------------------------------------------------- |
| `NativeGestures`           | `0`   | Native gestures only.                                            |
| `RawFiltered`              | `1`   | Filtered raw data.                                               |
| `RawUnfilteredAndGestures` | `2`   | Unfiltered raw data and native gestures.                         |
| `RawUnfilteredAlways`      | `3`   | Unfiltered data, including while lifted or with a button active. |
| `RawUnfilteredWithZ`       | `4`   | Mode `2` with Z data in place of width.                          |

#### `Origin` [#origin]

| Variant      | Value | Description         |
| ------------ | ----- | ------------------- |
| `LowerLeft`  | `1`   | Lower-left corner.  |
| `LowerRight` | `2`   | Lower-right corner. |
| `UpperLeft`  | `3`   | Upper-left corner.  |
| `UpperRight` | `4`   | Upper-right corner. |

#### `TouchMousePoint` and `TouchMouseStatus` [#touchmousepoint-and-touchmousestatus]

| Field          | Type     | Description                                     |
| -------------- | -------- | ----------------------------------------------- |
| `x`            | `u16`    | 12-bit X coordinate.                            |
| `y`            | `u16`    | 12-bit Y coordinate.                            |
| `width_x`      | `u8`     | Four-bit X width, or Z in `RawUnfilteredWithZ`. |
| `width_y`      | `u8`     | Four-bit Y width.                               |
| `MOUSE_LIFTED` | `1 << 0` | The mouse is lifted.                            |
| `BUTTON_DOWN`  | `1 << 1` | A mouse button is down.                         |

`TouchMouseStatus` retains unknown flag bits from an event.

#### Constants [#constants]

| Field               | Value | Description                                   |
| ------------------- | ----- | --------------------------------------------- |
| `TOUCH_POINT_COUNT` | `4`   | Number of raw touch-point slots in one event. |

### Events [#events]

`TouchMouseRawFeature` implements `EmittingFeature<TouchMouseRawEvent>`. It accepts event sub-IDs `0` and `1`.

| Variant                           | Description                                                        |
| --------------------------------- | ------------------------------------------------------------------ |
| `RawData { touches }`             | Four `Option<TouchMousePoint>` slots indexed by finger ID `0`–`3`. |
| `StatusChanged(TouchMouseStatus)` | A raw mouse-status flag update.                                    |

## Wire format [#wire-format]

Getters send three zero bytes. The setter sends its mode then two zero bytes. OpenLogi reads extended response payloads and ignores bytes not listed below.

### `get_touchpad_info` (fn 0) [#get_touchpad_info-fn-0]

| Bytes | Field                     | Notes                                                                                    |
| ----- | ------------------------- | ---------------------------------------------------------------------------------------- |
| 0–1   | `x_max_count`             | `u16`, BE.                                                                               |
| 2–3   | `y_max_count`             | `u16`, BE.                                                                               |
| 4–5   | `resolution_dpi`          | `u16`, BE.                                                                               |
| 6     | `origin`                  | Values `1`–`4` map to `Origin`. Other values return `Hidpp20Error::UnsupportedResponse`. |
| 7     | `max_finger_count`        | Raw `u8`.                                                                                |
| 8     | `width_height_data_range` | Raw `u8`.                                                                                |
| 9–15  | —                         | OpenLogi ignores these bytes.                                                            |

### `get_raw_mode` (fn 1) [#get_raw_mode-fn-1]

| Bytes | Field     | Notes                                                                                          |
| ----- | --------- | ---------------------------------------------------------------------------------------------- |
| 0     | `RawMode` | Values `0`–`4` map to named variants. Other values return `Hidpp20Error::UnsupportedResponse`. |
| 1–15  | —         | OpenLogi ignores these bytes.                                                                  |

### `set_raw_mode` (fn 2) [#set_raw_mode-fn-2]

| Byte | Field  | Notes                   |
| ---- | ------ | ----------------------- |
| 0    | `mode` | The `RawMode` value.    |
| 1–2  | —      | OpenLogi writes `0x00`. |

OpenLogi accepts a successful transport reply and does not decode its payload.

### `RawData` event (fn 0) [#rawdata-event-fn-0]

The event has four contiguous four-byte slots. For slot `i`, the bytes start at `i * 4`. An `x_high` value of `0xff` produces `None` for that slot.

| Offset        | Field        | Notes                                                |
| ------------- | ------------ | ---------------------------------------------------- |
| 0             | `x_high`     | X bits `11:4`, unless `0xff` marks a lifted contact. |
| 1             | `y_high`     | Y bits `11:4`.                                       |
| 2 `bits[3:0]` | X low nibble | Combined with `x_high`.                              |
| 2 `bits[7:4]` | Y low nibble | Combined with `y_high`.                              |
| 3 `bits[3:0]` | `width_x`    | Four-bit width or Z.                                 |
| 3 `bits[7:4]` | `width_y`    | Four-bit width.                                      |

Coordinates are `x = (x_high << 4) | low_nibble` and `y = (y_high << 4) | high_nibble`.

### `StatusChanged` event (fn 1) [#statuschanged-event-fn-1]

| Bytes         | Field          | Notes                                                   |
| ------------- | -------------- | ------------------------------------------------------- |
| 0 `bit[0]`    | `MOUSE_LIFTED` | Mouse lifted state.                                     |
| 0 `bit[1]`    | `BUTTON_DOWN`  | Button-down state.                                      |
| 0 `bits[7:2]` | —              | Retained by `TouchMouseStatus` without named semantics. |
| 1–15          | —              | OpenLogi ignores these bytes.                           |

## How OpenLogi uses it [#how-openlogi-uses-it]

No device-layer or agent path uses `TouchMouseRawFeature` in v0.8.15. The typed wrapper does not select a raw mode, read it back, or restore it for OpenLogi users.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::{
        EmittingFeature,
        touch_mouse_raw::{RawMode, TouchMouseRawFeature},
    },
};

// device: &mut Device, already created via Device::new(channel, index)
device.enumerate_features().await?;
if let Some(feature) = device.get_feature::<TouchMouseRawFeature>() {
    println!("{:?}", feature.get_touchpad_info().await?);
    feature.set_raw_mode(RawMode::RawFiltered).await?;

    let receiver = feature.listen();
    while let Ok(event) = receiver.recv().await {
        println!("{event:?}");
    }
}
```


# 0x6500 · gestures (/hidpp/features/x6500-gestures1)



`0x6500` is the `Gestures1` feature identifier in the HID++ registry. OpenLogi v0.8.15 has no typed wrapper or device-layer path for it. This page does not infer a function table or event layout from the identifier alone.

> **Spec:** No public Logitech specification. &#x2A;*Used by:** Not used by OpenLogi.

<Callout type="warn" title="Not implemented in OpenLogi">
  OpenLogi v0.8.15 recognises `0x6500` as `Gestures1`. It does not expose methods, types, events, or wire-format handling for this feature.
</Callout>

## What it does [#what-it-does]

No public specification or static analysis of Logitech software gives function indices or a payload layout for `0x6500`. Other touch features use raw-coordinate APIs or `0x6501` for the limited legacy thumbwheel discovery that OpenLogi supports.

**Status:** Not implemented in OpenLogi v0.8.15 · &#x2A;*Feature ID:** `0x6500` · &#x2A;*See also:** [0x6501 gestures2](/hidpp/features/x6501-gestures2)


# 0x6501 · gestures2 (/hidpp/features/x6501-gestures2)



`0x6501` is the legacy Gestures2 descriptor feature. Older MX mice, including MX Master 2S, expose the horizontal thumb wheel as gesture ID `46` here. Newer devices can instead expose the dedicated [`0x2150 thumbwheel`](/hidpp/features/x2150-thumbwheel) feature.

> **Spec:** Logitech HID++ 2.0 — *x6501 gestures2* (partial). &#x2A;*Used by:** [Scrolling](/docs/features/mouse/scrolling) capability discovery for legacy thumb wheels.

## Function reference [#function-reference]

`Gestures2Feature` implements feature version `0`. It implements only descriptor discovery and diversion for gesture ID `46`.

### Methods [#methods]

| Function                  | HID++ fn | Signature          | Returns                     |
| ------------------------- | -------- | ------------------ | --------------------------- |
| `thumbwheel`              | 0, paged | `()`               | `Option<ThumbwheelGesture>` |
| `has_thumbwheel`          | 0, paged | `()`               | `bool`                      |
| `thumbwheel_diverted`     | 0, 3     | `()`               | `Option<bool>`              |
| `set_thumbwheel_diverted` | 0, 4     | `(diverted: bool)` | `bool`                      |

All methods are `async` and return `Result<…, Hidpp20Error>`. The last two methods return `None` or `false` when gesture `46` is absent or not divertable.

### Types [#types]

#### `ThumbwheelGesture` [#thumbwheelgesture]

| Field             | Type          | Description                                                                                            |
| ----------------- | ------------- | ------------------------------------------------------------------------------------------------------ |
| `diversion_index` | `Option<u16>` | Sequential index among divertable descriptors. `None` means gesture `46` exists but is not divertable. |

#### Constants [#constants]

| Field                   | Value | Description                                         |
| ----------------------- | ----- | --------------------------------------------------- |
| `THUMBWHEEL_GESTURE_ID` | `46`  | Legacy Gestures2 ID for the horizontal thumb wheel. |

### Events [#events]

`Gestures2Feature` does not implement `EmittingFeature`. It exposes no events.

## Wire format [#wire-format]

OpenLogi scans descriptor pages until it finds gesture `46`, sees the end marker, or reaches 1,024 descriptor fields. It rejects a table that passes that bound with `Hidpp20Error::UnsupportedResponse`.

### Descriptor page (fn 0) [#descriptor-page-fn-0]

The request is `[index_hi, index_lo, 0x00]`. `index` is a `u16`, BE, for the first descriptor in the page. A response has eight two-byte descriptor fields at offsets `0–1` through `14–15`.

| Descriptor byte  | Meaning                 | OpenLogi treatment                                                       |
| ---------------- | ----------------------- | ------------------------------------------------------------------------ |
| high = `0x01`    | End marker              | Stops scanning. The low byte is ignored.                                 |
| high `bit[7]`    | Gesture descriptor      | The wrapper considers the field only when this bit is set.               |
| high `bit[1]`    | Divertable              | The wrapper assigns the current diversion index when gesture ID is `46`. |
| high `bit[0]`    | Enabled descriptor flag | The wrapper does not expose or test this bit.                            |
| high `bits[6:2]` | Other descriptor flags  | The wrapper ignores these bits.                                          |
| low              | Gesture ID              | The wrapper compares it with `46`.                                       |

The scan advances eight descriptors per response. It increments the diversion index only for a previous descriptor with `bit[1]` set. It ignores a non-gesture descriptor and its low byte.

### Diversion state (fn 3) [#diversion-state-fn-3]

For diversion index `i`, `offset = i >> 3` and `mask = 1 << (i & 7)`.

| Bytes               | Field    | Notes                                     |
| ------------------- | -------- | ----------------------------------------- |
| 0                   | `offset` | Byte in the feature's diversion bitmap.   |
| 1                   | —        | OpenLogi writes `0x01`.                   |
| 2                   | `mask`   | Bit mask in that byte.                    |
| response byte 0     | state    | `response[0] & mask != 0` means diverted. |
| response bytes 1–15 | —        | OpenLogi ignores these bytes.             |

### Set diversion (fn 4) [#set-diversion-fn-4]

| Bytes | Field    | Notes                                       |
| ----- | -------- | ------------------------------------------- |
| 0     | `offset` | `i >> 3`.                                   |
| 1     | —        | OpenLogi writes `0x01`.                     |
| 2     | `mask`   | `1 << (i & 7)`.                             |
| 3     | `value`  | `mask` diverts; `0` restores.               |
| 4–15  | —        | OpenLogi writes `0x00` in the long payload. |

OpenLogi ignores the write response. It makes no fn 4 request when the gesture is absent or not divertable.

## How OpenLogi uses it [#how-openlogi-uses-it]

The device inventory probes `Gestures2Feature::has_thumbwheel` only when the device has no dedicated `0x2150` thumbwheel. A `true` result enables the legacy thumbwheel capability. A probe error becomes `false`, so the inventory does not expose that capability from an unanswered descriptor scan.

The device layer does not call `thumbwheel_diverted` or `set_thumbwheel_diverted` in v0.8.15. It therefore performs no Gestures2 diversion write, read-back, or session restore.

## Open questions [#open-questions]

The wrapper relies on the end marker, `bit[7]`, `bit[1]`, and gesture ID `46`. It does not expose the enabled flag or other descriptor flags. No hardware capture verifies their full semantics or the function 4 response.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{device::Device, feature::gestures2::Gestures2Feature};

// device: &mut Device, already created via Device::new(channel, index)
device.enumerate_features().await?;
if let Some(feature) = device.get_feature::<Gestures2Feature>() {
    if let Some(gesture) = feature.thumbwheel().await? {
        println!("diversion index: {:?}", gesture.diversion_index);
        let changed = feature.set_thumbwheel_diverted(true).await?;
        println!("diverted: {changed}");
    }
}
```


# 0x8010 · gamingGKeys (/hidpp/features/x8010-gaming-g-keys)



The `gamingGKeys` feature serves the dedicated G-key row on gaming keyboards that expose it. G HUB static analysis identifies a count query, a physical-layout query, and one software-control switch for the whole row. OpenLogi v0.8.15 recognises the feature ID but has no typed wrapper.

> **Spec:** No public Logitech specification. Layout reverse-engineered from G HUB (static analysis). &#x2A;*Used by:** Not used by OpenLogi.

<Callout type="warn" title="Not implemented in OpenLogi">
  OpenLogi v0.8.15 registers `0x8010` by name. It does not provide a typed wrapper, event decoder, or device-layer use.
</Callout>

## What it does [#what-it-does]

G HUB enables or disables software control for the complete G-key row with function 2. It does not expose a per-key control method. Event 0 carries a G-key bitmap; G HUB accepts payloads of at least 4 bytes and handles up to 32 keys.

## Functions [#functions]

| Function                | fn | Purpose                                                              |
| ----------------------- | -: | -------------------------------------------------------------------- |
| `GetCount`              |  0 | Query the G-key count. G HUB                                         |
| `GetPhysicalLayout`     |  1 | Query the physical-layout identifier. G HUB                          |
| `EnableSoftwareControl` |  2 | Enable or disable software control for the complete G-key row. G HUB |

OpenLogi does not implement or hardware-verify the request, response, or event layouts.

> **Status:** Not implemented · &#x2A;*Feature ID:** `0x8010` · &#x2A;*See also:** `0x8020` gamingMKeys and `0x8030` macroRecord


# 0x8020 · gamingMKeys (/hidpp/features/x8020-gaming-m-keys)



The `gamingMKeys` feature serves the M keys on gaming keyboards that expose them. G HUB static analysis identifies a count query, an M-key LED setter, and one button event. OpenLogi v0.8.15 recognises the feature ID but has no typed wrapper.

> **Spec:** No public Logitech specification. Layout reverse-engineered from G HUB (static analysis). &#x2A;*Used by:** Not used by OpenLogi.

<Callout type="warn" title="Not implemented in OpenLogi">
  OpenLogi v0.8.15 registers `0x8020` by name. It does not provide a typed wrapper, event decoder, or device-layer use.
</Callout>

## What it does [#what-it-does]

G HUB can query the number of M keys and set an M-key LED. Event 0 carries an 8-bit M-key button bitmap. The recovered interface does not establish a command that selects an active macro bank.

## Functions [#functions]

| Function     | fn | Purpose                            |
| ------------ | -: | ---------------------------------- |
| `GetCount`   |  0 | Query the M-key count. G HUB       |
| `SetMkeyLED` |  1 | Set the M-key indicator LED. G HUB |

OpenLogi does not implement or hardware-verify the request, response, or event layouts.

> **Status:** Not implemented · &#x2A;*Feature ID:** `0x8020` · &#x2A;*See also:** `0x8010` gamingGKeys


# 0x8030 · macroRecord (/hidpp/features/x8030-macro-record)



The `macroRecord` feature serves the macro-record control on gaming keyboards that expose it. G HUB static analysis identifies one LED setter and one button event. OpenLogi v0.8.15 recognises the feature ID but has no typed wrapper.

> **Spec:** No public Logitech specification. Layout reverse-engineered from G HUB (static analysis). &#x2A;*Used by:** Not used by OpenLogi.

<Callout type="warn" title="Not implemented in OpenLogi">
  OpenLogi v0.8.15 registers `0x8030` by name. It does not provide a typed wrapper, event decoder, or device-layer use.
</Callout>

## What it does [#what-it-does]

G HUB sets the macro-record LED with function 0. Event 0 carries an 8-bit button value. The recovered interface does not establish a protocol command for starting, stopping, or storing a macro.

## Functions [#functions]

| Function | fn | Purpose                                       |
| -------- | -: | --------------------------------------------- |
| `SetLED` |  0 | Enable or disable the macro-record LED. G HUB |

OpenLogi does not implement or hardware-verify the request, response, or event layouts.

> **Status:** Not implemented · &#x2A;*Feature ID:** `0x8030` · &#x2A;*See also:** `0x8010` gamingGKeys


# 0x8040 · brightnessControl (/hidpp/features/x8040-brightness-control)



Controls display or backlight brightness on devices that advertise `BrightnessControl`. It reports a brightness range and capability bits. It can also read or set illumination separately when the device advertises that capability.

> **Spec:** Logitech HID++ 2.0 — *brightnessControl*. &#x2A;*Used by:** Typed wrapper in `openlogi-hidpp`; no v0.8.15 device-layer path.

## Function reference [#function-reference]

`BrightnessControlFeature` (`0x8040`, version `1`) exposes the following methods.

### Methods [#methods]

| Function           | HID++ fn | Signature           | Returns          |
| ------------------ | -------- | ------------------- | ---------------- |
| `get_info`         | 0        | `()`                | `BrightnessInfo` |
| `get_brightness`   | 1        | `()`                | `u16`            |
| `set_brightness`   | 2        | `(brightness: u16)` | `()`             |
| `get_illumination` | 3        | `()`                | `bool`           |
| `set_illumination` | 4        | `(enabled: bool)`   | `()`             |

All methods are `async` and return `Result<…, Hidpp20Error>`.

### Types [#types]

#### `BrightnessInfo` [#brightnessinfo]

| Field            | Type                     | Description                            |
| ---------------- | ------------------------ | -------------------------------------- |
| `min_brightness` | `u16`                    | Lowest accepted brightness.            |
| `max_brightness` | `u16`                    | Highest accepted brightness.           |
| `steps`          | `u16`                    | Number of advertised brightness steps. |
| `capabilities`   | `BrightnessCapabilities` | Feature capability bits.               |

#### `BrightnessCapabilities` [#brightnesscapabilities]

Unknown bits are retained when OpenLogi decodes the capability byte.

| Flag                  | Bit | Description                                             |
| --------------------- | --- | ------------------------------------------------------- |
| `HARDWARE_BRIGHTNESS` | 0   | Hardware can change brightness directly.                |
| `EVENTS`              | 1   | The device can emit brightness or illumination changes. |
| `ILLUMINATION`        | 2   | Illumination can be queried and controlled separately.  |
| `HARDWARE_ON_OFF`     | 3   | Hardware can toggle illumination directly.              |
| `TRANSIENT`           | 4   | Brightness does not persist across a power cycle.       |

### Events [#events]

`BrightnessControlFeature` exposes no event type or listener. The `EVENTS` capability therefore has no OpenLogi event consumer.

## Wire format [#wire-format]

Requests have a three-byte short payload. All multi-byte fields are BE. OpenLogi expands short responses to its 16-byte payload before decoding.

### `get_info` (fn 0) [#get_info-fn-0]

Request: `[0x00, 0x00, 0x00]`

| Bytes | Field             | Notes                              |
| ----- | ----------------- | ---------------------------------- |
| 0–1   | `max_brightness`  | BE `u16`.                          |
| 2     | `steps` low byte  | Combined with byte 6.              |
| 3     | `capabilities`    | `BrightnessCapabilities` bitfield. |
| 4–5   | `min_brightness`  | BE `u16`.                          |
| 6     | `steps` high byte | `steps` is BE from bytes 6 then 2. |

### `get_brightness` (fn 1) [#get_brightness-fn-1]

Request: `[0x00, 0x00, 0x00]`

| Bytes | Field      | Notes     |
| ----- | ---------- | --------- |
| 0–1   | brightness | BE `u16`. |

### `set_brightness` (fn 2) [#set_brightness-fn-2]

Request: `[brightness_hi, brightness_lo, 0x00]`, where brightness is a BE `u16`.

The response is an acknowledgement. OpenLogi does not inspect a response payload.

### `get_illumination` (fn 3) [#get_illumination-fn-3]

Request: `[0x00, 0x00, 0x00]`

| Byte | Field              | Notes                   |
| ---- | ------------------ | ----------------------- |
| 0    | illumination state | Bit 0 set means `true`. |

### `set_illumination` (fn 4) [#set_illumination-fn-4]

Request: `[enabled, 0x00, 0x00]`, where `enabled` is `0x00` or `0x01`.

The response is an acknowledgement. OpenLogi does not inspect a response payload.

## How OpenLogi uses it [#how-openlogi-uses-it]

OpenLogi registers `BrightnessControlFeature` in the HID++ feature registry. In v0.8.15, no `openlogi-device`, agent, CLI, inventory, or user-facing path opens this feature.

OpenLogi therefore does not read its range, apply a capability gate, read back a write, restore its state, or subscribe to its advertised events. A direct caller should read `get_info()` and require `ILLUMINATION` before calling fn 3 or fn 4.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::brightness_control::{BrightnessCapabilities, BrightnessControlFeature},
};

// `device` is already created for a HID++ channel and device index.
device.enumerate_features().await?;
if let Some(feature) = device.get_feature::<BrightnessControlFeature>() {
    let info = feature.get_info().await?;
    let current = feature.get_brightness().await?;
    println!("brightness: {current} of {}", info.max_brightness);

    if info.capabilities.contains(BrightnessCapabilities::ILLUMINATION) {
        feature.set_illumination(true).await?;
    }
}
```


# 0x8060 · adjustableReportRate (/hidpp/features/x8060-report-rate)



`adjustableReportRate` controls how often a device sends HID reports to its host. The feature expresses the active interval in milliseconds. Devices that expose `0x8060` advertise each supported interval in a one-byte bitmap.

> **Spec:** Logitech HID++ 2.0 — *adjustableReportRate*. &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis), Solaar. &#x2A;*Used by:** [Report rate](/docs/features/mouse/report-rate).

## Function reference [#function-reference]

The `ReportRateFeature` wrapper (`0x8060`, version 0+) exposes the legacy interval interface.

### Methods [#methods]

| Function               | HID++ fn | Signature              | Returns          |
| ---------------------- | -------- | ---------------------- | ---------------- |
| `get_report_rate_list` | 0        | `()`                   | `ReportRateList` |
| `get_report_rate`      | 1        | `()`                   | `u8`             |
| `set_report_rate`      | 2        | `(report_rate_ms: u8)` | `()`             |

All methods are `async` and return `Result<…, Hidpp20Error>`.

### Types [#types]

`ReportRateList` is a `u8` bitflag set. Bit `n` names the interval `n + 1` ms.

| Flag   | Bit or value | Description    |
| ------ | ------------ | -------------- |
| `MS_1` | `1 << 0`     | 1 ms interval. |
| `MS_2` | `1 << 1`     | 2 ms interval. |
| `MS_3` | `1 << 2`     | 3 ms interval. |
| `MS_4` | `1 << 3`     | 4 ms interval. |
| `MS_5` | `1 << 4`     | 5 ms interval. |
| `MS_6` | `1 << 5`     | 6 ms interval. |
| `MS_7` | `1 << 6`     | 7 ms interval. |
| `MS_8` | `1 << 7`     | 8 ms interval. |

### Events [#events]

`ReportRateFeature` exposes no event type. OpenLogi has no `0x8060` report-rate broadcast path.

## Wire format [#wire-format]

All functions send 3-byte short requests. OpenLogi reads functions 0 and 1 as 16-byte extended replies. The setter checks only the HID++ acknowledgement.

### `get_report_rate_list` (fn 0) [#get_report_rate_list-fn-0]

Request: `[0x00, 0x00, 0x00]`. All three bytes are padding.

| Byte or bits | Field            | Notes                                 |
| ------------ | ---------------- | ------------------------------------- |
| 0, bits 0–7  | `ReportRateList` | Bit 0 is 1 ms through bit 7 for 8 ms. |
| 1–15         | Reserved         | OpenLogi ignores these bytes.         |

### `get_report_rate` (fn 1) [#get_report_rate-fn-1]

Request: `[0x00, 0x00, 0x00]`. All three bytes are padding.

| Byte | Field            | Notes                                                                                                                      |
| ---- | ---------------- | -------------------------------------------------------------------------------------------------------------------------- |
| 0    | `report_rate_ms` | Active interval in milliseconds. OpenLogi accepts only `1..=8`; another value returns `Hidpp20Error::UnsupportedResponse`. |
| 1–15 | Reserved         | OpenLogi ignores these bytes.                                                                                              |

### `set_report_rate` (fn 2) [#set_report_rate-fn-2]

| Request bytes | Field            | Notes                                                                                                                                                    |
| ------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0             | `report_rate_ms` | Requested interval in milliseconds. OpenLogi accepts only `1..=8`; another value returns `Hidpp20Error::Feature(ErrorType::InvalidArgument)` before I/O. |
| 1–2           | Padding          | OpenLogi writes `0x00`.                                                                                                                                  |

Response: HID++ acknowledgement. OpenLogi ignores all response payload bytes. An in-range interval that the device does not support can return `InvalidArgument`.

## How OpenLogi uses it [#how-openlogi-uses-it]

Since v0.8.14, PR #1779 rejects invalid legacy reply and request intervals in the typed wrapper. Since v0.8.14, PR #1808 connects report rate to the device layer, agent IPC, desktop Pointer tab, and `openlogi api report-rate`.

The device layer prefers `0x8061` when a device exposes it. It falls back to `0x8060` only when `0x8061` is unsupported. For this feature, it converts the advertised intervals to whole hertz, reads the list and active rate, and refuses a value not in that list before it writes.

The device layer skips a write when the rate is already active. Otherwise it reads back the active rate after the acknowledgement. A different read-back returns `WriteError::ReportRateNotApplied`; a post-write read failure retains the acknowledged requested rate as a provisional result.

The agent saves `report_rate` only when the user requests persistence. On reconnect, it re-applies a saved rate only to a device with report-rate capability and only when the current connection advertises that rate. OpenLogi does not reset the device's report rate on exit. See [Report rate](/docs/features/mouse/report-rate) for the GUI, configuration, and CLI behavior.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::report_rate::{ReportRateFeature, ReportRateList},
    protocol::v20::Hidpp20Error,
};

async fn set_one_millisecond(device: &mut Device) -> Result<(), Hidpp20Error> {
    device.enumerate_features().await?;

    if let Some(feature) = device.get_feature::<ReportRateFeature>() {
        let supported = feature.get_report_rate_list().await?;
        if supported.contains(ReportRateList::MS_1) {
            feature.set_report_rate(1).await?;
        }
    }

    Ok(())
}
```


# 0x8061 · extendedAdjustableReportRate (/hidpp/features/x8061-extended-report-rate)



`extendedAdjustableReportRate` controls high-frequency polling from 125 Hz to 8000 Hz. It distinguishes wired USB from Logitech gaming wireless for capability and active-rate reads. The setter applies a rate to the connection currently used by the host.

> **Spec:** Logitech HID++ 2.0 — *extendedAdjustableReportRate*. &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis), Solaar. &#x2A;*Used by:** [Report rate](/docs/features/mouse/report-rate).

## Function reference [#function-reference]

The `ExtendedReportRateFeature` wrapper (`0x8061`, version 0+) exposes four operations.

### Methods [#methods]

| Function                      | HID++ fn | Signature                           | Returns                  |
| ----------------------------- | -------- | ----------------------------------- | ------------------------ |
| `get_device_capabilities`     | 0        | `(connection_type: ConnectionType)` | `ExtendedReportRateList` |
| `get_actual_report_rate_list` | 1        | `()`                                | `ExtendedReportRateList` |
| `get_report_rate`             | 2        | `(connection_type: ConnectionType)` | `ExtendedReportRate`     |
| `set_report_rate`             | 3        | `(report_rate: ExtendedReportRate)` | `()`                     |

All methods are `async` and return `Result<…, Hidpp20Error>`.

### Types [#types]

#### `ExtendedReportRateList` [#extendedreportratelist]

This `u16` bitflag set represents the rates in the response's second byte.

| Flag      | Bit or value | Description                    |
| --------- | ------------ | ------------------------------ |
| `HZ_125`  | `1 << 0`     | 125 Hz, or an 8 ms interval.   |
| `HZ_250`  | `1 << 1`     | 250 Hz, or a 4 ms interval.    |
| `HZ_500`  | `1 << 2`     | 500 Hz, or a 2 ms interval.    |
| `HZ_1000` | `1 << 3`     | 1000 Hz, or a 1 ms interval.   |
| `HZ_2000` | `1 << 4`     | 2000 Hz, or a 500 µs interval. |
| `HZ_4000` | `1 << 5`     | 4000 Hz, or a 250 µs interval. |
| `HZ_8000` | `1 << 6`     | 8000 Hz, or a 125 µs interval. |

#### `ConnectionType` [#connectiontype]

This non-exhaustive enum selects the connection for functions 0 and 2.

| Variant          | Value | Description               |
| ---------------- | ----- | ------------------------- |
| `Wired`          | 0     | Wired USB.                |
| `GamingWireless` | 1     | Logitech gaming wireless. |

#### `ExtendedReportRate` [#extendedreportrate]

This non-exhaustive enum is used by the setter and returned by function 2.

| Variant  | Value | Description         |
| -------- | ----- | ------------------- |
| `Hz125`  | 0     | 125 Hz, or 8 ms.    |
| `Hz250`  | 1     | 250 Hz, or 4 ms.    |
| `Hz500`  | 2     | 500 Hz, or 2 ms.    |
| `Hz1000` | 3     | 1000 Hz, or 1 ms.   |
| `Hz2000` | 4     | 2000 Hz, or 500 µs. |
| `Hz4000` | 5     | 4000 Hz, or 250 µs. |
| `Hz8000` | 6     | 8000 Hz, or 125 µs. |

### Events [#events]

The wrapper exposes no Rust event type. An unsolicited function-0 report is a report-rate change broadcast when its feature index matches `0x8061` and its software ID is 0. OpenLogi then advances the device settings revision and requests reconciliation.

## Wire format [#wire-format]

All four functions send 3-byte short requests. OpenLogi reads functions 0–2 as 16-byte extended replies. Function 3 checks only the HID++ acknowledgement.

### `get_device_capabilities` (fn 0) [#get_device_capabilities-fn-0]

| Request byte | Field             | Notes                                      |
| ------------ | ----------------- | ------------------------------------------ |
| 0            | `connection_type` | `0` for `Wired`; `1` for `GamingWireless`. |
| 1–2          | Padding           | OpenLogi writes `0x00`.                    |

| Response byte or bits | Field                     | Notes                                                                                                   |
| --------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------- |
| 0                     | Unconfirmed non-rate byte | Logi Options+ 2.8 does not include it in the rate mask. OpenLogi ignores it.                            |
| 1, bits 0–6           | `ExtendedReportRateList`  | Bit 0 is 125 Hz through bit 6 for 8000 Hz.                                                              |
| 1, bit 7              | Unassigned                | OpenLogi retains this bit in the raw bitflags value. The device layer ignores it when it forms choices. |
| 2–15                  | Reserved                  | OpenLogi ignores these bytes.                                                                           |

### `get_actual_report_rate_list` (fn 1) [#get_actual_report_rate_list-fn-1]

Request: `[0x00, 0x00, 0x00]`. All three bytes are padding.

| Response byte or bits | Field                     | Notes                                                                                                   |
| --------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------- |
| 0                     | Unconfirmed non-rate byte | Logi Options+ 2.8 does not include it in the rate mask. OpenLogi ignores it.                            |
| 1, bits 0–6           | `ExtendedReportRateList`  | Same one-byte mask as function 0. It names rates for the current connection.                            |
| 1, bit 7              | Unassigned                | OpenLogi retains this bit in the raw bitflags value. The device layer ignores it when it forms choices. |
| 2–15                  | Reserved                  | OpenLogi ignores these bytes.                                                                           |

### `get_report_rate` (fn 2) [#get_report_rate-fn-2]

| Request byte | Field             | Notes                                      |
| ------------ | ----------------- | ------------------------------------------ |
| 0            | `connection_type` | `0` for `Wired`; `1` for `GamingWireless`. |
| 1–2          | Padding           | OpenLogi writes `0x00`.                    |

| Response byte | Field                             | Notes                                                                                               |
| ------------- | --------------------------------- | --------------------------------------------------------------------------------------------------- |
| 0             | `ExtendedReportRate` discriminant | `0` is `Hz125` through `6` for `Hz8000`. Another value returns `Hidpp20Error::UnsupportedResponse`. |
| 1–15          | Reserved                          | OpenLogi ignores these bytes.                                                                       |

### `set_report_rate` (fn 3) [#set_report_rate-fn-3]

| Request byte | Field                             | Notes                                    |
| ------------ | --------------------------------- | ---------------------------------------- |
| 0            | `ExtendedReportRate` discriminant | `0` is `Hz125` through `6` for `Hz8000`. |
| 1–2          | Padding                           | OpenLogi writes `0x00`.                  |

Response: HID++ acknowledgement. OpenLogi ignores all response payload bytes.

### Report-rate change broadcast (fn 0) [#report-rate-change-broadcast-fn-0]

| Payload bytes   | Field                  | Notes                                                                                                                |
| --------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------- |
| 0–1             | Unconfirmed event data | Options+ 2.8 requires at least two bytes before it emits an event. OpenLogi does not decode or preserve these bytes. |
| Remaining bytes | Unconfirmed            | No field layout is established. OpenLogi does not decode them.                                                       |

## How OpenLogi uses it [#how-openlogi-uses-it]

Since v0.8.14, PR #1779 reads the rate mask from response byte 1 alone. Byte 0 cannot create a false rate capability. Since v0.8.14, PR #1808 connects the feature to the device layer, agent IPC, desktop Pointer tab, CLI, and inventory event listener.

The device layer prefers `0x8061` over `0x8060`. It derives `Wired` for a direct device route and `GamingWireless` for a receiver route. It reads function 1 for the current connection's choices and function 2 for its active rate.

Before writing, OpenLogi refuses a rate outside the current list and skips a rate already active. It then reads function 2 independently. A different value returns `WriteError::ReportRateNotApplied`; a post-write read failure retains the acknowledged requested rate as a provisional result.

The inventory listener recognizes an unmatched, software-ID-0 function-0 broadcast for the registered `0x8061` index. It advances `settings_revision`, queues reconciliation, and lets clients re-read the rate. The broadcast does not trigger a saved-rate replay.

The agent re-applies a saved `report_rate` only on reconnect, only for report-rate-capable devices, and only when the active connection advertises it. OpenLogi does not reset the report rate on exit. See [Report rate](/docs/features/mouse/report-rate) for the GUI, configuration, and CLI behavior.

## Open questions [#open-questions]

Logi Options+ 2.8 reads response byte 1 alone for functions 0 and 1. The purpose of response byte 0 remains unconfirmed. OpenLogi ignores byte 0 instead of assigning it a rate meaning.

Logi Options+ 2.8 accepts a function-0 notification only with at least two payload bytes. The payload fields are not recovered. OpenLogi uses the report only as a prompt to re-read the device state; a hardware capture is required to name the fields.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::extended_report_rate::{
        ConnectionType, ExtendedReportRate, ExtendedReportRateFeature,
    },
    protocol::v20::Hidpp20Error,
};

async fn set_wired_rate(device: &mut Device) -> Result<(), Hidpp20Error> {
    device.enumerate_features().await?;

    if let Some(feature) = device.get_feature::<ExtendedReportRateFeature>() {
        let supported = feature
            .get_device_capabilities(ConnectionType::Wired)
            .await?;
        if supported.bits() & (1_u16 << u8::from(ExtendedReportRate::Hz1000)) != 0 {
            feature.set_report_rate(ExtendedReportRate::Hz1000).await?;
        }
    }

    Ok(())
}
```


# 0x8070 · colorLedEffects (/hidpp/features/x8070-color-led-effects)



`colorLedEffects` is the per-zone RGB engine in Logitech keyboards and mice. Each LED zone reports its location and effect list. An effect index belongs to one zone only.

> **Spec:** Logitech HID++ 2.0 — *colorLedEffects* (v7). &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis), Solaar, G HUB (static analysis). &#x2A;*Used by:** [Keyboard lighting](/docs/features/keyboard/lighting).

## Function reference [#function-reference]

### Methods [#methods]

| Function                | HID++ fn | Signature                                                                                                  | Returns                                               |
| ----------------------- | -------: | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| `get_info`              |        0 | `()`                                                                                                       | `ColorLedInfo`                                        |
| `get_zone_info`         |        1 | `(zone_index: u8)`                                                                                         | `ZoneInfo`                                            |
| `get_zone_effect_info`  |        2 | `(zone_index: u8, zone_effect_index: u8)`                                                                  | `ZoneEffectInfo`                                      |
| `set_zone_effect`       |        3 | `(zone_index: u8, zone_effect_index: u8, params: [u8; ZONE_EFFECT_PARAM_COUNT], persistence: Persistence)` | `()`                                                  |
| `get_nv_config`         |        4 | `(capability: NvCapabilities)`                                                                             | `NvConfig`                                            |
| `set_nv_config`         |        5 | `(capability: NvCapabilities, state: NvCapabilityState, param1: u8, param2: u8)`                           | `()`                                                  |
| `get_led_bin_info`      |        6 | `(zone_index: u8, led_bin_index: LedBinIndex)`                                                             | `LedBinInfo`                                          |
| `get_sw_control`        |        7 | `()`                                                                                                       | `SwControlState`                                      |
| `set_sw_control`        |        8 | `(control: SwControl, sync_events: bool)`                                                                  | `()`                                                  |
| `get_effect_settings`   |        9 | `(zone_index: u8, source: PersistenceSource)`                                                              | `EffectSettings`                                      |
| `clear_effect_settings` |       10 | `(zone_index: u8)`                                                                                         | `()`                                                  |
| `set_cycling_direction` |       11 | `(direction: CyclingDirection)`                                                                            | `()`                                                  |
| `get_current_color`     |       12 | `(zone_index: u8)`                                                                                         | `Rgb`                                                 |
| `synchronize_effect`    |       13 | `(zone_index: u8, drift_value: i16)`                                                                       | `()`                                                  |
| `get_zone_effect`       |       14 | `(zone_index: u8, source: PersistenceSource)`                                                              | `ZoneEffect`                                          |
| `set_led_bin_info`      |       15 | `(info: &LedBinInfo)`                                                                                      | `LedBinInfo`                                          |
| `listen`                |        — | `(&self)`                                                                                                  | `async_channel::Receiver&lt;ColorLedEffectsEvent&gt;` |

Every operation except `listen` is an `async fn` that returns `Result&lt;_, Hidpp20Error&gt;`. `listen` is supplied by `EmittingFeature&lt;ColorLedEffectsEvent&gt;`.

### Types [#types]

`ZONE_EFFECT_PARAM_COUNT` is `10`.

#### Data structures [#data-structures]

| Type             | Fields                                                                                                             | Description                                                                                                      |
| ---------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `Rgb`            | `red: u8`, `green: u8`, `blue: u8`                                                                                 | One 8-bit value per colour channel.                                                                              |
| `ColorLedInfo`   | `zone_count: u8`, `nv_capabilities: NvCapabilities`, `ext_capabilities: ExtCapabilities`                           | Feature-wide information.                                                                                        |
| `ZoneInfo`       | `zone_index: u8`, `location: LocationEffect`, `effects_number: u8`, `persistency: PersistencyCapabilities`         | One zone. Iterate `0..effects_number` with `get_zone_effect_info`.                                               |
| `ZoneEffectInfo` | `zone_index: u8`, `zone_effect_index: u8`, `effect_id: EffectId`, `effect_capabilities: u16`, `effect_period: u16` | One effect. A zero `effect_capabilities` value uses Raptor compatibility defaults. A zero period is unavailable. |
| `ZoneEffect`     | `zone_index: u8`, `zone_effect_index: u8`, `params: [u8; ZONE_EFFECT_PARAM_COUNT]`                                 | The configured effect and its raw parameters.                                                                    |
| `EffectSettings` | `zone_index: u8`, `color: Rgb`, `period: u16`, `brightness: u8`, `param: u8`                                       | Decoded settings for one zone.                                                                                   |
| `SwControlState` | `control: SwControl`, `sync_events: bool`                                                                          | LED ownership and sync-event state.                                                                              |
| `NvConfig`       | `capability: NvCapabilities`, `state: NvCapabilityState`, `param1: u8`, `param2: u8`                               | One non-volatile capability entry.                                                                               |
| `LedBinInfo`     | `zone_index: u8`, `led_bin_index: LedBinIndex`, `red: u16`, `green: u16`, `blue: u16`, `white: u16`                | Manufacturing LED-bin values.                                                                                    |

All durations and periods in these structures are milliseconds. `effect_capabilities` remains a raw bitmask because its bits depend on `effect_id`.

#### Enumerations [#enumerations]

| Type                | Variants                                                                                                                                                                               | Values                                                                  |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `EffectId`          | `Disabled`, `FixedColor`, `PulsingBreathingLegacy`, `Cycling`, `ColorWave`, `Starlight`, `LightOnPress`, `AudioVisualizer`, `BootUp`, `DemoMode`, `PulsingBreathingWaveform`, `Ripple` | `0`–`11` in listed order. `AudioVisualizer` is reserved.                |
| `LocationEffect`    | `Primary`, `Logo`, `LeftSide`, `RightSide`, `Combined`, `Primary1`, `Primary2`, `Primary3`, `Primary4`, `Primary5`, `Primary6`                                                         | `1`–`11` in listed order.                                               |
| `Persistence`       | `Volatile`, `VolatileAndNonVolatile`, `NonVolatileOnly`                                                                                                                                | `0`, `1`, `2`. RAM only; RAM plus EEPROM; EEPROM only.                  |
| `PersistenceSource` | `Ram`, `Eeprom`                                                                                                                                                                        | `0`, `1`.                                                               |
| `SwControl`         | `Firmware`, `Software`                                                                                                                                                                 | `0`, `1`.                                                               |
| `CyclingDirection`  | `Clockwise`, `Anticlockwise`                                                                                                                                                           | `0`, `1`.                                                               |
| `LedBinIndex`       | `BinValueBrightness`, `BinValueColor`, `CalibrationFactors`, `Brightness`, `ColorimetricX`, `ColorimetricY`                                                                            | `0`–`5` in listed order.                                                |
| `NvCapabilityState` | `NoChange`, `Enabled`, `Disabled`                                                                                                                                                      | `0`, `1`, `2`. `NoChange` is a read-only sentinel that assumes enabled. |

#### Bit flags [#bit-flags]

| Type                      | Flag                     | Bit            | Meaning                                          |
| ------------------------- | ------------------------ | -------------- | ------------------------------------------------ |
| `NvCapabilities`          | `BOOT_UP_EFFECT`         | `1 &lt;&lt; 0` | A boot-up effect is configurable.                |
| `NvCapabilities`          | `DEMO`                   | `1 &lt;&lt; 1` | Demo mode is supported.                          |
| `NvCapabilities`          | `USER_DEMO_MODE`         | `1 &lt;&lt; 2` | User demo mode is supported.                     |
| `ExtCapabilities`         | `GET_ZONE_EFFECT`        | `1 &lt;&lt; 0` | `get_zone_effect` is supported.                  |
| `ExtCapabilities`         | `NO_GET_EFFECT_SETTINGS` | `1 &lt;&lt; 1` | `get_effect_settings` is not supported.          |
| `ExtCapabilities`         | `SET_LED_BIN_INFO`       | `1 &lt;&lt; 2` | `set_led_bin_info` is supported.                 |
| `ExtCapabilities`         | `MONOCHROME_ONLY`        | `1 &lt;&lt; 3` | Only monochrome effects are supported.           |
| `ExtCapabilities`         | `NO_SYNCHRONIZE_EFFECT`  | `1 &lt;&lt; 4` | Sync correction and its event are not supported. |
| `PersistencyCapabilities` | `ALWAYS_ON`              | `1 &lt;&lt; 0` | The zone can persist always-on state.            |
| `PersistencyCapabilities` | `ALWAYS_OFF`             | `1 &lt;&lt; 1` | The zone can persist always-off state.           |
| `PersistencyCapabilities` | `ON_THEN_OFF`            | `1 &lt;&lt; 2` | The zone can persist on-then-off state.          |

`PersistencyCapabilities::empty()` means that the zone has no persistency support.

### Events [#events]

`ColorLedEffectsFeature` implements `EmittingFeature&lt;ColorLedEffectsEvent&gt;`.

| Variant      | Fields                                  | Description                                                                                       |
| ------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `SyncEffect` | `zone_index: u8`, `effect_counter: u16` | A periodic effect reached a sync point. `0xff` targets all zones. The counter is in milliseconds. |

## Wire format [#wire-format]

Offsets are payload bytes. Short requests carry three bytes. Long requests carry 16 bytes. Every multi-byte value below is **BE**.

### `get_info` (fn 0) [#get_info-fn-0]

**Request:** `[0x00, 0x00, 0x00]`.

| Byte | Field              | Notes                        |
| ---- | ------------------ | ---------------------------- |
| 0    | `zone_count`       | Number of zones.             |
| 1–2  | `nv_capabilities`  | `NvCapabilities`, BE `u16`.  |
| 3–4  | `ext_capabilities` | `ExtCapabilities`, BE `u16`. |

### `get_zone_info` (fn 1) [#get_zone_info-fn-1]

**Request:** `[zone_index, 0x00, 0x00]`.

| Byte | Field            | Notes                                            |
| ---- | ---------------- | ------------------------------------------------ |
| 0    | `zone_index`     | Echo. OpenLogi requires it to match the request. |
| 1–2  | `location`       | `LocationEffect`, BE `u16`.                      |
| 3    | `effects_number` | Number of effects in this zone.                  |
| 4    | `persistency`    | `PersistencyCapabilities`.                       |

OpenLogi requires a long reply. It rejects a short reply or a mismatched index with `UnsupportedResponse`.

### `get_zone_effect_info` (fn 2) [#get_zone_effect_info-fn-2]

**Request:** `[zone_index, zone_effect_index, 0x00]`.

| Byte | Field                 | Notes                                                  |
| ---- | --------------------- | ------------------------------------------------------ |
| 0    | `zone_index`          | Response echo.                                         |
| 1    | `zone_effect_index`   | Response echo.                                         |
| 2–3  | `effect_id`           | `EffectId`, BE `u16`.                                  |
| 4–5  | `effect_capabilities` | Effect-specific bitmask, BE `u16`.                     |
| 6–7  | `effect_period`       | Period in milliseconds, BE `u16`; zero is unavailable. |

### `set_zone_effect` (fn 3) [#set_zone_effect-fn-3]

**Request:** long, 16 bytes.

| Byte  | Field               | Notes                                                                            |
| ----- | ------------------- | -------------------------------------------------------------------------------- |
| 0     | `zone_index`        | Target zone.                                                                     |
| 1     | `zone_effect_index` | Effect ordinal in that zone.                                                     |
| 2–11  | `params`            | Ten effect-specific bytes. `FixedColor` uses bytes 2–4 for red, green, and blue. |
| 12    | `persistence`       | `Persistence` value.                                                             |
| 13–15 | padding             | OpenLogi writes `0x00`.                                                          |

The wrapper waits for acknowledgement and discards its payload.

### `get_nv_config` (fn 4) [#get_nv_config-fn-4]

**Request:** `[cap_hi, cap_lo, 0x00]`. The capability is one `NvCapabilities` bit as BE `u16`.

| Byte | Field        | Notes                              |
| ---- | ------------ | ---------------------------------- |
| 0–1  | `capability` | Echoed `NvCapabilities`, BE `u16`. |
| 2    | `state`      | `NvCapabilityState`.               |
| 3    | `param1`     | Capability-specific byte.          |
| 4    | `param2`     | Capability-specific byte.          |

OpenLogi requires exactly one requested capability bit.

### `set_nv_config` (fn 5) [#set_nv_config-fn-5]

**Request:** long, 16 bytes.

| Byte | Field        | Notes                               |
| ---- | ------------ | ----------------------------------- |
| 0–1  | `capability` | One `NvCapabilities` bit, BE `u16`. |
| 2    | `state`      | `NvCapabilityState`.                |
| 3    | `param1`     | Capability-specific byte.           |
| 4    | `param2`     | Capability-specific byte.           |
| 5–15 | padding      | OpenLogi writes `0x00`.             |

The wrapper waits for acknowledgement and discards its payload.

### `get_led_bin_info` (fn 6) [#get_led_bin_info-fn-6]

**Request:** `[zone_index, led_bin_index, 0x00]`.

| Byte | Field           | Notes          |
| ---- | --------------- | -------------- |
| 0    | `zone_index`    | Response echo. |
| 1    | `led_bin_index` | `LedBinIndex`. |
| 2–3  | `red`           | BE `u16`.      |
| 4–5  | `green`         | BE `u16`.      |
| 6–7  | `blue`          | BE `u16`.      |
| 8–9  | `white`         | BE `u16`.      |

### `get_sw_control` (fn 7) [#get_sw_control-fn-7]

**Request:** `[0x00, 0x00, 0x00]`.

| Byte | Field         | Notes                                      |
| ---- | ------------- | ------------------------------------------ |
| 0    | `control`     | `SwControl`: `0` firmware or `1` software. |
| 1    | `sync_events` | Zero is false. Any non-zero value is true. |

### `set_sw_control` (fn 8) [#set_sw_control-fn-8]

**Request:** `[control, sync_events, 0x00]`. `sync_events` is `0` or `1`.

The wrapper waits for acknowledgement and discards its payload. Sync-event selection is not stored in EEPROM.

### `get_effect_settings` (fn 9) [#get_effect_settings-fn-9]

**Request:** `[zone_index, source, 0x00]`.

| Byte | Field        | Notes                   |
| ---- | ------------ | ----------------------- |
| 0    | `zone_index` | Response echo.          |
| 1–3  | `color`      | Red, green, blue bytes. |
| 4–5  | `period`     | Milliseconds, BE `u16`. |
| 6    | `brightness` | Raw brightness byte.    |
| 7    | `param`      | Effect-specific byte.   |

This function is unavailable when `NO_GET_EFFECT_SETTINGS` is set.

### `clear_effect_settings` (fn 10) [#clear_effect_settings-fn-10]

**Request:** `[zone_index, 0x00, 0x00]`.

The wrapper waits for acknowledgement and discards its payload. The operation reverts the zone to its default mode.

### `set_cycling_direction` (fn 11) [#set_cycling_direction-fn-11]

**Request:** `[direction, 0x00, 0x00]`. `direction` is `0` for `Clockwise` or `1` for `Anticlockwise`.

The wrapper waits for acknowledgement and discards its payload.

### `get_current_color` (fn 12) [#get_current_color-fn-12]

**Request:** `[zone_index, 0x00, 0x00]`.

| Byte | Field | Notes                       |
| ---- | ----- | --------------------------- |
| 0    | /     | OpenLogi ignores this byte. |
| 1–3  | `Rgb` | Red, green, blue bytes.     |

### `synchronize_effect` (fn 13) [#synchronize_effect-fn-13]

**Request:** long, 16 bytes.

| Byte | Field         | Notes                     |
| ---- | ------------- | ------------------------- |
| 0    | `zone_index`  | `0xff` targets all zones. |
| 1    | reserved      | OpenLogi writes `0x00`.   |
| 2–3  | `drift_value` | Milliseconds, BE `i16`.   |
| 4–15 | padding       | OpenLogi writes `0x00`.   |

The function is valid while sync events are enabled. It is unavailable when `NO_SYNCHRONIZE_EFFECT` is set.

### `get_zone_effect` (fn 14) [#get_zone_effect-fn-14]

**Request:** `[zone_index, source, 0x00]`.

| Byte | Field               | Notes                      |
| ---- | ------------------- | -------------------------- |
| 0    | `zone_index`        | Response echo.             |
| 1    | `zone_effect_index` | Response effect ordinal.   |
| 2–11 | `params`            | Ten effect-specific bytes. |

This function is unavailable unless `GET_ZONE_EFFECT` is set.

### `set_led_bin_info` (fn 15) [#set_led_bin_info-fn-15]

**Request:** long, 16 bytes.

| Byte  | Field           | Notes                   |
| ----- | --------------- | ----------------------- |
| 0     | `zone_index`    | Target zone.            |
| 1     | `led_bin_index` | `LedBinIndex`.          |
| 2–3   | `red`           | BE `u16`.               |
| 4–5   | `green`         | BE `u16`.               |
| 6–7   | `blue`          | BE `u16`.               |
| 8–9   | `white`         | BE `u16`.               |
| 10–15 | padding         | OpenLogi writes `0x00`. |

The response has the `get_led_bin_info` layout. OpenLogi decodes and returns those echoed values.

### `SyncEffect` event (event fn 0) [#synceffect-event-event-fn-0]

| Byte | Field            | Notes                                      |
| ---- | ---------------- | ------------------------------------------ |
| 0    | `zone_index`     | `0xff` targets all zones.                  |
| 1–2  | `effect_counter` | Timing position in milliseconds, BE `u16`. |

## How OpenLogi uses it [#how-openlogi-uses-it]

The automatic keyboard-lighting path tries `0x8070` before `0x8071`, `0x8081`, and `0x8080`. It writes only a volatile fixed-colour effect. The agent reapplies the saved colour when the keyboard reconnects instead of writing EEPROM for each colour change.

Since v0.8.14, PR [#1758](https://github.com/AprilNEA/OpenLogi/pull/1758) finds `EffectId::FixedColor` in each enumerated zone. It writes the discovered effect index, not the `EffectId` value. A zone without that effect is skipped, and no target causes fallback to the next lighting path.

The writer currently limits normal enumeration to the first four reported zones. It also uses legacy effect index `1` for zones `0..4` when `zone_count` is zero or `get_zone_info` is rejected. These compatibility fallbacks can still write a wrong effect or omit zones.

The production lighting path uses only functions 0–3. It does not take this feature's software control or invoke NV configuration, synchronization, or other v1-only functions. The user-facing control is the [Keyboard lighting](/docs/features/keyboard/lighting) tab.

## Open questions [#open-questions]

### Version-gated functions [#version-gated-functions]

Options+ 2.8 gates functions 4, 5, 7, 8, and 13 on feature version 1 or later. G HUB static analysis also identifies a feature-version check. OpenLogi registers one wrapper from version 0 and has no runtime gate for these calls.

The version condition has static-analysis support but no hardware capture confirms it. The static-colour UI does not call these functions. Wrapper consumers must treat a version-0 error as possible.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::feature::color_led_effects::{
    ColorLedEffectsFeature, EffectId, Persistence, ZONE_EFFECT_PARAM_COUNT,
};
use hidpp::protocol::v20::Hidpp20Error;

async fn set_red(feature: &ColorLedEffectsFeature) -> Result<(), Hidpp20Error> {
    let mut params = [0_u8; ZONE_EFFECT_PARAM_COUNT];
    params[..3].copy_from_slice(&[0xff, 0x00, 0x00]);

    let device = feature.get_info().await?;
    for zone in 0..device.zone_count {
        let zone_info = feature.get_zone_info(zone).await?;
        for effect in 0..zone_info.effects_number {
            let info = feature.get_zone_effect_info(zone, effect).await?;
            if info.effect_id == EffectId::FixedColor {
                feature
                    .set_zone_effect(zone, effect, params, Persistence::Volatile)
                    .await?;
                break;
            }
        }
    }
    Ok(())
}
```


# 0x8071 · rgbEffects (/hidpp/features/x8071-rgb-effects)



`rgbEffects` is the per-cluster successor to `0x8070` `colorLedEffects`. A cluster has its own effect ordinals. Software must own the affected clusters before it sets a cluster effect.

> **Spec:** Logitech HID++ 2.0 — *rgbEffects* (v4). &#x2A;*Cross-checked:** Logi Options+ 2.8 (static analysis), Solaar, G HUB (static analysis). &#x2A;*Used by:** [Keyboard lighting](/docs/features/keyboard/lighting).

## Function reference [#function-reference]

### Methods [#methods]

| Function                        | HID++ fn | Signature                                                                                                                                           | Returns                                          |
| ------------------------------- | -------: | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| `get_device_info`               |        0 | `()`                                                                                                                                                | `RgbDeviceInfo`                                  |
| `get_cluster_info`              |        0 | `(cluster_index: u8)`                                                                                                                               | `RgbClusterInfo`                                 |
| `get_effect_info`               |        0 | `(cluster_index: u8, cluster_effect_index: u8)`                                                                                                     | `RgbEffectInfo`                                  |
| `get_onboard_effect_info`       |        0 | `(cluster_index: u8, cluster_effect_index: u8, slot: u8, slot_info_type: SlotInfoType)`                                                             | `[u8; ONBOARD_INFO_PARAM_COUNT]`                 |
| `set_rgb_cluster_effect`        |        1 | `(cluster_index: u8, cluster_effect_index: u8, params: [u8; CLUSTER_EFFECT_PARAM_COUNT], persistence: RgbPersistence, power_mode: PowerModeTarget)` | `()`                                             |
| `set_multi_led_cluster_pattern` |        2 | `(cluster_index: u8, pattern: u8)`                                                                                                                  | `()`                                             |
| `get_nv_config`                 |        3 | `(capability: RgbNvCapabilities)`                                                                                                                   | `RgbNvConfig`                                    |
| `set_nv_config`                 |        3 | `(config: &RgbNvConfig)`                                                                                                                            | `()`                                             |
| `get_led_bin_info`              |        4 | `(cluster_index: u8, led_bin_index: LedBinIndex, backup: bool)`                                                                                     | `[u8; LED_BIN_PARAM_COUNT]`                      |
| `set_led_bin_info`              |        4 | `(cluster_index: u8, led_bin_index: LedBinIndex, params: [u8; LED_BIN_PARAM_COUNT])`                                                                | `()`                                             |
| `get_sw_control`                |        5 | `()`                                                                                                                                                | `RgbSwControl`                                   |
| `set_sw_control`                |        5 | `(control: SwControlFlags, events: EventsNotificationFlags)`                                                                                        | `()`                                             |
| `set_effect_sync_correction`    |        6 | `(cluster_index: u8, drift_value: i16)`                                                                                                             | `()`                                             |
| `get_power_mode_config`         |        7 | `()`                                                                                                                                                | `RgbPowerModeConfig`                             |
| `set_power_mode_config`         |        7 | `(config: RgbPowerModeConfig)`                                                                                                                      | `()`                                             |
| `get_power_mode`                |        8 | `()`                                                                                                                                                | `RgbPowerMode`                                   |
| `set_power_mode`                |        8 | `(mode: RgbPowerMode)`                                                                                                                              | `()`                                             |
| `shutdown`                      |        9 | `()`                                                                                                                                                | `()`                                             |
| `listen`                        |        — | `(&self)`                                                                                                                                           | `async_channel::Receiver&lt;RgbEffectsEvent&gt;` |

Every operation except `listen` is an `async fn` that returns `Result&lt;_, Hidpp20Error&gt;`. `listen` is supplied by `EmittingFeature&lt;RgbEffectsEvent&gt;`.

### Types [#types]

`CLUSTER_EFFECT_PARAM_COUNT` is `10`. `ONBOARD_INFO_PARAM_COUNT` is `13`. `LED_BIN_PARAM_COUNT` is `8`. `ALL_CLUSTERS` and `ALL_EFFECTS` are both `0xff`.

#### Data structures [#data-structures]

| Type                 | Fields                                                                                                                                                                  | Description                                                                                                                             |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `RgbDeviceInfo`      | `cluster_count: u8`, `nv_capabilities: RgbNvCapabilities`, `ext_capabilities: RgbExtCapabilities`, `multicluster_effect_count: u8`                                      | Device-level information.                                                                                                               |
| `RgbClusterInfo`     | `cluster_index: u8`, `location: u16`, `effects_number: u8`, `display_persistency: DisplayPersistencyCapabilities`, `effect_persistency: bool`, `multiled_pattern: bool` | One cluster. `location` is a raw `locationEffect` value.                                                                                |
| `RgbEffectInfo`      | `cluster_index: u8`, `cluster_effect_index: u8`, `effect_id: u16`, `effect_capabilities: u16`, `effect_period: u16`                                                     | One cluster effect. Both identifiers are raw. A zero capability value uses Raptor compatibility defaults. A zero period is unavailable. |
| `RgbSwControl`       | `control: SwControlFlags`, `events: EventsNotificationFlags`                                                                                                            | Software-ownership and event-request flags.                                                                                             |
| `RgbNvConfig`        | `capability: RgbNvCapabilities`, `state: u8`, `params: [u8; 6]`                                                                                                         | One non-volatile configuration entry. The six parameters are capability-specific.                                                       |
| `RgbPowerModeConfig` | `flags: u16`, `no_activity_timeout_to_power_save: u16`, `no_activity_timeout_to_off: u16`                                                                               | Raw flags and two timeouts in seconds.                                                                                                  |

`RgbNvConfig` is non-exhaustive. Outside this crate, obtain it through `get_nv_config`, change the required fields, then pass it to `set_nv_config`.

#### Enumerations [#enumerations]

| Type                | Variants                                                                                                      | Values                                |
| ------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| `SlotInfoType`      | `SlotState`, `Defaults`, `Uuid0To10`, `Uuid11To16`, `EffectName0To10`, `EffectName11To21`, `EffectName21To31` | `0`–`6` in listed order.              |
| `RgbPowerMode`      | `FullRgb`, `PowerSave`, `PowerOff`                                                                            | `1`, `2`, `3`.                        |
| `PowerModeTarget`   | `FullPower`, `PowerSave`                                                                                      | `0`, `1`.                             |
| `LedBinIndex`       | `BinValueBrightness`, `BinValueColor`, `CalibrationFactors`, `Brightness`, `ColorimetricX`, `ColorimetricY`   | `0`–`5` in listed order.              |
| `ActivityEventType` | `NoActivityTimeoutReached`, `UserActivityDetected`, `Other(u8)`                                               | `0`, `1`, or an unmodelled raw value. |

#### Bit flags [#bit-flags]

| Type                             | Flag                       | Bit            | Meaning                                       |
| -------------------------------- | -------------------------- | -------------- | --------------------------------------------- |
| `RgbPersistence`                 | `VOLATILE`                 | `1 &lt;&lt; 0` | Apply the effect to RAM.                      |
| `RgbPersistence`                 | `NON_VOLATILE`             | `1 &lt;&lt; 1` | Store the effect in EEPROM.                   |
| `RgbExtCapabilities`             | `GET_ZONE_EFFECT`          | `1 &lt;&lt; 0` | Stored-effect `get_info` mode is supported.   |
| `RgbExtCapabilities`             | `SET_LED_BIN_INFO`         | `1 &lt;&lt; 2` | `set_led_bin_info` is supported.              |
| `RgbExtCapabilities`             | `MONOCHROME_ONLY`          | `1 &lt;&lt; 3` | Only monochrome effects are supported.        |
| `RgbExtCapabilities`             | `NO_EFFECT_SYNC`           | `1 &lt;&lt; 4` | Effect sync and correction are not supported. |
| `RgbExtCapabilities`             | `SHUTDOWN`                 | `1 &lt;&lt; 5` | `shutdown` is supported.                      |
| `RgbExtCapabilities`             | `CLUSTER_CHANGED_EVENT`    | `1 &lt;&lt; 6` | The cluster-change event is supported.        |
| `RgbNvCapabilities`              | `BOOT_UP_EFFECT`           | `1 &lt;&lt; 0` | Boot-up effect.                               |
| `RgbNvCapabilities`              | `DEMO`                     | `1 &lt;&lt; 1` | Demo mode.                                    |
| `RgbNvCapabilities`              | `USER_DEMO_MODE`           | `1 &lt;&lt; 2` | User demo mode.                               |
| `RgbNvCapabilities`              | `EVENTS_DISPLAY`           | `1 &lt;&lt; 3` | Events display.                               |
| `RgbNvCapabilities`              | `ACTIVE_DIMMING`           | `1 &lt;&lt; 4` | Active dimming.                               |
| `RgbNvCapabilities`              | `RAMP_DOWN_TO_OFF`         | `1 &lt;&lt; 5` | Ramp down to off.                             |
| `RgbNvCapabilities`              | `SHUTDOWN_EFFECT`          | `1 &lt;&lt; 6` | Shutdown effect.                              |
| `SwControlFlags`                 | `ALL_CLUSTERS`             | `1 &lt;&lt; 0` | Software controls RGB clusters.               |
| `SwControlFlags`                 | `POWER_MODES`              | `1 &lt;&lt; 1` | Software controls power modes.                |
| `EventsNotificationFlags`        | `EFFECTS_SYNC`             | `1 &lt;&lt; 0` | Request effect-sync events.                   |
| `EventsNotificationFlags`        | `USER_ACTIVITY`            | `1 &lt;&lt; 1` | Request user-activity events.                 |
| `EventsNotificationFlags`        | `NO_USER_ACTIVITY_TIMEOUT` | `1 &lt;&lt; 2` | Request no-activity-timeout events.           |
| `DisplayPersistencyCapabilities` | `ALWAYS_ON`                | `1 &lt;&lt; 0` | The cluster can persist always-on state.      |
| `DisplayPersistencyCapabilities` | `ALWAYS_OFF`               | `1 &lt;&lt; 1` | The cluster can persist always-off state.     |
| `DisplayPersistencyCapabilities` | `ON_THEN_OFF`              | `1 &lt;&lt; 2` | The cluster can persist on-then-off state.    |

The bitflag types retain unknown bits. The wrapper exposes capability flags but does not apply a generic before-send capability gate.

### Events [#events]

`RgbEffectsFeature` implements `EmittingFeature&lt;RgbEffectsEvent&gt;`.

| Variant          | Fields                                                                                                                                                                  | Description                                                                                                                                                                                                                                  |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `EffectSync`     | `cluster_index: u8`, `effect_counter: u16`                                                                                                                              | A periodic effect reached a sync point. `0xff` targets all clusters. The counter is in milliseconds.                                                                                                                                         |
| `UserActivity`   | `ActivityEventType`                                                                                                                                                     | User activity began or its absence timed out.                                                                                                                                                                                                |
| `ClusterChanged` | `cluster_index: u8`, `cluster_effect_index: u8`, `params: [u8; CLUSTER_EFFECT_PARAM_COUNT]`, `persistence: RgbPersistence`, `power_mode: Option&lt;PowerModeTarget&gt;` | A cluster effect changed. `power_mode` is `None` for an unmodelled two-bit value.                                                                                                                                                            |
| `NvEffectSync`   | `payload: [u8; 16]`                                                                                                                                                     | The stored NV effect configuration may have changed. The payload is raw and its layout is unknown. A short report is zero-extended to 16 bytes. Treat the event as a hint to re-read every NV capability you depend on with `get_nv_config`. |

## Wire format [#wire-format]

Offsets are payload bytes. Short requests carry three bytes. Long requests carry 16 bytes. Every multi-byte value below is **BE**.

### `get_device_info` (fn 0) [#get_device_info-fn-0]

**Request:** `[0xff, 0xff, 0x00]` for all clusters, all effects, and general information.

| Byte | Field                       | Notes                                  |
| ---- | --------------------------- | -------------------------------------- |
| 0–1  | /                           | OpenLogi ignores these response bytes. |
| 2    | `cluster_count`             | Number of clusters.                    |
| 3–4  | `nv_capabilities`           | `RgbNvCapabilities`, BE `u16`.         |
| 5–6  | `ext_capabilities`          | `RgbExtCapabilities`, BE `u16`.        |
| 7    | `multicluster_effect_count` | Number of multi-cluster effects.       |

### `get_cluster_info` (fn 0) [#get_cluster_info-fn-0]

**Request:** `[cluster_index, 0xff, 0x00]` for all effects and general information.

| Byte | Field                 | Notes                                            |
| ---- | --------------------- | ------------------------------------------------ |
| 0    | `cluster_index`       | Echo. OpenLogi requires it to match the request. |
| 1    | /                     | OpenLogi ignores this response byte.             |
| 2–3  | `location`            | Raw location identifier, BE `u16`.               |
| 4    | `effects_number`      | Number of effects in this cluster.               |
| 5    | `display_persistency` | `DisplayPersistencyCapabilities`.                |
| 6    | `effect_persistency`  | Non-zero means EEPROM effect persistence.        |
| 7    | `multiled_pattern`    | Non-zero means multi-LED patterns.               |

OpenLogi requires a long reply. It rejects a short reply or a mismatched index with `UnsupportedResponse`.

### `get_effect_info` (fn 0) [#get_effect_info-fn-0]

**Request:** `[cluster_index, cluster_effect_index, 0x00]` for general information.

| Byte | Field                  | Notes                                                  |
| ---- | ---------------------- | ------------------------------------------------------ |
| 0    | `cluster_index`        | Response echo.                                         |
| 1    | `cluster_effect_index` | Response echo.                                         |
| 2–3  | `effect_id`            | Raw effect identifier, BE `u16`.                       |
| 4–5  | `effect_capabilities`  | Effect-specific bitmask, BE `u16`.                     |
| 6–7  | `effect_period`        | Period in milliseconds, BE `u16`; zero is unavailable. |

### `get_onboard_effect_info` (fn 0) [#get_onboard_effect_info-fn-0]

**Request:** long, `[cluster_index, cluster_effect_index, 0x01, slot, slot_info_type, 0x00…]`.

| Byte | Field        | Notes                                                                    |
| ---- | ------------ | ------------------------------------------------------------------------ |
| 0–2  | /            | OpenLogi ignores these response bytes.                                   |
| 3–15 | return value | Raw `[u8; ONBOARD_INFO_PARAM_COUNT]`. Meaning depends on `SlotInfoType`. |

### `set_rgb_cluster_effect` (fn 1) [#set_rgb_cluster_effect-fn-1]

**Request:** long, 16 bytes.

| Byte  | Field                  | Notes                                                           |
| ----- | ---------------------- | --------------------------------------------------------------- |
| 0     | `cluster_index`        | Target cluster.                                                 |
| 1     | `cluster_effect_index` | Effect ordinal in that cluster.                                 |
| 2–11  | `params`               | Ten effect-specific bytes.                                      |
| 12    | flags                  | `RgbPersistence` in bits 0–1. `PowerModeTarget` is in bits 2–3. |
| 13–15 | padding                | OpenLogi writes `0x00`.                                         |

The wrapper waits for native write completion and then the acknowledgement. Software must first set `SwControlFlags::ALL_CLUSTERS`.

### `set_multi_led_cluster_pattern` (fn 2) [#set_multi_led_cluster_pattern-fn-2]

**Request:** `[cluster_index, pattern, 0x00]`.

The wrapper waits for acknowledgement and discards its payload.

### `get_nv_config` (fn 3) [#get_nv_config-fn-3]

**Request:** `[0x00, cap_hi, cap_lo]`. Byte 0 selects read. The capability is BE `u16`.

| Byte | Field        | Notes                                                      |
| ---- | ------------ | ---------------------------------------------------------- |
| 0    | selector     | OpenLogi ignores this response byte.                       |
| 1–2  | `capability` | `RgbNvCapabilities`, BE `u16`.                             |
| 3    | `state`      | Capability-specific state byte.                            |
| 4–9  | `params`     | Six capability-specific bytes. OpenLogi preserves all six. |

OpenLogi requires a long reply. It does not name the six parameters because their meanings vary by capability.

### `set_nv_config` (fn 3) [#set_nv_config-fn-3]

**Request:** long, 16 bytes.

| Byte  | Field        | Notes                             |
| ----- | ------------ | --------------------------------- |
| 0     | selector     | OpenLogi writes `0x01` for write. |
| 1–2   | `capability` | `RgbNvCapabilities`, BE `u16`.    |
| 3     | `state`      | Capability-specific state byte.   |
| 4–9   | `params`     | All six bytes from `RgbNvConfig`. |
| 10–15 | padding      | OpenLogi writes `0x00`.           |

Since v0.8.14, OpenLogi accepts only a read `RgbNvConfig` outside the crate. This preserves every parameter during a read-modify-write.

### `get_led_bin_info` (fn 4) [#get_led_bin_info-fn-4]

**Request:** `[selector, cluster_index, led_bin_index]`. The selector is `0x00` for active data or `0x02` for backup data.

| Byte | Field        | Notes                                  |
| ---- | ------------ | -------------------------------------- |
| 0–2  | /            | OpenLogi ignores these response bytes. |
| 3–10 | return value | Raw `[u8; LED_BIN_PARAM_COUNT]`.       |

### `set_led_bin_info` (fn 4) [#set_led_bin_info-fn-4]

**Request:** long, `[0x01, cluster_index, led_bin_index, params[0..8], 0x00…]`.

The wrapper waits for acknowledgement and discards its payload.

### `get_sw_control` (fn 5) [#get_sw_control-fn-5]

**Request:** `[0x00, 0x00, 0x00]`. Byte 0 selects read.

| Byte | Field     | Notes                                |
| ---- | --------- | ------------------------------------ |
| 0    | selector  | OpenLogi ignores this response byte. |
| 1    | `control` | `SwControlFlags`.                    |
| 2    | `events`  | `EventsNotificationFlags`.           |

### `set_sw_control` (fn 5) [#set_sw_control-fn-5]

**Request:** `[0x01, control.bits(), events.bits()]`. Byte 0 selects write.

The acknowledgement must echo all three request bytes. OpenLogi waits for native write completion before the bounded acknowledgement wait.

### `set_effect_sync_correction` (fn 6) [#set_effect_sync_correction-fn-6]

**Request:** long, `[cluster_index, 0x00, drift_hi, drift_lo, 0x00…]`.

| Byte | Field           | Notes                        |
| ---- | --------------- | ---------------------------- |
| 0    | `cluster_index` | `0xff` targets all clusters. |
| 1    | reserved        | OpenLogi writes `0x00`.      |
| 2–3  | `drift_value`   | Milliseconds, BE `i16`.      |
| 4–15 | padding         | OpenLogi writes `0x00`.      |

### `get_power_mode_config` (fn 7) [#get_power_mode_config-fn-7]

**Request:** `[0x00, 0x00, 0x00]`. Byte 0 selects read.

| Byte | Field                               | Notes                                |
| ---- | ----------------------------------- | ------------------------------------ |
| 0    | selector                            | OpenLogi ignores this response byte. |
| 1–2  | `flags`                             | Raw flags, BE `u16`.                 |
| 3–4  | `no_activity_timeout_to_power_save` | Seconds, BE `u16`.                   |
| 5–6  | `no_activity_timeout_to_off`        | Seconds, BE `u16`.                   |

### `set_power_mode_config` (fn 7) [#set_power_mode_config-fn-7]

**Request:** long, `[0x01, flags_hi, flags_lo, psave_hi, psave_lo, off_hi, off_lo, 0x00…]`.

The flags and both timeouts are BE `u16` values. The wrapper waits for acknowledgement and discards its payload.

### `get_power_mode` (fn 8) [#get_power_mode-fn-8]

**Request:** `[0x00, 0x00, 0x00]`. Byte 0 selects read.

| Byte | Field          | Notes                                        |
| ---- | -------------- | -------------------------------------------- |
| 0    | selector       | OpenLogi ignores this response byte.         |
| 1    | `RgbPowerMode` | `1` full RGB, `2` power-save, `3` power-off. |

### `set_power_mode` (fn 8) [#set_power_mode-fn-8]

**Request:** `[0x01, mode, 0x00]`. Byte 0 selects write.

Software must first set `SwControlFlags::POWER_MODES`. The wrapper waits for acknowledgement and discards its payload.

### `shutdown` (fn 9) [#shutdown-fn-9]

**Request:** `[0x00, 0x00, 0x00]`.

The wrapper waits for acknowledgement and discards its payload. Devices advertise this operation with `RgbExtCapabilities::SHUTDOWN`.

### Events (unsolicited) [#events-unsolicited]

| Sub-ID | Event            | Byte layout                                                                                                                                                       |
| -----: | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|      0 | `EffectSync`     | Byte 0 is `cluster_index`. Bytes 1–2 are `effect_counter`, BE `u16` milliseconds. `0xff` targets all clusters.                                                    |
|      1 | `UserActivity`   | Byte 0 is `ActivityEventType`.                                                                                                                                    |
|      2 | `ClusterChanged` | Byte 0 is `cluster_index`. Byte 1 is `cluster_effect_index`. Bytes 2–11 are parameters. Byte 12 stores persistence in bits 0–1 and target power mode in bits 2–3. |
|      3 | `NvEffectSync`   | Options+ 2.8 has a fourth notification handler. Its payload layout is unverified. OpenLogi publishes bytes 0–15 as raw bytes in `payload`.                        |

## How OpenLogi uses it [#how-openlogi-uses-it]

The automatic lighting path uses `0x8071` after `0x8070` cannot supply a fixed colour. It enumerates every cluster and each cluster's effects before a write. It claims control only when every cluster has static effect ID `1`.

For `0x8081` per-key output, OpenLogi uses the same claim when this effect engine exists. It first applies static colour to every supporting cluster, sends the frame, and repeats the frame after a fresh claim. A partial engine falls back before it claims software control.

Since v0.8.14, PR [#1769](https://github.com/AprilNEA/OpenLogi/pull/1769) records a successful claim as an agent-session lease. PR [#1770](https://github.com/AprilNEA/OpenLogi/pull/1770) closes the release races. On an orderly agent exit, OpenLogi refuses new lighting writes, waits up to 2 seconds for admitted writes, then releases each recorded device within a shared 2-second budget.

The release clears only `ALL_CLUSTERS`. It preserves the saved event flags and any other saved control flags. A disconnected device or failed release is logged and does not delay exit. A standalone diagnostic write deliberately leaves the volatile colour and its control claim in place.

The [Keyboard lighting](/docs/features/keyboard/lighting) tab provides one static colour and brightness. It does not expose animated effects, per-key editing, power-mode configuration, or NV configuration. Since v0.8.14, PR [#1753](https://github.com/AprilNEA/OpenLogi/pull/1753) preserves all six NV parameters when a wrapper consumer edits a read configuration.

## Open questions [#open-questions]

### Fourth notification [#fourth-notification]

Options+ 2.8 static analysis identifies sub-ID `3` as an NV-effect synchronization notification. It does not recover the payload schema. OpenLogi decodes sub-IDs `0`–`2` field by field. Since PR [#1951](https://github.com/AprilNEA/OpenLogi/pull/1951), it publishes sub-ID `3` as `NvEffectSync` with the raw 16-byte payload.

The payload meaning is still an open question. The event needs a hardware capture, a public specification, or stronger analysis before OpenLogi can publish typed fields. This page deliberately documents no byte meanings for it.

### Rate restrictions [#rate-restrictions]

G HUB static analysis lists a rate-restrictions operation for this feature. The analysis does not establish its function index or payload layout. OpenLogi v0.8.15 does not expose that operation.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::feature::rgb_effects::{RgbEffectsFeature, RgbNvCapabilities};
use hidpp::protocol::v20::Hidpp20Error;

async fn enable_boot_effect(feature: &RgbEffectsFeature) -> Result<(), Hidpp20Error> {
    let device = feature.get_device_info().await?;
    if !device
        .nv_capabilities
        .contains(RgbNvCapabilities::BOOT_UP_EFFECT)
    {
        return Ok(());
    }

    let mut config = feature
        .get_nv_config(RgbNvCapabilities::BOOT_UP_EFFECT)
        .await?;
    config.state = 1;
    feature.set_nv_config(&config).await
}
```


# 0x8080 · perKeyLighting (/hidpp/features/x8080-per-key-lighting)



`0x8080` is the earlier per-key RGB feature for keyboards with addressable LEDs.
OpenLogi drives it as a raw HID++ fallback when newer lighting paths are unavailable.

> **Spec:** No public Logitech specification. Layout reverse-engineered from Logi Options+ 2.8 (static analysis). &#x2A;*Used by:** [Keyboard lighting](/docs/features/keyboard/lighting).

## Function reference [#function-reference]

OpenLogi has no typed `PerKeyLightingFeature` wrapper for `0x8080`.
Its device writer resolves the runtime feature index and sends the two raw commands below.

### Methods [#methods]

| Function       | HID++ fn | Signature                                                                      | Returns                                                       |
| -------------- | -------: | ------------------------------------------------------------------------------ | ------------------------------------------------------------- |
| `setKeyColors` |        3 | one 64-byte `0x12` report with at most 14 `(key_id, red, green, blue)` entries | Raw write length; feature errors are collected before commit. |
| `flushLeds`    |        5 | one normal long request with a 16-byte zero payload                            | `Result<(), WriteError>` after the HID++ response.            |

### Types [#types]

The raw writer has no public `0x8080` type, enum, or per-key state model.
It accepts one `u8` value for each RGB channel.

### Events [#events]

`0x8080` has no feature-specific OpenLogi event type.
The raw writer listens only for HID++ feature errors that match its streamed frames.

## Wire format [#wire-format]

`setKeyColors` uses the 64-byte HID++ very-long report (`0x12`).
`flushLeds` uses a normal HID++ long request and waits for its response.

### `setKeyColors` (fn 3) [#setkeycolors-fn-3]

OpenLogi sends 17 frames for its `0x00..=0xe8` HID usage range.
Sixteen frames contain 14 entries, and the final frame contains nine entries.

| Byte(s) | Field                    | Value or encoding                       | Notes                                                                    |
| ------- | ------------------------ | --------------------------------------- | ------------------------------------------------------------------------ |
| 0       | report ID                | `0x12`                                  | HID++ very-long report.                                                  |
| 1       | device index             | runtime value                           | The device route supplies this value.                                    |
| 2       | feature index            | runtime value                           | OpenLogi resolves the index for `0x8080`.                                |
| 3       | function and software ID | high nibble `3`; low nibble `0xa`       | Function 3 with OpenLogi software ID `0xa`.                              |
| 4–5     | `key_type`               | BE `0x0001`                             | OpenLogi writes this fixed value. Options+ 2.8                           |
| 6–7     | `entry_count`            | BE `u16`, `1..=14`                      | The count equals entries in this frame. Options+ 2.8                     |
| 8–63    | entries                  | up to 14 × `(key_id, red, green, blue)` | `key_id` is one byte, followed by three 8-bit RGB channels. Options+ 2.8 |

Unused entry bytes remain zero. They are outside `entry_count` and must not be treated as entries.
Since v0.8.14, OpenLogi writes the final count as BE `0x0009`, not `0x000e`.

### `flushLeds` (fn 5) [#flushleds-fn-5]

This command commits the colours staged by prior function-3 reports.
OpenLogi sends it only after the streamed reports pass its error check.

| Byte(s) | Field   | Value      | Notes                                                                       |
| ------- | ------- | ---------- | --------------------------------------------------------------------------- |
| 0–15    | payload | all `0x00` | OpenLogi sends a normal long request with function 5 and software ID `0xa`. |

OpenLogi waits for the normal HID++ reply to this request.
It does not assign semantics to the reply payload.

## How OpenLogi uses it [#how-openlogi-uses-it]

`LightingMethod::Auto` tries `0x8070`, then `0x8071`, then `0x8081`, and finally `0x8080`.
`LightingMethod::PerKey` selects this raw path for diagnostics.

The writer resolves `0x8080` through the Root feature at run time.
It has no typed feature wrapper or feature-version gate.

The writer streams every HID usage from `0x00` through `0xe8` with one requested RGB colour.
It checks that every raw report wrote all 64 bytes.

The protocol response report ID for a very-long function-3 request is not established.
OpenLogi therefore records matching feature errors while streaming each frame.

A Root `ping` follows the last frame and acts as an ordered response fence.
If the device rejected any frame, OpenLogi returns that error and does not send `flushLeds`.

The final function-5 request uses the normal HID++ request path and waits for its reply.
This check shipped in v0.8.14 through PR [#1764](https://github.com/AprilNEA/OpenLogi/pull/1764).

OpenLogi applies a solid colour only.
It does not store or expose a map of independent key colours through the keyboard-lighting product surface.

## Open questions [#open-questions]

Options+ 2.8 calls function 3 synchronously, but static analysis does not establish its response report ID.
OpenLogi cannot wait for a per-frame success reply until a hardware capture identifies that response.

The current error listener and Root-ping fence prevent a known rejected frame from being committed.
A hardware capture must verify this behavior on `0x8080` keyboards.

## Usage (Rust) [#usage-rust]

```rust
use openlogi_hid::{
    set_keyboard_color_with, DeviceRoute, LightingMethod, WriteError,
};

async fn paint_with_legacy_per_key(
    route: &DeviceRoute,
) -> Result<(), WriteError> {
    set_keyboard_color_with(
        route,
        LightingMethod::PerKey,
        0x20,
        0x80,
        0xff,
    )
    .await
}
```


# 0x8081 · perKeyLighting (/hidpp/features/x8081-per-key-lighting)



`0x8081` is the zone-addressed successor to `0x8080` for keyboards with addressable LEDs.
It stages zone colours, then commits the complete frame with `frame_end`.

> **Spec:** No public Logitech specification. Layout reverse-engineered from Logi Options+ 2.8 (static analysis). &#x2A;*Cross-checked:** Solaar. &#x2A;*Used by:** [Keyboard lighting](/docs/features/keyboard/lighting).

## Function reference [#function-reference]

`PerKeyLightingFeature` implements `0x8081` from feature version 0.
All methods are `async` and return `Result<…, Hidpp20Error>`.

### Methods [#methods]

| Function                               | HID++ fn | Signature                                                                           | Returns    |
| -------------------------------------- | -------: | ----------------------------------------------------------------------------------- | ---------- |
| `get_rgb_zone_presence`                |        0 | `(page: ZonePresencePage)`                                                          | `[u8; 14]` |
| `set_individual_rgb_zones`             |        1 | `(zones: &[RgbZone])`                                                               | `()`       |
| `set_consecutive_rgb_zones`            |        2 | `(first_zone_id: u8, colors: [Rgb; 5])`                                             | `()`       |
| `set_consecutive_rgb_zones_delta_5bit` |        3 | `(first_zone_id: u8, packed: [u8; 15])`                                             | `()`       |
| `set_consecutive_rgb_zones_delta_4bit` |        4 | `(first_zone_id: u8, packed: [u8; 15])`                                             | `()`       |
| `set_range_rgb_zones`                  |        5 | `(ranges: &[RgbZoneRange])`                                                         | `()`       |
| `set_rgb_zones_single_value`           |        6 | `(color: Rgb, zone_ids: &[u8])`                                                     | `()`       |
| `frame_end`                            |        7 | `(persistence: FramePersistence, current_frame: u16, frames_till_next_change: u16)` | `()`       |

The individual, range, and single-value setters transmit at most 4, 3, and 13 slice entries.
They ignore entries after those limits.

### Types [#types]

#### `Rgb` [#rgb]

`Rgb` holds one 8-bit value for each colour channel.

| Field   | Type | Description    |
| ------- | ---- | -------------- |
| `red`   | `u8` | Red channel.   |
| `green` | `u8` | Green channel. |
| `blue`  | `u8` | Blue channel.  |

#### `RgbZone` [#rgbzone]

`RgbZone` assigns one `Rgb` value to one zone.

| Field     | Type  | Description                                                           |
| --------- | ----- | --------------------------------------------------------------------- |
| `zone_id` | `u8`  | Zone ID. IDs `0x00` and `0xff` are rejected as end-of-list sentinels. |
| `color`   | `Rgb` | Colour to stage.                                                      |

#### `RgbZoneRange` [#rgbzonerange]

`RgbZoneRange` assigns one colour to an inclusive range.

| Field           | Type  | Description                                        |
| --------------- | ----- | -------------------------------------------------- |
| `first_zone_id` | `u8`  | First included zone ID. Sentinel IDs are rejected. |
| `last_zone_id`  | `u8`  | Last included zone ID. Sentinel IDs are rejected.  |
| `color`         | `Rgb` | Colour to stage across the range.                  |

#### `ZonePresencePage` [#zonepresencepage]

`ZonePresencePage` selects one 112-bit presence bitmap page.
This non-exhaustive enum has these known variants.

| Variant         | Value | Description           |
| --------------- | ----: | --------------------- |
| `Zones0To111`   |   `0` | Zone IDs `0..=111`.   |
| `Zones112To223` |   `1` | Zone IDs `112..=223`. |
| `Zones224To255` |   `2` | Zone IDs `224..=255`. |

#### `FramePersistence` [#framepersistence]

`FramePersistence` selects the storage target for `frame_end`.
This non-exhaustive enum has these known variants.

| Variant                  | Value | Description                                    |
| ------------------------ | ----: | ---------------------------------------------- |
| `Volatile`               |   `0` | Apply the frame in RAM only.                   |
| `VolatileAndNonVolatile` |   `1` | Apply the frame in RAM and store it in EEPROM. |

### Events [#events]

OpenLogi defines no `0x8081` feature event type.
`frame_end` is a request, not a device event.

## Wire format [#wire-format]

Function 0 sends a three-byte short payload.
Functions 1 through 7 send 16-byte long payloads.

Setter responses repeat accepted request parameters.
OpenLogi requires a long response and checks the zone-ID echo for functions 1 through 6.

### `get_rgb_zone_presence` (fn 0) [#get_rgb_zone_presence-fn-0]

Request payload: `[0x00, page, 0x00]`.
Byte 0 selects zone-presence information, byte 1 selects the page, and byte 2 is zero padding.

| Byte(s) | Field                | Encoding                             | Notes                                            |
| ------- | -------------------- | ------------------------------------ | ------------------------------------------------ |
| 0–1     | response prefix      | returned by the device               | OpenLogi does not model these bytes.             |
| 2–15    | zone presence bitmap | 14 bytes; LSB-first within each byte | Bit `i` names zone `page_base + i`. Options+ 2.8 |

### `set_individual_rgb_zones` (fn 1) [#set_individual_rgb_zones-fn-1]

The request has four four-byte slots. OpenLogi zeroes unused slots.

| Byte(s)        | Field     | Encoding | Notes                                               |
| -------------- | --------- | -------- | --------------------------------------------------- |
| `slot × 4`     | `zone_id` | one `u8` | Slots `0..=3`; sent IDs cannot be `0x00` or `0xff`. |
| `slot × 4 + 1` | `red`     | one `u8` | /                                                   |
| `slot × 4 + 2` | `green`   | one `u8` | /                                                   |
| `slot × 4 + 3` | `blue`    | one `u8` | /                                                   |

The response must be long.
OpenLogi verifies every sent `zone_id` at offsets 0, 4, 8, and 12.

### `set_consecutive_rgb_zones` (fn 2) [#set_consecutive_rgb_zones-fn-2]

The request sets exactly five consecutive zones in full 24-bit RGB.

| Byte(s)     | Field              | Encoding | Notes                      |
| ----------- | ------------------ | -------- | -------------------------- |
| 0           | `first_zone_id`    | one `u8` | Sentinel IDs are rejected. |
| `1 + 3 × i` | red for zone `i`   | one `u8` | `i` is `0..=4`.            |
| `2 + 3 × i` | green for zone `i` | one `u8` | /                          |
| `3 + 3 × i` | blue for zone `i`  | one `u8` | /                          |

The response must be long.
OpenLogi verifies the echoed `first_zone_id` at byte 0.

### `set_consecutive_rgb_zones_delta_5bit` (fn 3) [#set_consecutive_rgb_zones_delta_5bit-fn-3]

The request encodes eight consecutive zones as signed 5-bit channel deltas.
The deltas are packed MSB-first by zone and then by channel.

| Byte(s) | Field           | Encoding | Notes                                                                                |
| ------- | --------------- | -------- | ------------------------------------------------------------------------------------ |
| 0       | `first_zone_id` | one `u8` | Sentinel IDs are rejected.                                                           |
| 1–15    | `packed`        | 15 bytes | Eight groups of three signed 5-bit deltas. OpenLogi transmits these bytes unchanged. |

The response must be long.
OpenLogi verifies the echoed `first_zone_id` at byte 0.

### `set_consecutive_rgb_zones_delta_4bit` (fn 4) [#set_consecutive_rgb_zones_delta_4bit-fn-4]

The request encodes ten consecutive zones as signed 4-bit channel deltas.
The deltas use the high nibble before the low nibble.

| Byte(s) | Field           | Encoding | Notes                                                                              |
| ------- | --------------- | -------- | ---------------------------------------------------------------------------------- |
| 0       | `first_zone_id` | one `u8` | Sentinel IDs are rejected.                                                         |
| 1–15    | `packed`        | 15 bytes | Ten groups of three signed 4-bit deltas. OpenLogi transmits these bytes unchanged. |

The response must be long.
OpenLogi verifies the echoed `first_zone_id` at byte 0.

### `set_range_rgb_zones` (fn 5) [#set_range_rgb_zones-fn-5]

The request has three five-byte range slots. OpenLogi zeroes unused slots.

| Byte(s)        | Field           | Encoding | Notes                                           |
| -------------- | --------------- | -------- | ----------------------------------------------- |
| `slot × 5`     | `first_zone_id` | one `u8` | Slots `0..=2`; sentinel IDs are rejected.       |
| `slot × 5 + 1` | `last_zone_id`  | one `u8` | Inclusive range end; sentinel IDs are rejected. |
| `slot × 5 + 2` | `red`           | one `u8` | /                                               |
| `slot × 5 + 3` | `green`         | one `u8` | /                                               |
| `slot × 5 + 4` | `blue`          | one `u8` | /                                               |
| 15             | padding         | `0x00`   | The three slots occupy bytes 0–14.              |

The response must be long.
OpenLogi verifies only the first sent `first_zone_id` at byte 0.

### `set_rgb_zones_single_value` (fn 6) [#set_rgb_zones_single_value-fn-6]

The request applies one colour to up to 13 selected zones.
OpenLogi zeroes unused zone-ID bytes.

| Byte(s) | Field      | Encoding        | Notes                                |
| ------- | ---------- | --------------- | ------------------------------------ |
| 0       | `red`      | one `u8`        | /                                    |
| 1       | `green`    | one `u8`        | /                                    |
| 2       | `blue`     | one `u8`        | /                                    |
| 3–15    | `zone_ids` | up to 13 × `u8` | Sent IDs cannot be `0x00` or `0xff`. |

The response must be long.
OpenLogi verifies the first sent `zone_id` at byte 3.

### `frame_end` (fn 7) [#frame_end-fn-7]

`frame_end` commits all staged zone updates.
Set both counters to zero for a one-shot update.

| Byte(s) | Field                     | Encoding   | Notes                               |
| ------- | ------------------------- | ---------- | ----------------------------------- |
| 0       | `persistence`             | `0` or `1` | `FramePersistence` value.           |
| 1–2     | `current_frame`           | BE `u16`   | Frame number.                       |
| 3–4     | `frames_till_next_change` | BE `u16`   | Frame count before the next change. |
| 5–15    | padding                   | `0x00`     | OpenLogi writes zeroes.             |

OpenLogi waits for a matching response to function 7.
It does not inspect the response payload.

## How OpenLogi uses it [#how-openlogi-uses-it]

`LightingMethod::Auto` reaches `0x8081` after `0x8070` and `0x8071` fail to provide a complete solid-colour path.
`LightingMethod::PerKeyV2` selects this path directly.

OpenLogi queries all three presence pages before any write.
It skips sentinel IDs `0x00` and `0xff`, plus page-three padding above `0xff`.

The device writer sends one function-6 request per 13 present zones.
It writes the same colour to every present zone and then calls function 7 with `Volatile`, `0`, and `0`.

Any failed subwrite prevents `frame_end`.
If `0x8081` is absent or reports no usable zones, Auto falls back to raw `0x8080`.

When `0x8071` is present, OpenLogi reads the software-control state before it mutates the device.
It claims `ALL_CLUSTERS`, paints available Static effects as a base, sends the `0x8081` frame, and replays it once after a fresh claim.

A failed write restores the earlier software-control flags.
A successful agent write retains its lease and returns control when the agent exits.

A device with `0x8081` but no `0x8071` receives the frame without a software-control claim.
The user-facing setting is [Keyboard lighting](/docs/features/keyboard/lighting).

The typed wrapper exposes all six zone setters.
The production device path uses only `set_rgb_zones_single_value`, so it provides a whole-keyboard solid colour rather than a user-visible per-zone map.

## Open questions [#open-questions]

No hardware capture verifies which devices require `0x8071` software control and Static effects for visible `0x8081` paint.
Solaar treats per-key paint as a Static sub-mode, while OpenLogi uses that sequence only when `0x8071` exists.

Options+ 2.8 static analysis supports zone echoes for functions 1 through 6.
The full multi-range reply contract remains unverified, so OpenLogi checks only the first range start for function 5.

Options+ 2.8 does not inspect the function-7 response payload.
OpenLogi likewise accepts any matching function-7 response, pending hardware verification.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::per_key_lighting::{
        FramePersistence, PerKeyLightingFeature, Rgb, RgbZone, ZonePresencePage,
    },
    protocol::v20::Hidpp20Error,
};

async fn stage_two_zones(device: &mut Device) -> Result<(), Hidpp20Error> {
    device.enumerate_features().await?;
    let Some(feature) = device.get_feature::<PerKeyLightingFeature>() else {
        return Ok(());
    };

    let _presence = feature
        .get_rgb_zone_presence(ZonePresencePage::Zones0To111)
        .await?;

    feature
        .set_individual_rgb_zones(&[
            RgbZone {
                zone_id: 1,
                color: Rgb {
                    red: 0xff,
                    green: 0x00,
                    blue: 0x00,
                },
            },
            RgbZone {
                zone_id: 2,
                color: Rgb {
                    red: 0x00,
                    green: 0x00,
                    blue: 0xff,
                },
            },
        ])
        .await?;

    feature.frame_end(FramePersistence::Volatile, 0, 0).await
}
```


# 0x8090 · modeStatus (/hidpp/features/x8090-mode-status)



The `modeStatus` feature reads two mode-status bytes and selectively updates their bits. In v1, bit 0 of `status0` selects performance mode; an unset bit selects endurance mode. OpenLogi v0.8.15 provides a typed protocol wrapper, but no device-layer path uses it.

> **Spec:** Logitech HID++ 2.0 — *modeStatus* (v1). &#x2A;*Cross-checked:** G HUB (static analysis). &#x2A;*Used by:** Not used by OpenLogi.

## Function reference [#function-reference]

`ModeStatusFeature` is available when feature enumeration finds `0x8090` at version 1 or later.

### Methods [#methods]

| Function               | HID++ fn | Signature                    | Returns                  |
| ---------------------- | -------: | ---------------------------- | ------------------------ |
| `get_mode_status`      |        0 | `()`                         | `ModeStatus`             |
| `set_mode_status`      |        1 | `(change: ModeStatusChange)` | `()`                     |
| `set_performance_mode` |        1 | `(enabled: bool)`            | `()`                     |
| `get_device_config`    |        2 | `()`                         | `ModeStatusCapabilities` |

All methods are `async` and return `Result<…, Hidpp20Error>`.

### Types [#types]

#### `ModeStatus` [#modestatus]

| Field     | Type          | Description                                                                       |
| --------- | ------------- | --------------------------------------------------------------------------------- |
| `status0` | `ModeStatus0` | Primary status bits. Unknown bits are retained.                                   |
| `status1` | `u8`          | Secondary status byte. v1 reserves this byte, and OpenLogi preserves it on reads. |

#### `ModeStatusChange` [#modestatuschange]

| Field           | Type          | Description                      |
| --------------- | ------------- | -------------------------------- |
| `status0`       | `ModeStatus0` | Requested primary status bits.   |
| `status1`       | `u8`          | Requested secondary status byte. |
| `changed_mask0` | `ModeStatus0` | Primary bits to update.          |
| `changed_mask1` | `u8`          | Secondary bits to update.        |

#### `ModeStatus0` [#modestatus0]

| Variant       | Value | Description                                            |
| ------------- | ----- | ------------------------------------------------------ |
| `PERFORMANCE` | bit 0 | Performance mode. An unset bit selects endurance mode. |

#### `ModeStatusCapabilities` [#modestatuscapabilities]

| Variant           | Value | Description                                |
| ----------------- | ----- | ------------------------------------------ |
| `HARDWARE_SWITCH` | bit 0 | A hardware switch can change the mode bit. |
| `SOFTWARE_SWITCH` | bit 1 | Software can change the mode bit.          |

### Events [#events]

`ModeStatusFeature` has no event listener or event decoder in OpenLogi v0.8.15.

## Wire format [#wire-format]

Short requests contain 3 zero bytes. OpenLogi expands short responses to its 16-byte payload buffer before decoding them.

### `get_mode_status` (fn 0) [#get_mode_status-fn-0]

Request: `[0x00, 0x00, 0x00]`.

| Byte | Field     | Notes                                                             |
| ---- | --------- | ----------------------------------------------------------------- |
| 0    | `status0` | `ModeStatus0`; bit 0 is `PERFORMANCE`. Unknown bits are retained. |
| 1    | `status1` | Secondary status byte.                                            |
| 2–15 | —         | OpenLogi does not read these bytes.                               |

### `set_mode_status` (fn 1) [#set_mode_status-fn-1]

OpenLogi sends a 16-byte long request and checks only the result status.

| Byte | Field           | Notes                            |
| ---- | --------------- | -------------------------------- |
| 0    | `status0`       | Requested primary status bits.   |
| 1    | `status1`       | Requested secondary status byte. |
| 2    | `changed_mask0` | Primary bits to update.          |
| 3    | `changed_mask1` | Secondary bits to update.        |
| 4–15 | —               | OpenLogi writes zero.            |

### `set_performance_mode` (fn 1) [#set_performance_mode-fn-1]

This convenience method uses the fn 1 layout. It writes `PERFORMANCE` or an empty `status0`, sets `changed_mask0` to `PERFORMANCE`, and writes zero to both secondary fields.

### `get_device_config` (fn 2) [#get_device_config-fn-2]

Request: `[0x00, 0x00, 0x00]`.

| Byte | Field          | Notes                                                                                             |
| ---- | -------------- | ------------------------------------------------------------------------------------------------- |
| 0–1  | `capabilities` | `u16` BE. Bits 0 and 1 map to `HARDWARE_SWITCH` and `SOFTWARE_SWITCH`. Unknown bits are retained. |
| 2–15 | —              | OpenLogi does not read these bytes.                                                               |

### Status event (event 0) [#status-event-event-0]

| Byte | Field          | Notes                                                                                                               |
| ---- | -------------- | ------------------------------------------------------------------------------------------------------------------- |
| 0–3  | Status payload | G HUB passes four bytes to its upper layer without byte-order conversion. The field mapping is not recovered. G HUB |

OpenLogi v0.8.15 does not subscribe to or decode this event.

## How OpenLogi uses it [#how-openlogi-uses-it]

OpenLogi creates `ModeStatusFeature` during feature enumeration when a device reports `0x8090` version 1 or later. No device-layer writer, session, inventory path, CLI command, or user-facing page calls the wrapper in v0.8.15. OpenLogi therefore performs no read-modify-write sequence, read-back check, state restoration, or capability gate beyond the wrapper’s version gate.

## Open questions [#open-questions]

G HUB static analysis identifies a four-byte status event, but it does not recover the meanings of its bytes. The event payload and device behavior need hardware verification before OpenLogi adds an event decoder or a device-layer policy.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    device::Device,
    feature::mode_status::{
        ModeStatus0, ModeStatusCapabilities, ModeStatusFeature,
    },
    protocol::v20::Hidpp20Error,
};

async fn enable_performance_mode(device: &mut Device) -> Result<(), Hidpp20Error> {
    device.enumerate_features().await?;

    let Some(feature) = device.get_feature::<ModeStatusFeature>() else {
        return Ok(());
    };

    let status = feature.get_mode_status().await?;
    println!("performance mode: {}", status.status0.contains(ModeStatus0::PERFORMANCE));

    let capabilities = feature.get_device_config().await?;
    if capabilities.contains(ModeStatusCapabilities::SOFTWARE_SWITCH) {
        feature.set_performance_mode(true).await?;
    }

    Ok(())
}
```


# 0x8100 · onboardProfiles (/hidpp/features/x8100-onboard-profiles)



The `onboardProfiles` feature controls the mode and profile storage of compatible gaming devices. G HUB static analysis identifies mode control, active-profile selection, profile-sector reads and writes, CRC retrieval, macro execution, and profile-resolution control. OpenLogi v0.8.15 recognises the feature ID but has no typed wrapper.

> **Spec:** No public Logitech specification. Layout reverse-engineered from G HUB (static analysis). &#x2A;*Used by:** Not used by OpenLogi.

<Callout type="warn" title="Not implemented in OpenLogi">
  OpenLogi v0.8.15 registers `0x8100` by name. It does not provide a typed wrapper, event decoder, mode policy, profile reader, or profile writer.
</Callout>

## What it does [#what-it-does]

G HUB uses this feature to select onboard or host mode, select an active profile, and read or write profile sectors. Its function 0 response has at least 10 bytes: memory model, profile format, macro format, profile counts, button count, sector count, sector size as `u16` BE, and an unresolved zero-based byte-9 bit field. G HUB reads sectors in 16-byte pieces and writes a complete sector with fn 6 through fn 8; fn 6 carries sector, offset, and length as three `u16` BE values. Its writer fills the final 16-byte block with `0xFF` and stores a CRC-16/CCITT-FALSE value in the final two bytes as BE.

G HUB event 0 carries a profile identifier as `u16` BE. Event 1 carries the active profile-resolution index as `u8`. G HUB static analysis also shows that it applies a saved per-device mode at connection and restores onboard mode when its backend exits. These are not OpenLogi actions.

## Functions [#functions]

| Function                     | fn | Purpose                                          |
| ---------------------------- | -: | ------------------------------------------------ |
| `GetOnboardProfilesInfo`     |  0 | Query profile-memory metadata. G HUB             |
| `SetOnboardMode`             |  1 | Select onboard or host mode. G HUB               |
| `IsOnboardMode`              |  2 | Query the selected mode. G HUB                   |
| `SetActiveProfile`           |  3 | Select the active profile. G HUB                 |
| `GetActiveProfile`           |  4 | Query the active profile. G HUB                  |
| `ReadData`                   |  5 | Read profile-storage data. G HUB                 |
| `BeginWrite`                 |  6 | Begin a sector write. G HUB                      |
| `WriteData`                  |  7 | Write profile-storage data. G HUB                |
| `EndWrite`                   |  8 | Finish a sector write. G HUB                     |
| `ExecuteMacro`               |  9 | Execute a stored macro. G HUB                    |
| `GetCRC`                     | 10 | Query the CRC for a sector. G HUB                |
| `GetActiveProfileResolution` | 11 | Query the active profile-resolution value. G HUB |
| `SetActiveProfileResolution` | 12 | Set the active profile-resolution value. G HUB   |

OpenLogi does not implement or hardware-verify these request, response, profile, or event layouts. The byte-9 info fields and profile-format layouts need hardware verification before an implementation writes them.

> **Status:** Not implemented · &#x2A;*Feature ID:** `0x8100`


# 0x8110 · mouseButtonFilter (/hidpp/features/x8110-mouse-button-filter)



The `mouseButtonFilter` registry name corresponds to a mouse-button spy interface in G HUB static analysis. The interface exposes button count, spy start and stop, and a button map. OpenLogi v0.8.15 recognises the feature ID but has no typed wrapper.

> **Spec:** No public Logitech specification. Layout reverse-engineered from G HUB (static analysis). &#x2A;*Used by:** Not used by OpenLogi.

<Callout type="warn" title="Not implemented in OpenLogi">
  OpenLogi v0.8.15 registers `0x8110` by name. It does not provide a typed wrapper, event decoder, button-map restore, or device-layer use.
</Callout>

## What it does [#what-it-does]

G HUB uses the interface after it enters host mode. It clears the map slots for buttons that software controls, then receives their states through event 0. Event 0 is a `u16` BE button bitmap. Bit-to-button mappings vary by device model and are not hardware-verified by OpenLogi.

## Functions [#functions]

| Function         | fn | Purpose                       |
| ---------------- | -: | ----------------------------- |
| `GetButtonCount` |  0 | Query the button count. G HUB |
| `StartSpy`       |  1 | Start button spying. G HUB    |
| `StopSpy`        |  2 | Stop button spying. G HUB     |
| `GetButtonMap`   |  3 | Read the button map. G HUB    |
| `SetButtonMap`   |  4 | Write the button map. G HUB   |

OpenLogi does not implement or hardware-verify the request, response, map, or event layouts.

> **Status:** Not implemented · &#x2A;*Feature ID:** `0x8110`


# 0x8111 · latencyMonitoring (/hidpp/features/x8111-latency-monitoring)



The `latencyMonitoring` feature is a gaming-device monitoring interface. G HUB static analysis identifies a capability query, notification control, and an event path. OpenLogi v0.8.15 recognises the feature ID but has no typed wrapper.

> **Spec:** No public Logitech specification. Layout reverse-engineered from G HUB (static analysis). &#x2A;*Used by:** Not used by OpenLogi.

<Callout type="warn" title="Not implemented in OpenLogi">
  OpenLogi v0.8.15 registers `0x8111` by name. It does not provide a typed wrapper, event decoder, or device-layer use.
</Callout>

## What it does [#what-it-does]

G HUB queries capabilities with function 0. It enables or disables notifications with function 1, which accepts a Boolean, an 8-bit value, and a list of 32-bit values. Static analysis confirms an event handler but does not recover the event layout, latency units, or statistic meanings.

## Functions [#functions]

| Function              | fn | Purpose                                                   |
| --------------------- | -: | --------------------------------------------------------- |
| `GetCapabilities`     |  0 | Query latency-monitoring capabilities. G HUB              |
| `EnableNotifications` |  1 | Enable or disable latency-monitoring notifications. G HUB |

OpenLogi does not implement or hardware-verify the request, response, or event layouts.

> **Status:** Not implemented · &#x2A;*Feature ID:** `0x8111`


# 0x8123 · forceFeedback (/hidpp/features/x8123-force-feedback)



The `forceFeedback` feature is a gaming-wheel interface in the G HUB support inventory. Static analysis identifies methods for effect download, effect state, global gains, aperture, reset, and effect destruction. OpenLogi v0.8.15 recognises the feature ID but has no typed wrapper.

> **Spec:** No public Logitech specification. Behavior reverse-engineered from G HUB (static analysis). &#x2A;*Used by:** Not used by OpenLogi.

<Callout type="warn" title="Not implemented in OpenLogi">
  OpenLogi v0.8.15 registers `0x8123` by name. It does not provide a typed wrapper, event decoder, or device-layer use.
</Callout>

## What it does [#what-it-does]

G HUB static analysis names effect-download methods for condition, constant, periodic, and ramp effects. It also names methods to reset effects, control their state, query or set global gains and aperture, and destroy an effect. The analysis does not recover HID++ function indices or packet layouts, so this page does not specify an on-wire format.

> **Status:** Not implemented · &#x2A;*Feature ID:** `0x8123`


# 0x8300 · sidetone (/hidpp/features/x8300-sidetone)



`0x8300` controls headset sidetone: microphone audio sent back to the ear cups. OpenLogi provides a version-1 typed wrapper. OpenLogi does not connect this feature to a device, agent, CLI, or desktop control path.

> **Spec:** Logitech HID++ 2.0 — *Sidetone*. &#x2A;*Cross-checked:** G HUB 2026.6 (static analysis). &#x2A;*Used by:** Typed wrapper in `openlogi-hidpp`; no device-layer path.

## Function reference [#function-reference]

`SidetoneFeature` implements HID++ `0x8300` from version 1. Every method is asynchronous and returns `Result<…, Hidpp20Error>`.

### Methods [#methods]

| Function             | HID++ fn | Signature                             | Returns              |
| -------------------- | -------- | ------------------------------------- | -------------------- |
| `get_sidetone_level` | 0        | `(&self)`                             | `u8`                 |
| `set_sidetone_level` | 1        | `(&self, level: u8)`                  | `()`                 |
| `get_sidetone_mute`  | 2        | `(&self)`                             | `SidetoneMuteStatus` |
| `set_sidetone_mute`  | 3        | `(&self, change: SidetoneMuteChange)` | `()`                 |

### Types [#types]

#### `SidetoneMuteStatus` [#sidetonemutestatus]

| Field      | Type | Description                                                 |
| ---------- | ---- | ----------------------------------------------------------- |
| `statuses` | `u8` | Raw mute-status bitmask. A set bit denotes a muted channel. |

`SidetoneMuteStatus` is `#[non_exhaustive]`.

#### `SidetoneMuteChange` [#sidetonemutechange]

| Field         | Type | Description                                                     |
| ------------- | ---- | --------------------------------------------------------------- |
| `change_mask` | `u8` | A set bit selects the corresponding channel for update.         |
| `statuses`    | `u8` | A set bit requests mute for the corresponding selected channel. |

### Events [#events]

OpenLogi v0.8.15 defines no sidetone event type and `SidetoneFeature` does not implement `EmittingFeature`.

## Wire format [#wire-format]

Every method sends a 3-byte short request. The wrapper calls `extend_payload()` before it reads a reply. A short reply is therefore zero-extended to 16 bytes by OpenLogi.

The wrapper documents the level as `0..=100`, but it sends any `u8` without local range validation. G HUB static analysis confirms the same named method set, but it does not verify these payload layouts or device range behavior.

### `get_sidetone_level` (fn 0) [#get_sidetone_level-fn-0]

Request: `[0x00, 0x00, 0x00]`.

| Byte | Field | Notes                                                              |
| ---- | ----- | ------------------------------------------------------------------ |
| 0    | level | OpenLogi returns this raw `u8`. The documented range is `0..=100`. |

### `set_sidetone_level` (fn 1) [#set_sidetone_level-fn-1]

Request: `[level, 0x00, 0x00]`.

| Byte | Field   | Notes                                                 |
| ---- | ------- | ----------------------------------------------------- |
| 0    | `level` | OpenLogi sends any `u8`. No local range check occurs. |
| 1–2  | padding | OpenLogi writes zero.                                 |

OpenLogi awaits the response and ignores its payload.

### `get_sidetone_mute` (fn 2) [#get_sidetone_mute-fn-2]

Request: `[0x00, 0x00, 0x00]`.

| Byte | Field                          | Notes                         |
| ---- | ------------------------------ | ----------------------------- |
| 0    | `SidetoneMuteStatus::statuses` | Raw `u8` mute-status bitmask. |

### `set_sidetone_mute` (fn 3) [#set_sidetone_mute-fn-3]

Request: `[change_mask, statuses, 0x00]`.

| Byte | Field                             | Notes                                        |
| ---- | --------------------------------- | -------------------------------------------- |
| 0    | `SidetoneMuteChange::change_mask` | Selects channel status bits to update.       |
| 1    | `SidetoneMuteChange::statuses`    | Requested status bits for selected channels. |
| 2    | padding                           | OpenLogi writes zero.                        |

OpenLogi awaits the response and ignores its payload.

## How OpenLogi uses it [#how-openlogi-uses-it]

The feature registry creates `SidetoneFeature` for `0x8300` version 1 and later. No v0.8.15 device-layer, agent, CLI, or desktop path opens the wrapper. OpenLogi therefore has no sidetone state model, capability gate, read-modify-write operation, read-back check, or exit-time restore behavior.

## Open questions [#open-questions]

G HUB 2026.6 static analysis identifies sidetone level and mute get/set methods, plus an event handler. The available evidence does not identify function IDs, response widths, mute-bit assignments, event sub-IDs, or event layouts. Hardware captures or a feature-specific Logitech specification are required to verify them.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    feature::sidetone::SidetoneFeature,
    protocol::v20::Hidpp20Error,
};

async fn lower_sidetone(feature: &SidetoneFeature) -> Result<(), Hidpp20Error> {
    let level = feature.get_sidetone_level().await?;
    feature.set_sidetone_level(level.saturating_sub(10)).await
}
```


# 0x8310 · equalizer (/hidpp/features/x8310-equalizer)



`0x8310` controls an audio device's per-band equalizer and microphone noise reduction. OpenLogi provides a version-2 typed wrapper. OpenLogi does not connect this feature to a device, agent, CLI, or desktop control path.

> **Spec:** Logitech HID++ 2.0 — *x8310 equalizer*. &#x2A;*Cross-checked:** G HUB 2026.6 (static analysis). &#x2A;*Used by:** Typed wrapper in `openlogi-hidpp`; no device-layer path.

## Function reference [#function-reference]

`EqualizerFeature` implements HID++ `0x8310` from version 2. Every method is asynchronous and returns `Result<…, Hidpp20Error>`.

### Methods [#methods]

| Function                  | HID++ fn | Signature                                             | Returns    |
| ------------------------- | -------- | ----------------------------------------------------- | ---------- |
| `get_eq_info`             | 0        | `(&self)`                                             | `EqInfo`   |
| `get_frequencies`         | 1        | `(&self, band_count: u8)`                             | `Vec<u16>` |
| `get_frequency_gains`     | 2        | `(&self, location: GainLocation, band_count: u8)`     | `Vec<i8>`  |
| `set_frequency_gains`     | 3        | `(&self, persistence: GainPersistence, gains: &[i8])` | `Vec<i8>`  |
| `get_mic_noise_reduction` | 4        | `(&self)`                                             | `bool`     |
| `set_mic_noise_reduction` | 5        | `(&self, enabled: bool)`                              | `()`       |

### Types [#types]

#### `EqInfo` [#eqinfo]

| Field          | Type             | Description                                                      |
| -------------- | ---------------- | ---------------------------------------------------------------- |
| `band_count`   | `u8`             | Number of frequency bands.                                       |
| `db_range`     | `u8`             | Symmetric gain range, in dB, when both explicit bounds are zero. |
| `capabilities` | `EqCapabilities` | How the device stores equalizer values.                          |
| `db_min`       | `i8`             | Minimum gain, in dB, or `0` for the symmetric-range shorthand.   |
| `db_max`       | `i8`             | Maximum gain, in dB, or `0` for the symmetric-range shorthand.   |

`EqInfo` is `#[non_exhaustive]`. `effective_range(&self) -> (i8, i8)` returns `(-db_range, db_range)` when both explicit bounds are `0`.

#### `EqCapabilities` [#eqcapabilities]

| Flag                     | Bit | Description                                         |
| ------------------------ | --- | --------------------------------------------------- |
| `STORED_AS_GAINS`        | 0   | The device stores equalizer values as gains.        |
| `STORED_AS_COEFFICIENTS` | 1   | The device stores equalizer values as coefficients. |

The wrapper retains unknown capability bits when it reads this `u8` bitfield.

#### `GainLocation` [#gainlocation]

| Variant  | Value | Description                                 |
| -------- | ----- | ------------------------------------------- |
| `Eeprom` | 0     | Read the custom equalizer stored in EEPROM. |
| `Ram`    | 1     | Read the active equalizer in RAM.           |

`GainLocation` is `#[non_exhaustive]`.

#### `GainPersistence` [#gainpersistence]

| Variant                  | Value | Description                    |
| ------------------------ | ----- | ------------------------------ |
| `Volatile`               | 0     | Apply gains to RAM only.       |
| `VolatileAndNonVolatile` | 1     | Apply gains to RAM and EEPROM. |
| `NonVolatileOnly`        | 2     | Apply gains to EEPROM only.    |

`GainPersistence` is `#[non_exhaustive]`.

### Events [#events]

OpenLogi v0.8.15 defines no equalizer event type and `EqualizerFeature` does not implement `EmittingFeature`.

## Wire format [#wire-format]

Functions 0, 1, 2, 4, and 5 send 3-byte short requests. Function 3 sends a 16-byte long request. The wrapper converts every response to a 16-byte buffer, zero-extending a short reply.

Frequencies are BE `u16` values. Gains are signed `i8` values. The wrapper checks the echoed page index for function 1. It does not validate an echoed persistence selector for function 3.

### `get_eq_info` (fn 0) [#get_eq_info-fn-0]

Request: `[0x00, 0x00, 0x00]`.

| Byte | Field          | Notes                                            |
| ---- | -------------- | ------------------------------------------------ |
| 0    | `band_count`   | Number of frequency bands.                       |
| 1    | `db_range`     | Symmetric range, in dB, when bytes 3–4 are zero. |
| 2    | `capabilities` | `EqCapabilities` flags.                          |
| 3    | `db_min`       | Signed `i8` minimum gain, in dB.                 |
| 4    | `db_max`       | Signed `i8` maximum gain, in dB.                 |

### `get_frequencies` (fn 1) [#get_frequencies-fn-1]

The wrapper sends one request per page. A page contains at most seven frequencies.

Request: `[start_index, 0x00, 0x00]`.

| Byte | Field         | Notes                              |
| ---- | ------------- | ---------------------------------- |
| 0    | `start_index` | Index of the first requested band. |

| Response byte | Field                | Notes                                                   |
| ------------- | -------------------- | ------------------------------------------------------- |
| 0             | echoed `start_index` | OpenLogi returns `UnsupportedResponse` if this differs. |
| 1–2           | frequency 0          | BE `u16`, in Hz.                                        |
| 3–4           | frequency 1          | BE `u16`, in Hz.                                        |
| 5–14          | frequencies 2–6      | Five further BE `u16` values when the page needs them.  |
| 15            | unused               | OpenLogi ignores this byte.                             |

### `get_frequency_gains` (fn 2) [#get_frequency_gains-fn-2]

Request: `[location, 0x00, 0x00]`.

| Byte | Field      | Notes                                            |
| ---- | ---------- | ------------------------------------------------ |
| 0    | `location` | `GainLocation`: `0` for `Eeprom`; `1` for `Ram`. |

| Response byte | Field | Notes                               |
| ------------- | ----- | ----------------------------------- |
| 0 onward      | gains | One signed `i8` per requested band. |

### `set_frequency_gains` (fn 3) [#set_frequency_gains-fn-3]

The request is a 16-byte long payload. OpenLogi rejects more than 15 gains.

| Byte | Field         | Notes                                    |
| ---- | ------------- | ---------------------------------------- |
| 0    | `persistence` | `GainPersistence`: `0`, `1`, or `2`.     |
| 1–15 | gains         | Signed `i8` values encoded as raw bytes. |

The wrapper skips response byte 0 and returns as many signed gain bytes as the request supplied. It does not compare the skipped byte with `persistence`.

### `get_mic_noise_reduction` (fn 4) [#get_mic_noise_reduction-fn-4]

Request: `[0x00, 0x00, 0x00]`.

| Byte | Field   | Notes                                          |
| ---- | ------- | ---------------------------------------------- |
| 0    | enabled | Zero is `false`; any non-zero value is `true`. |

### `set_mic_noise_reduction` (fn 5) [#set_mic_noise_reduction-fn-5]

Request: `[enabled, 0x00, 0x00]`.

| Byte | Field     | Notes                                               |
| ---- | --------- | --------------------------------------------------- |
| 0    | `enabled` | OpenLogi writes `0` for `false` and `1` for `true`. |
| 1–2  | padding   | OpenLogi writes zero.                               |

OpenLogi awaits the response and ignores its payload.

## How OpenLogi uses it [#how-openlogi-uses-it]

The feature registry creates `EqualizerFeature` for `0x8310` version 2 and later. No v0.8.15 device-layer, agent, CLI, or desktop path opens the wrapper. OpenLogi therefore has no equalizer state model, capability gate, read-modify-write operation, read-back check, or exit-time restore behavior.

## Open questions [#open-questions]

G HUB 2026.6 static analysis identifies the same six getter and setter names, plus an event handler. The available evidence does not identify function IDs, event sub-IDs, response widths, or the full protocol layout. Hardware captures or a feature-specific Logitech specification are required to verify them.

OpenLogi zero-extends short replies for every equalizer getter. This is an implementation fact, not proof that a short reply is valid for a multi-byte response. The available evidence does not establish the required response lengths.

## Usage (Rust) [#usage-rust]

```rust
use hidpp::{
    feature::equalizer::{EqualizerFeature, GainLocation},
    protocol::v20::Hidpp20Error,
};

async fn active_gains(feature: &EqualizerFeature) -> Result<Vec<i8>, Hidpp20Error> {
    let info = feature.get_eq_info().await?;
    feature
        .get_frequency_gains(GainLocation::Ram, info.band_count)
        .await
}
```


# Keyboards (/docs/features/keyboard)



OpenLogi has two independent keyboard binding paths. Select a keyboard and open
its **Keys** tab to use the path available for that device.

## Two keyboard binding paths [#two-keyboard-binding-paths]

| Attribute          | HID++ controls                                             | OS-hook function keys                                     |
| ------------------ | ---------------------------------------------------------- | --------------------------------------------------------- |
| Scope              | One Logitech keyboard that reports reprogrammable controls | Keyboard events received by this computer                 |
| Identity           | HID++ `0x1b04` 16-bit control ID                           | `esc`, `f1`–`f19`, with optional modifiers                |
| Config             | `[devices."unit:6be9d300".bindings]`                       | `[keyboard.bindings]`                                     |
| Scope of a binding | Per physical device; an app profile can override it        | Global to the computer                                    |
| GUI                | **Keys** shows marked HID++ controls                       | **Keys** falls back to the function row                   |
| Platforms          | macOS, Linux, and Windows                                  | macOS and Windows; Linux does not capture keyboard events |

## Bind a key in the GUI [#bind-a-key-in-the-gui]

Click a key on the **Keys** tab to open its panel. The panel lists the same
action catalog as the mouse button picker, plus a **Power User** section. Pick an
action to bind it.

To clear a binding, choose **Native function**, or pick the action that is
already bound again. Both remove the override, so the key returns to its native
behavior. The F-key panel in v0.8.15 has no **Reset to defaults** button.

## HID++ keyboard controls [#hid-keyboard-controls]

A Logitech keyboard that exposes HID++ `0x1b04` reprogrammable controls binds
keys by their 16-bit control ID (CID). When the keyboard image has control
markers, the **Keys** tab shows those controls. Otherwise, it shows the OS-hook
function row.

Use a catalog name below or `control:0x<cid>` for any divertable control from
`openlogi diag controls`. An unbound control keeps its firmware action. A stored
`None` is not unbound in v0.8.15: the agent diverts and suppresses that control.
Delete the control entry to restore its firmware action.

```toml
[devices."unit:6be9d300".bindings]
KeySearch = "MissionControl"
KeyCalculator = { CustomShortcut = "Cmd+Shift+C" }
"control:0x01f3" = "ShowActionsRing"
```

## Control catalog [#control-catalog]

OpenLogi names these 43 HID++ controls. The **Keys** tab uses the localized
labels when the keyboard reports the matching control.

| Config key                  | **Keys** label                     | CID      |
| --------------------------- | ---------------------------------- | -------- |
| `KeyBrightnessDown`         | **Brightness Down Key**            | `0x00c7` |
| `KeyBrightnessUp`           | **Brightness Up Key**              | `0x00c8` |
| `KeyBacklightDown`          | **Backlight Down Key**             | `0x00e2` |
| `KeyBacklightUp`            | **Backlight Up Key**               | `0x00e3` |
| `KeyMissionControl`         | **Mission Control Key**            | `0x00e0` |
| `KeyLaunchpad`              | **Launchpad Key**                  | `0x00e1` |
| `KeyShowDesktop`            | **Show Desktop Key**               | `0x006e` |
| `KeySearch`                 | **Search Key**                     | `0x00d4` |
| `KeyDictation`              | **Dictation Key**                  | `0x0103` |
| `KeyEmoji`                  | **Emoji Key**                      | `0x0108` |
| `KeyScreenCapture`          | **Screen Capture Key**             | `0x010a` |
| `KeyPrintScreen`            | **Print Screen Key**               | `0x00bf` |
| `KeyMicMute`                | **Mic Mute Key**                   | `0x011c` |
| `KeyPreviousTrack`          | **Previous Track Key**             | `0x00e4` |
| `KeyPlayPause`              | **Play/Pause Key**                 | `0x00e5` |
| `KeyPlayPauseTrack`         | **Play/Pause Key (track)**         | `0x0141` |
| `KeyNextTrack`              | **Next Track Key**                 | `0x00e6` |
| `KeyMute`                   | **Mute Key**                       | `0x00e7` |
| `KeyVolumeDown`             | **Volume Down Key**                | `0x00e8` |
| `KeyVolumeUp`               | **Volume Up Key**                  | `0x00e9` |
| `KeyCalculator`             | **Calculator Key**                 | `0x000a` |
| `KeyLock`                   | **Lock Key**                       | `0x006f` |
| `KeyDoNotDisturb`           | **Do Not Disturb Key**             | `0x011d` |
| `KeyEmojiHeartEyes`         | **Heart-Eyes Emoji Key**           | `0x0104` |
| `KeyEmojiCrying`            | **Crying Emoji Key**               | `0x0105` |
| `KeyEmojiSmiley`            | **Smiley Emoji Key**               | `0x0106` |
| `KeyEmojiTears`             | **Tears-of-Joy Emoji Key**         | `0x0107` |
| `KeyLookUp`                 | **Look Up Key**                    | `0x00ea` |
| `KeyAppSwitch`              | **App Switch Key**                 | `0x0100` |
| `KeyAppSwitchLaunchpad`     | **App Switch / Launchpad Key**     | `0x0109` |
| `KeyHome`                   | **Home Key**                       | `0x0118` |
| `KeyEnd`                    | **End Key**                        | `0x0119` |
| `KeyPageUp`                 | **Page Up Key**                    | `0x011a` |
| `KeyPageDown`               | **Page Down Key**                  | `0x011b` |
| `KeyMultiPlatformAppSwitch` | **App Switch Key (multiplatform)** | `0x00ba` |
| `KeyMultiPlatformHome`      | **Home Key (multiplatform)**       | `0x00bb` |
| `KeyMultiPlatformMenu`      | **Menu Key (multiplatform)**       | `0x00bc` |
| `KeyMultiPlatformInsert`    | **Insert Key (multiplatform)**     | `0x00be` |
| `KeyLanguageSwitch`         | **Language Switch Key**            | `0x00dd` |
| `KeyCircle`                 | **Circle Key**                     | `0x01a3` |
| `KeyTriangle`               | **Triangle Key**                   | `0x01a4` |
| `KeyDiamond`                | **Diamond Key**                    | `0x01a5` |
| `KeyStar`                   | **Star Key**                       | `0x01a6` |

## Global function-key remapper [#global-function-key-remapper]

The OS-hook path remaps `esc` and `f1`–`f19` from any keyboard. Its bindings are
global, so they do not belong to a Logitech device or an app profile. Use
`[modifier+]key`; valid modifiers are `shift`, `control` (`ctrl`), `option`
(`alt`), and `command` (`cmd`).

Fn is not a valid trigger because the firmware does not report it as an ordinary
key modifier. Choose **Native function** in the **Keys** panel to clear a
function-key binding. The GUI removes that trigger from `[keyboard.bindings]`.

```toml
[keyboard.bindings]
f1 = "MissionControl"
"shift+f2" = "ShowDesktop"
"cmd+f5" = { CustomShortcut = "Cmd+Shift+P" }
```

The action list includes **Actions Ring**. Its **Power User** section edits
&#x2A;*Type Text…** and &#x2A;*Workflow…** actions. The configuration format also supports
`CustomShortcut` for an immediate key combination; see the full
[configuration reference](/docs/configurations).

## Device settings [#device-settings]

Select a keyboard and open its **Device** tab. The keyboard card contains the
settings that the connected keyboard supports.

**Fn Lock** makes bare F-row keys send F1–F12. The printed function then moves to
Fn + key. OpenLogi supports HID++ `0x40a3`, `0x40a2`, and `0x40a0` Fn-inversion
features, and re-applies a saved setting when the keyboard reconnects.

**Keep OS layout** applies to multi-platform keyboards that expose HID++
`0x4531`. When enabled, the agent sets the keyboard layout for this computer's
operating system when the keyboard connects. It sets the layout again after a
keyboard layout shortcut changes it.

## Related keyboard features [#related-keyboard-features]

**Easy-Switch.** Easy-Switch channels hold paired computers and can coordinate
host switching with other devices. See [Easy-Switch](/docs/features/easy-switch).

**Keyboard lighting.** Backlight controls and static RGB lighting are separate
from key bindings. See [Keyboard lighting](/docs/features/keyboard/lighting).

**Lock-key indicators.** OpenLogi can show Caps Lock, Num Lock, and Scroll Lock
state changes on screen when the keyboard reports them. See
[on-screen notices](/docs/features/notifications).

**Status:** HID++ control bindings work on macOS, Linux, and Windows when the
keyboard reports [`0x1b04` specialKeysMSEButtons](/hidpp/features/x1b04-special-keys-mse-buttons).
**Fn Lock** uses [`0x40a0` fnInversion](/hidpp/features/x40a0-fn-inversion),
[`0x40a2` fnInversionWithDefaultState](/hidpp/features/x40a2-fn-inversion), or
[`0x40a3` fnInversionMultiHost](/hidpp/features/x40a3-fn-inversion-multi-host).
**Keep OS layout** uses [`0x4531` multiPlatform](/hidpp/features/x4531-multi-platform).
The global function-key remapper works on macOS and Windows. Linux does not
capture keyboard events.


# Keyboard lighting (/docs/features/keyboard/lighting)



OpenLogi separates static RGB lighting from a keyboard's white backlight. The
HID++ features that a keyboard reports decide which controls appear.

| Keyboard capability                  | HID++ feature                                    | Where to control it                 | Controls                                                      | Storage                                                                           |
| ------------------------------------ | ------------------------------------------------ | ----------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| RGB keyboard                         | One of `0x8070`, `0x8071`, `0x8081`, or `0x8080` | **Lighting** tab                    | One static colour, an on/off switch, and **Brightness**       | `config.toml`; the agent reapplies the saved setting when the keyboard reconnects |
| White-backlight keyboard             | `0x1982`                                         | **Device** tab → **Backlight** card | **Light the keys**, plus the controls the keyboard advertises | Keyboard non-volatile memory; never `config.toml`                                 |
| Keyboard with only legacy Backlight1 | `0x1981`                                         | No control                          | Not supported                                                 | Not written                                                                       |

## Static RGB lighting [#static-rgb-lighting]

Select an RGB keyboard in the device gallery and open its **Lighting** tab. The
tab appears when OpenLogi detects at least one supported RGB feature.

* Select a colour swatch to enable lighting and apply that static colour.
* **Brightness** accepts `0`–`100` in steps of `5`. The slider writes the value
  when you release it.
* OpenLogi provides a whole-keyboard static colour. It does not provide animated
  effects or a per-key colour editor.

### In the config [#in-the-config]

OpenLogi saves the RGB selection by physical device key. It reloads the agent
so the agent can apply that selection again after a reconnect.

```toml
[devices."receiver:aabbccdd:slot:1".lighting]
enabled = true
color = "ff0000"
brightness = 80
```

`enabled`, `color`, and `brightness` remain in the config while lighting is
off. Re-enabling lighting restores the saved colour and brightness. See
[Configuration](/docs/configurations) for the full reference.

## Protocol and diagnostics [#protocol-and-diagnostics]

OpenLogi tries the RGB paths in this order: `0x8070`, `0x8071`, `0x8081`, then
`0x8080`. The preferred `0x8070` path selects a volatile fixed-colour effect,
which can replace a running onboard profile.

When OpenLogi uses `0x8071`, the agent keeps software control for its session
and returns control to the firmware when it exits. It serializes writes and
release work so an old release cannot replace a later colour selection.

Use the diagnostic command to set a solid colour on a wired RGB keyboard. It
uses the same HID++ write path as the GUI.

```sh
openlogi diag lighting ff0000
```

<Callout type="info">
  Quit Logi Options+ first. Both applications require exclusive HID++ access.
</Callout>

## White backlight [#white-backlight]

An online keyboard that reports `0x1982` shows the **Backlight** card on its
**Device** tab. The card reads the keyboard's state and shows only the controls
that the keyboard accepts.

* **Light the keys** is available on every `0x1982` keyboard. The keyboard saves
  this setting, so it applies on every computer.
* **Automatic brightness** appears when the keyboard supports automatic mode. It
  adjusts brightness to the light around the keyboard.
* **Brightness** appears when the keyboard supports software level selection.
  The buttons span `0` through the highest level the keyboard reports. Choosing
  a level turns **Automatic brightness** off.
* **Battery saving** appears when the keyboard supports it. The keyboard turns
  the backlight off when its battery runs low.

Turning **Light the keys** off disables **Automatic brightness** and
**Brightness**, but not **Battery saving**. When firmware, rather than the
setting, turns the keys off, the card says &#x2A;*Off while the battery is critically
low.** or &#x2A;*Off while the room is bright enough.**

The card writes each accepted change to the keyboard and replaces its displayed
state with the keyboard's read-back. Backlight settings are stored only in the
keyboard's non-volatile memory, not in `config.toml`.

### From the command line [#from-the-command-line]

`openlogi backlight` reads the current state and the controls a selected
keyboard offers. The same persistent `0x1982` controls are available from the
command line:

```sh
openlogi backlight auto on
openlogi backlight level 3
openlogi backlight battery-saving on
```

A level must be from `0` through the keyboard's reported level count minus one.
`openlogi backlight off` keeps the LEDs dark regardless of ambient light or
hand proximity. `openlogi backlight on` restores the stored mode and level.

## On-screen backlight level [#on-screen-backlight-level]

OpenLogi shows a transient on-screen **Backlight** level indicator when it
receives a backlight-change event. The indicator shows the current and highest
reported levels, then hides after `1,500 ms`. See
[Notifications](/docs/features/notifications) for the related on-screen notices.

**Status:** macOS, Linux, and Windows. See
[`0x8070` colorLedEffects](/hidpp/features/x8070-color-led-effects),
[`0x8071` rgbEffects](/hidpp/features/x8071-rgb-effects),
[`0x8081` perKeyLighting2](/hidpp/features/x8081-per-key-lighting),
[`0x8080` perKeyLighting](/hidpp/features/x8080-per-key-lighting), and
[`0x1982` backlight](/hidpp/features/x1982-backlight) for protocol detail.


# DPI presets (/docs/features/mouse/dpi)



Set the active DPI and maintain an ordered preset list in the **Pointer** tab.
OpenLogi reads HID++ `0x2201` adjustableDpi or `0x2202` extendedAdjustableDpi.

## From the GUI [#from-the-gui]

Select a mouse, then open **Pointer**. The DPI controls use values reported by the selected device:

* **DPI** shows the current value. When the device reports several values, the slider spans its lowest and highest values. Its displayed step is the smallest gap between adjacent values. Dragging previews a value. Releasing the slider writes the nearest supported value.
* A mouse with one supported value shows **Fixed DPI** instead of a slider.
* **Presets** contains clickable DPI values. Clicking a value normalizes it to the device grid, then writes it. The first value matching the current DPI is active.
* **Add** appends the current DPI. The remove button deletes its preset by position. Duplicate values remain valid because each position can be a separate cycle stop.

While the capability read is pending, the panel shows reading states. An offline mouse reports that DPI is unavailable. A failed read provides &#x2A;*Couldn't read DPI. Click to retry.** A mouse without adjustable DPI reports &#x2A;*This device does not report adjustable DPI support.**

Since v0.8.12, the editor seeds its value from saved `dpi` while its live read is pending. The device's live value replaces that seed when the read completes. Other **Pointer** controls use their own capability checks, so **Report rate** can appear without adjustable DPI.

<Callout type="info" title="Sensor echo validation">
  Since v0.8.14, OpenLogi rejects a `0x2201` supported-DPI reply when its sensor echo differs from the requested sensor. This prevents another sensor's list from becoming the selected list.
</Callout>

## Preset actions [#preset-actions]

A hardware button can use either action:

* **`CycleDpiPresets`** advances through `dpi_presets` in order and wraps after the last entry.
* **`SetDpiPreset`** selects a zero-based preset position. It has an argument, so it is not available in the GUI action picker. Add it by editing [Configuration](/docs/configurations).

When you replace the preset list, OpenLogi resets the cycle position to its first entry. The list order is therefore the cycle order.

## In the config [#in-the-config]

OpenLogi stores the active value and preset list under the physical device key:

```toml
schema_version = 9

[devices."receiver:aabbccdd:slot:1"]
dpi = 1600
dpi_presets = [800, 1600, 3200]
```

The mouse holds DPI in volatile memory. OpenLogi saves `dpi` and re-applies it when the device reconnects. See [Configuration](/docs/configurations) for the full action syntax and [Per-app profiles](/docs/features/profiles) for per-app button bindings.

**Status:** macOS, Linux, and Windows, for devices that expose adjustable DPI. See [`0x2201` adjustableDpi](/hidpp/features/x2201-adjustable-dpi) for the protocol detail.


# Remap buttons (/docs/features/mouse/remap-buttons)



Use a mouse's **Buttons** tab to change a supported control. Each control uses one action, a five-direction gesture map, or a Back / Forward wheel-hold mode.

## From the GUI [#from-the-gui]

Select a mouse, then open **Buttons**. Click its hotspot dot or label card to open the control inspector.

The label card shows the effective binding. It shows **Unbound** when no binding exists.

* **Action picker** — Search the standard action list. **Do Nothing** captures the input without synthesizing an operating-system event.
* **Custom shortcut** — Record a chord, then choose **Tap** or **Hold**. Tap writes `CustomShortcut`; Hold writes `HoldShortcut`.
* **Open application or folder** — Enter an application, folder, path, or URL. The picker can author this action directly.
* **Default profile** — This profile changes device-wide bindings. An app profile has one action per button; **Use the default profile** removes that button's override.

The mouse picker does not author `TapKeyHoldingModifiers`, `TypeText`, `RunAppleScript`, `RunShellCommand`, or `Workflow`. Add those actions in TOML.

Use `Super` in a custom shortcut when you need the platform logo modifier. It is Command on macOS, and the Super or Windows key on Linux and Windows.

## Actions by platform [#actions-by-platform]

The same action name can use a different native path on each platform.

| Action                            | macOS                               | Linux                                                                                                  | Windows                      |
| --------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------- |
| `MissionControl`                  | Sends a Dock notification.          | GNOME opens Activities. KDE uses KWin Overview. Hyprland skips this action.                            | Sends Win+Tab.               |
| `AppExpose`                       | Sends a Dock notification.          | KDE uses KWin ExposeClass. GNOME and Hyprland skip this action.                                        | Sends Win+Tab.               |
| `ShowDesktop`                     | Sends a Dock notification.          | GNOME sends Super+D. KDE uses KWin. Hyprland skips this action.                                        | Sends Win+D.                 |
| `LaunchpadShow`                   | Sends a Dock notification.          | GNOME sends Super+A. Hyprland uses its native helper or remap. Other sessions skip it.                 | Sends the Windows key.       |
| `PreviousDesktop` / `NextDesktop` | Sends a Dock desktop swipe.         | KDE uses KWin D-Bus. Other sessions use their shortcut path. Hyprland uses its native helper or remap. | Sends Win+Ctrl+Left / Right. |
| `Screenshot`                      | Takes a native full-screen capture. | Sends Print Screen.                                                                                    | Sends Win+PrtScn.            |
| `CaptureRegion`                   | Sends Cmd+Shift+Ctrl+4.             | Sends the desktop's region-capture Print Screen chord.                                                 | Sends Win+Shift+S.           |

On Linux, a missing native equivalent is skipped. OpenLogi does not substitute an action with different scope.

## Gesture mode [#gesture-mode]

Choose **Use gestures** for a supported control in the **Default profile**. The inspector then has **Up**, **Down**, **Left**, **Right**, and **Click** actions.

**Middle Click**, **Back**, and **Forward** can enter gesture mode. **DPI Toggle** can enter it after the mouse reports raw-XY support.

A dedicated **Gesture Button** and the MX Master 4 **Haptic Panel** can also use gestures. The M720 Triathlon multiplatform thumb button is recognised as a Gesture Button. An MX Ergo precision-mode slot maps to `DpiToggle`.

A held Gesture Button can dispatch chained swipes. Keep holding the button, then make another swipe to dispatch another direction.

**Hold to scroll sideways** is available for **Back** and **Forward** when the mouse reports raw-wheel support. While you hold the button, the main wheel scrolls left and right.

A press without wheel movement still runs the button's click action. Wheel hold and gesture mode are mutually exclusive for that button.

See [Hold to scroll sideways](/docs/features/mouse/scrolling#hold-to-scroll-sideways) for the scrolling behavior.

## Capture paths [#capture-paths]

OpenLogi captures ordinary **Middle Click**, **Back**, and **Forward** input through the operating-system hook. It captures supported device controls through HID++ `0x1b04` `reprogControlsV4` diversion.

The HID++ path covers the dedicated Gesture Button, the Haptic Panel, and supported DPI / ModeShift controls. It also enables HID++ side-button gestures on Windows when the device reports the required control data.

The agent rebuilds the per-device capture plan after a binding changes. Controls not required by an effective binding or built-in gesture keep their firmware behavior.

## In the config [#in-the-config]

Bindings use a physical device key. This example shows a modifier-held tap, a wheel hold, a short / long pair, and a gesture map:

```toml
[devices."unit:6be9d300".bindings]
Back = { TapKeyHoldingModifiers = "Alt+Tab" }
Forward = { click = "BrowserForward", wheel = "HorizontalScroll" }
MiddleClick = { short = "Copy", long = "Paste" }

[devices."unit:6be9d300".bindings.GestureButton]
Up = "MissionControl"
Down = "AppExpose"
Left = "PreviousDesktop"
Right = "NextDesktop"
Click = "ShowActionsRing"
```

`CustomShortcut` taps the complete chord immediately. `HoldShortcut` holds modifiers and the ordinary key until the physical button releases.

`TapKeyHoldingModifiers` taps the ordinary key once, then holds the modifiers until release. This can keep an application switcher open after it receives `Alt+Tab`.

Short / long pairs are TOML-only. Releasing before 500 ms runs `short`; reaching 500 ms runs `long` once.

See [Configuration](/docs/configurations) for every action and binding form.

## Per-app bindings [#per-app-bindings]

Per-app bindings use one action per button. They cannot store gesture directions, short / long pairs, or wheel-hold behavior.

Use an app override to replace a global action in one app. Clear that override to inherit the Default profile again.

See [Per-app profiles](/docs/features/profiles) for targeting and overlay rules.

**Status:** macOS, Linux, and Windows. Device-side capture uses [`0x1b04` specialKeysMSEButtons](/hidpp/features/x1b04-special-keys-mse-buttons).


# Report rate (/docs/features/mouse/report-rate)



Use **Report rate** in the **Pointer** tab to choose how often a mouse reports motion. OpenLogi supports HID++ `0x8060` adjustableReportRate and `0x8061` extendedAdjustableReportRate.

## From the GUI [#from-the-gui]

The row appears only when the selected mouse reports report-rate capability. It contains one button for each rate that the current connection offers:

* The selected rate is the mouse's current rate.
* Selecting a rate saves `report_rate` for the device and writes the mouse.
* The device read-back replaces the optimistic selection. If the write fails, OpenLogi reads the mouse again.

The rate list belongs to the current connection. A mouse can offer different rates through different connections. The row is hidden while the mouse is offline. While a read is pending, it shows &#x2A;*Reading report rate…**. A failed read provides &#x2A;*Couldn't read the report rate. Click to retry.**

## In the config [#in-the-config]

OpenLogi saves a report rate in hertz under the physical device key:

```toml
schema_version = 9

[devices."unit:6be9d300"]
report_rate = 1000
```

On reconnect, the agent re-applies the saved rate only when the active connection offers that rate. When `report_rate` is absent, OpenLogi leaves the mouse's current rate unchanged. See [Configuration](/docs/configurations) for the complete reference.

## Automation API [#automation-api]

`openlogi api report-rate` returns the current rate and the rates offered by the current connection. A write accepts only one of those listed rates:

```bash
openlogi api report-rate --device "$DEVICE_ID"
openlogi api report-rate --device "$DEVICE_ID" --set 1000 --save
```

`--save` persists the requested rate. Without `--save`, the command changes the live device only.

## Pointer motion scaling [#pointer-motion-scaling]

HID++ `0x2205` PointerMotionScaling is separate from **Report rate**. OpenLogi v0.8.15 has no GUI control for it. The only OpenLogi caller is the diagnostic command:

```bash
openlogi diag pointer-speed
```

The command temporarily writes a raw 8.8 fixed-point scale and attempts to restore the original scale. `256` is 1×. Use `--target RAW` to select another nonzero raw value. See [`0x2205` PointerMotionScaling](/hidpp/features/x2205-pointer-motion-scaling) for the wire format.

**Status:** macOS, Linux, and Windows, for devices that expose the matching HID++ feature. See [`0x8060` adjustableReportRate](/hidpp/features/x8060-report-rate), [`0x8061` extendedAdjustableReportRate](/hidpp/features/x8061-extended-report-rate), and [`0x2205` PointerMotionScaling](/hidpp/features/x2205-pointer-motion-scaling).


# Scrolling (/docs/features/mouse/scrolling)



OpenLogi separates app-wide wheel handling from each mouse's wheel settings.
Open **Settings** → **General** for host-side controls.
Open a mouse's **Pointer** tab for controls that use its current connection.
For ratchet and free-spin mode, see [SmartShift](/docs/features/mouse/smartshift).

## App-wide wheel settings [#app-wide-wheel-settings]

**Smooth scrolling** replaces eligible traditional mouse-wheel ticks with finite eased motion.
It is off by default.
Continuous trackpad input stays native.

**Vertical Scroll Sensitivity** scales traditional vertical wheel distance from `1` through `100`.
The default `14` is 1×.
It does not scale trackpad input.

**Thumb Wheel Sensitivity** uses the same `1` through `100` range.
The default `14` is 1×.
It sets the app-wide default for a thumb wheel's scroll speed and discrete-action threshold.
A device can override it in its configuration.

## Per-device main wheel [#per-device-main-wheel]

Select a mouse and open **Pointer**.
The following controls depend on the wheel capabilities of its current connection.

* **Invert scroll direction** reverses this mouse's vertical wheel direction.
  The trackpad keeps the system direction.
  OpenLogi uses native HID++ inversion when the wheel reports it.
  Otherwise, OpenLogi reverses the wheel while it captures the event.
* **Wheel resolution** offers **Device default**, **Standard**, and **High resolution**.
  **Device default** leaves the wheel unchanged.
  **Standard** scrolls once per physical ratchet step.
  **High resolution** detects movement between ratchet steps.
* **Smooth wheel** is a per-device switch for a main wheel with HID++ `0x2121`.
  OpenLogi diverts every wheel report and posts eased scrolling.
  This happens even when **Smooth scrolling** is off.
  The result still uses this mouse's inversion and **Vertical Scroll Sensitivity**.

**Smooth wheel** needs input-hook access and `capture_mouse_events = true`.
On macOS, the hook needs Accessibility access.
Without either condition, the wheel stays native.

When you turn **Smooth wheel** off, disable the mouse, or exit the agent, OpenLogi returns the wheel to native reporting.
If the agent stops first, the wheel remains diverted until the agent runs again or the mouse reconnects.
A reconnect drops the diversion.

The **Smooth wheel** card appears when the current connection reports `0x2121`.
A wheel enabled on another connection remains visible as an other-connection state.
You can turn it off there, but cannot turn it on without `0x2121` on the current connection.

## Thumb wheel [#thumb-wheel]

A supported **Thumb Wheel** uses HID++ `0x2150`.
Its two directions are stored as `ThumbwheelScrollUp` and `ThumbwheelScrollDown` bindings.
OpenLogi diverts it when the sensitivity differs from `14` or a direction differs from its native binding.

The GUI recognizes these exact direction pairs as presets:

* **Back / Forward**, **Undo / Redo**, **Browser Back / Forward**, and **Previous / Next Tab**.
* **Previous / Next Desktop**, **Next / Previous Desktop**, and **Previous / Next Track**.
* **Volume Up / Down**, **Volume Down / Up**, and **Cycle DPI Presets**.
* **Vertical Scroll**, &#x2A;*Vertical Scroll (Reversed)**, **Horizontal Scroll**, and &#x2A;*Horizontal Scroll (Reversed)**.

**Volume Down / Up** reverses **Volume Up / Down**.
**Horizontal Scroll** preserves the native direction and does not divert the wheel.
&#x2A;*Horizontal Scroll (Reversed)** swaps the directions and diverts the wheel.
A thumb-wheel swipe can fire a discrete action more than once, including volume changes.

With **Smooth scrolling** off, diverted horizontal thumb-wheel input remains a phased gesture.
The gesture ends after `120 ms` without another tick.
This keeps horizontal swipe-aware applications responsive.

`Thumbwheel` is the capacitive tap report, not a mechanical click.
Its default is **Do Nothing**.
The GUI has no control for it because incidental thumb contact can produce a tap.
Bind it in the configuration only when that behavior is acceptable.

## Hold to scroll sideways [#hold-to-scroll-sideways]

On a mouse without a thumb wheel, hold **Back** or **Forward** and turn the main wheel.
The held wheel becomes a virtual horizontal thumb wheel.
Turning away from you scrolls left, and turning toward you scrolls right.
**Invert scroll direction** reverses those directions.

A release without turning the wheel runs the button's `click` action.
A press that turns the wheel runs no other action.
The feature is off until you add the wheel-hold binding.

Wheel hold needs raw-wheel reporting for that button through HID++ `0x1b04`.
Only some firmware exposes this capability.
It also needs the mouse hook and `capture_mouse_events = true`.
On macOS, that means Accessibility access.

Without raw-wheel reporting or the hook, OpenLogi captures the button only for `click`.
The main wheel then stays native.
When mouse capture is off, the button keeps its native function.

OpenLogi returns the button and wheel to firmware when the binding changes, the mouse disconnects, or the agent exits.
It does not set the firmware's persistent flag.
A power cycle also clears the diversion.

In the default profile, select **Back** or **Forward** and enable **Hold to scroll sideways**.
The switch appears only when that button reports raw-wheel support.
A carried-over binding remains visible, so you can turn it off on an unsupported mouse.
Changing the button action preserves its wheel hold and changes `click`.

Enabling wheel hold on a gesture button leaves gesture mode and keeps the gesture map's **Click** action.
Enabling gestures again restores the saved gesture directions.
Wheel hold is per device, not per application.
An app profile action for that button replaces wheel hold in that application.

## In the config [#in-the-config]

The settings below combine app-wide scroll handling, per-device wheel controls, thumb-wheel bindings, and a Back wheel hold.
See [Configuration](/docs/configurations) for the full reference.

```toml
schema_version = 9

[app_settings]
capture_mouse_events = true
smooth_scroll = true
vertical_scroll_sensitivity = 20  # 1–100; 14 is 1×
thumbwheel_sensitivity = 14       # App-wide thumb-wheel default

[devices."unit:6be9d300"]
invert_scroll = true
scroll_resolution = "high"        # "low", "high", or omit for the device default
smooth_wheel = true
thumbwheel_sensitivity = 30       # Per-device override

[devices."unit:6be9d300".bindings]
ThumbwheelScrollUp = "HorizontalScrollLeft"
ThumbwheelScrollDown = "HorizontalScrollRight"
Back = { click = "BrowserBack", wheel = "HorizontalScroll" }
```

**Status:** macOS, Linux, and Windows. Per-device controls require the matching device capability on its current connection. See [`0x2121` hiResWheel](/hidpp/features/x2121-hires-wheel), [`0x2150` thumbwheel](/hidpp/features/x2150-thumbwheel), and [`0x1b04` specialKeysMSEButtons](/hidpp/features/x1b04-special-keys-mse-buttons).


# SmartShift (/docs/features/mouse/smartshift)



Use SmartShift in the **Pointer** tab to control a mouse wheel's free-spin and ratchet modes. OpenLogi uses HID++ `0x2111` smartShiftEnhanced, and also supports the older `0x2110` smartShiftWheel.

## From the GUI [#from-the-gui]

Select a mouse, then open **Pointer**. SmartShift controls appear when the device reports the feature:

* **Wheel mode** switches between **Free spin** and **Ratchet**.
* **Sensitivity** sets the ratchet release threshold. The GUI range is `8`–`50`; the default is `16`. Higher values keep ratchet engaged longer. The slider is available only in ratchet mode without permanent ratchet.
* **Scrolling force** sets tunable ratchet torque from `1%` to `100%` of the wheel's maximum force. It appears only when `0x2111` advertises `TUNABLE_TORQUE`. It is disabled in free-spin mode.
* **Permanent ratchet** keeps the wheel ratcheted at every speed. It is available only in ratchet mode.
* **Thumb Wheel Sensitivity** is a per-device override. See [Scrolling](/docs/features/mouse/scrolling).

OpenLogi reads the current state when the panel opens. An offline device shows that SmartShift is unavailable. A pending read shows &#x2A;*Reading SmartShift settings…**. A failed read provides &#x2A;*Couldn't read SmartShift settings. Click to retry.**

## Write confirmation [#write-confirmation]

Since v0.8.14, OpenLogi confirms each SmartShift write with a device read. It marks the write confirmed only when the returned mode, `auto_disengage`, and `tunable_torque` match the requested state. A different reply or a failed read marks the write as failed.

## In the config [#in-the-config]

OpenLogi stores SmartShift under the physical device key:

```toml
schema_version = 9

[devices."unit:6be9d300".smartshift]
mode = "ratchet"
auto_disengage = 16     # 8–254; 255 keeps permanent ratchet
tunable_torque = 50     # 1–100 percent; omit on unsupported devices
```

The GUI exposes `8`–`50` for `auto_disengage`. The saved configuration accepts `8`–`254`; `255` means permanent ratchet. The device keeps these settings in volatile memory, so the agent re-applies the saved state after reconnecting.

## Command and API [#command-and-api]

Use the diagnostic command to set **Scrolling force** on a mouse that supports tunable torque:

```bash
openlogi diag smartshift --torque 60
```

`--torque` accepts `1`–`100` percent. The `openlogi api smartshift` result includes `tunable_torque` as a `1`–`100` percentage, or `null` when the device has no tunable torque. The same result includes `auto_disengage` as an integer.

**Status:** macOS, Linux, and Windows. SmartShift uses [`0x2111` smartShiftEnhanced](/hidpp/features/x2111-smartshift-enhanced) or [`0x2110` smartShiftWheel](/hidpp/features/x2110-smartshift). **Scrolling force** requires `0x2111` with `TUNABLE_TORQUE`.
