@openmouse/protocol 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +167 -0
- package/README.md +89 -0
- package/dist/asus/index.d.ts +52 -0
- package/dist/asus/index.d.ts.map +1 -0
- package/dist/asus/index.js +248 -0
- package/dist/asus/index.js.map +1 -0
- package/dist/atk/index.d.ts +200 -0
- package/dist/atk/index.d.ts.map +1 -0
- package/dist/atk/index.js +480 -0
- package/dist/atk/index.js.map +1 -0
- package/dist/bitmouse/index.d.ts +284 -0
- package/dist/bitmouse/index.d.ts.map +1 -0
- package/dist/bitmouse/index.js +396 -0
- package/dist/bitmouse/index.js.map +1 -0
- package/dist/compx/codec.d.ts +31 -0
- package/dist/compx/codec.d.ts.map +1 -0
- package/dist/compx/codec.js +50 -0
- package/dist/compx/codec.js.map +1 -0
- package/dist/corsair/index.d.ts +147 -0
- package/dist/corsair/index.d.ts.map +1 -0
- package/dist/corsair/index.js +223 -0
- package/dist/corsair/index.js.map +1 -0
- package/dist/dareu/index.d.ts +136 -0
- package/dist/dareu/index.d.ts.map +1 -0
- package/dist/dareu/index.js +344 -0
- package/dist/dareu/index.js.map +1 -0
- package/dist/drivers/asus/hid.d.ts +29 -0
- package/dist/drivers/asus/hid.d.ts.map +1 -0
- package/dist/drivers/asus/hid.js +419 -0
- package/dist/drivers/asus/hid.js.map +1 -0
- package/dist/drivers/atk/bitmouse-hid.d.ts +80 -0
- package/dist/drivers/atk/bitmouse-hid.d.ts.map +1 -0
- package/dist/drivers/atk/bitmouse-hid.js +501 -0
- package/dist/drivers/atk/bitmouse-hid.js.map +1 -0
- package/dist/drivers/atk/hid.d.ts +163 -0
- package/dist/drivers/atk/hid.d.ts.map +1 -0
- package/dist/drivers/atk/hid.js +1368 -0
- package/dist/drivers/atk/hid.js.map +1 -0
- package/dist/drivers/atk/products.d.ts +13 -0
- package/dist/drivers/atk/products.d.ts.map +1 -0
- package/dist/drivers/atk/products.js +13 -0
- package/dist/drivers/atk/products.js.map +1 -0
- package/dist/drivers/attackshark/dpi.d.ts +41 -0
- package/dist/drivers/attackshark/dpi.d.ts.map +1 -0
- package/dist/drivers/attackshark/dpi.js +215 -0
- package/dist/drivers/attackshark/dpi.js.map +1 -0
- package/dist/drivers/attackshark/hid.d.ts +124 -0
- package/dist/drivers/attackshark/hid.d.ts.map +1 -0
- package/dist/drivers/attackshark/hid.js +728 -0
- package/dist/drivers/attackshark/hid.js.map +1 -0
- package/dist/drivers/corsair/hid.d.ts +70 -0
- package/dist/drivers/corsair/hid.d.ts.map +1 -0
- package/dist/drivers/corsair/hid.js +414 -0
- package/dist/drivers/corsair/hid.js.map +1 -0
- package/dist/drivers/dareu/hid.d.ts +59 -0
- package/dist/drivers/dareu/hid.d.ts.map +1 -0
- package/dist/drivers/dareu/hid.js +478 -0
- package/dist/drivers/dareu/hid.js.map +1 -0
- package/dist/drivers/endgame/egg-op1-hid.d.ts +107 -0
- package/dist/drivers/endgame/egg-op1-hid.d.ts.map +1 -0
- package/dist/drivers/endgame/egg-op1-hid.js +605 -0
- package/dist/drivers/endgame/egg-op1-hid.js.map +1 -0
- package/dist/drivers/endgame/egg-we-control.d.ts +42 -0
- package/dist/drivers/endgame/egg-we-control.d.ts.map +1 -0
- package/dist/drivers/endgame/egg-we-control.js +87 -0
- package/dist/drivers/endgame/egg-we-control.js.map +1 -0
- package/dist/drivers/endgame/egg-we-hid.d.ts +58 -0
- package/dist/drivers/endgame/egg-we-hid.d.ts.map +1 -0
- package/dist/drivers/endgame/egg-we-hid.js +514 -0
- package/dist/drivers/endgame/egg-we-hid.js.map +1 -0
- package/dist/drivers/fantech/hid.d.ts +106 -0
- package/dist/drivers/fantech/hid.d.ts.map +1 -0
- package/dist/drivers/fantech/hid.js +295 -0
- package/dist/drivers/fantech/hid.js.map +1 -0
- package/dist/drivers/finalmouse/hid.d.ts +31 -0
- package/dist/drivers/finalmouse/hid.d.ts.map +1 -0
- package/dist/drivers/finalmouse/hid.js +181 -0
- package/dist/drivers/finalmouse/hid.js.map +1 -0
- package/dist/drivers/gearhub/hid.d.ts +191 -0
- package/dist/drivers/gearhub/hid.d.ts.map +1 -0
- package/dist/drivers/gearhub/hid.js +542 -0
- package/dist/drivers/gearhub/hid.js.map +1 -0
- package/dist/drivers/glorious/classic-hid.d.ts +62 -0
- package/dist/drivers/glorious/classic-hid.d.ts.map +1 -0
- package/dist/drivers/glorious/classic-hid.js +326 -0
- package/dist/drivers/glorious/classic-hid.js.map +1 -0
- package/dist/drivers/glorious/hid.d.ts +39 -0
- package/dist/drivers/glorious/hid.d.ts.map +1 -0
- package/dist/drivers/glorious/hid.js +203 -0
- package/dist/drivers/glorious/hid.js.map +1 -0
- package/dist/drivers/gwolves/hid.d.ts +27 -0
- package/dist/drivers/gwolves/hid.d.ts.map +1 -0
- package/dist/drivers/gwolves/hid.js +225 -0
- package/dist/drivers/gwolves/hid.js.map +1 -0
- package/dist/drivers/gwolves/products.d.ts +17 -0
- package/dist/drivers/gwolves/products.d.ts.map +1 -0
- package/dist/drivers/gwolves/products.js +58 -0
- package/dist/drivers/gwolves/products.js.map +1 -0
- package/dist/drivers/hyperx/hid.d.ts +59 -0
- package/dist/drivers/hyperx/hid.d.ts.map +1 -0
- package/dist/drivers/hyperx/hid.js +229 -0
- package/dist/drivers/hyperx/hid.js.map +1 -0
- package/dist/drivers/incott/hid.d.ts +513 -0
- package/dist/drivers/incott/hid.d.ts.map +1 -0
- package/dist/drivers/incott/hid.js +1173 -0
- package/dist/drivers/incott/hid.js.map +1 -0
- package/dist/drivers/index.d.ts +5 -0
- package/dist/drivers/index.d.ts.map +1 -0
- package/dist/drivers/index.js +5 -0
- package/dist/drivers/index.js.map +1 -0
- package/dist/drivers/keychron/m6-hid.d.ts +81 -0
- package/dist/drivers/keychron/m6-hid.d.ts.map +1 -0
- package/dist/drivers/keychron/m6-hid.js +486 -0
- package/dist/drivers/keychron/m6-hid.js.map +1 -0
- package/dist/drivers/keychron/nape-hid.d.ts +60 -0
- package/dist/drivers/keychron/nape-hid.d.ts.map +1 -0
- package/dist/drivers/keychron/nape-hid.js +432 -0
- package/dist/drivers/keychron/nape-hid.js.map +1 -0
- package/dist/drivers/ksnake/hid.d.ts +74 -0
- package/dist/drivers/ksnake/hid.d.ts.map +1 -0
- package/dist/drivers/ksnake/hid.js +368 -0
- package/dist/drivers/ksnake/hid.js.map +1 -0
- package/dist/drivers/lamzu/hid.d.ts +58 -0
- package/dist/drivers/lamzu/hid.d.ts.map +1 -0
- package/dist/drivers/lamzu/hid.js +414 -0
- package/dist/drivers/lamzu/hid.js.map +1 -0
- package/dist/drivers/lamzu-atlantis/hid.d.ts +148 -0
- package/dist/drivers/lamzu-atlantis/hid.d.ts.map +1 -0
- package/dist/drivers/lamzu-atlantis/hid.js +587 -0
- package/dist/drivers/lamzu-atlantis/hid.js.map +1 -0
- package/dist/drivers/logitech/bolt.d.ts +41 -0
- package/dist/drivers/logitech/bolt.d.ts.map +1 -0
- package/dist/drivers/logitech/bolt.js +105 -0
- package/dist/drivers/logitech/bolt.js.map +1 -0
- package/dist/drivers/logitech/hidpp.d.ts +525 -0
- package/dist/drivers/logitech/hidpp.d.ts.map +1 -0
- package/dist/drivers/logitech/hidpp.js +2774 -0
- package/dist/drivers/logitech/hidpp.js.map +1 -0
- package/dist/drivers/logitech/mode-status.d.ts +53 -0
- package/dist/drivers/logitech/mode-status.d.ts.map +1 -0
- package/dist/drivers/logitech/mode-status.js +52 -0
- package/dist/drivers/logitech/mode-status.js.map +1 -0
- package/dist/drivers/logitech/onboard-profiles.d.ts +298 -0
- package/dist/drivers/logitech/onboard-profiles.d.ts.map +1 -0
- package/dist/drivers/logitech/onboard-profiles.js +965 -0
- package/dist/drivers/logitech/onboard-profiles.js.map +1 -0
- package/dist/drivers/logitech/rgb-effects.d.ts +20 -0
- package/dist/drivers/logitech/rgb-effects.d.ts.map +1 -0
- package/dist/drivers/logitech/rgb-effects.js +93 -0
- package/dist/drivers/logitech/rgb-effects.js.map +1 -0
- package/dist/drivers/mchose/a5-gen1-hid.d.ts +36 -0
- package/dist/drivers/mchose/a5-gen1-hid.d.ts.map +1 -0
- package/dist/drivers/mchose/a5-gen1-hid.js +219 -0
- package/dist/drivers/mchose/a5-gen1-hid.js.map +1 -0
- package/dist/drivers/mchose/dock-hid.d.ts +26 -0
- package/dist/drivers/mchose/dock-hid.d.ts.map +1 -0
- package/dist/drivers/mchose/dock-hid.js +176 -0
- package/dist/drivers/mchose/dock-hid.js.map +1 -0
- package/dist/drivers/mchose/hid.d.ts +118 -0
- package/dist/drivers/mchose/hid.d.ts.map +1 -0
- package/dist/drivers/mchose/hid.js +550 -0
- package/dist/drivers/mchose/hid.js.map +1 -0
- package/dist/drivers/mchose/v3-hid.d.ts +109 -0
- package/dist/drivers/mchose/v3-hid.d.ts.map +1 -0
- package/dist/drivers/mchose/v3-hid.js +572 -0
- package/dist/drivers/mchose/v3-hid.js.map +1 -0
- package/dist/drivers/microsoft/hid.d.ts +25 -0
- package/dist/drivers/microsoft/hid.d.ts.map +1 -0
- package/dist/drivers/microsoft/hid.js +247 -0
- package/dist/drivers/microsoft/hid.js.map +1 -0
- package/dist/drivers/moddo/hid.d.ts +31 -0
- package/dist/drivers/moddo/hid.d.ts.map +1 -0
- package/dist/drivers/moddo/hid.js +154 -0
- package/dist/drivers/moddo/hid.js.map +1 -0
- package/dist/drivers/mouse-types.d.ts +388 -0
- package/dist/drivers/mouse-types.d.ts.map +1 -0
- package/dist/drivers/mouse-types.js +2 -0
- package/dist/drivers/mouse-types.js.map +1 -0
- package/dist/drivers/ninjutso/hid.d.ts +62 -0
- package/dist/drivers/ninjutso/hid.d.ts.map +1 -0
- package/dist/drivers/ninjutso/hid.js +680 -0
- package/dist/drivers/ninjutso/hid.js.map +1 -0
- package/dist/drivers/orbital/hid.d.ts +24 -0
- package/dist/drivers/orbital/hid.d.ts.map +1 -0
- package/dist/drivers/orbital/hid.js +85 -0
- package/dist/drivers/orbital/hid.js.map +1 -0
- package/dist/drivers/orbital/host-protocol.d.ts +59 -0
- package/dist/drivers/orbital/host-protocol.d.ts.map +1 -0
- package/dist/drivers/orbital/host-protocol.js +884 -0
- package/dist/drivers/orbital/host-protocol.js.map +1 -0
- package/dist/drivers/pulsar/pulsar-hid.d.ts +63 -0
- package/dist/drivers/pulsar/pulsar-hid.d.ts.map +1 -0
- package/dist/drivers/pulsar/pulsar-hid.js +381 -0
- package/dist/drivers/pulsar/pulsar-hid.js.map +1 -0
- package/dist/drivers/pulsar/pulsar-pro-hid.d.ts +46 -0
- package/dist/drivers/pulsar/pulsar-pro-hid.d.ts.map +1 -0
- package/dist/drivers/pulsar/pulsar-pro-hid.js +343 -0
- package/dist/drivers/pulsar/pulsar-pro-hid.js.map +1 -0
- package/dist/drivers/pulsar/pulsar-xs1-hid.d.ts +40 -0
- package/dist/drivers/pulsar/pulsar-xs1-hid.d.ts.map +1 -0
- package/dist/drivers/pulsar/pulsar-xs1-hid.js +251 -0
- package/dist/drivers/pulsar/pulsar-xs1-hid.js.map +1 -0
- package/dist/drivers/rawm/hid.d.ts +29 -0
- package/dist/drivers/rawm/hid.d.ts.map +1 -0
- package/dist/drivers/rawm/hid.js +210 -0
- package/dist/drivers/rawm/hid.js.map +1 -0
- package/dist/drivers/razer/cobra-hid.d.ts +63 -0
- package/dist/drivers/razer/cobra-hid.d.ts.map +1 -0
- package/dist/drivers/razer/cobra-hid.js +282 -0
- package/dist/drivers/razer/cobra-hid.js.map +1 -0
- package/dist/drivers/razer/hid-open.d.ts +13 -0
- package/dist/drivers/razer/hid-open.d.ts.map +1 -0
- package/dist/drivers/razer/hid-open.js +29 -0
- package/dist/drivers/razer/hid-open.js.map +1 -0
- package/dist/drivers/razer/hid.d.ts +231 -0
- package/dist/drivers/razer/hid.d.ts.map +1 -0
- package/dist/drivers/razer/hid.js +772 -0
- package/dist/drivers/razer/hid.js.map +1 -0
- package/dist/drivers/razer/viper-hid.d.ts +33 -0
- package/dist/drivers/razer/viper-hid.d.ts.map +1 -0
- package/dist/drivers/razer/viper-hid.js +177 -0
- package/dist/drivers/razer/viper-hid.js.map +1 -0
- package/dist/drivers/razer/viper-mini-hid.d.ts +58 -0
- package/dist/drivers/razer/viper-mini-hid.d.ts.map +1 -0
- package/dist/drivers/razer/viper-mini-hid.js +273 -0
- package/dist/drivers/razer/viper-mini-hid.js.map +1 -0
- package/dist/drivers/razer/viper-v4-pro-hid.d.ts +26 -0
- package/dist/drivers/razer/viper-v4-pro-hid.d.ts.map +1 -0
- package/dist/drivers/razer/viper-v4-pro-hid.js +154 -0
- package/dist/drivers/razer/viper-v4-pro-hid.js.map +1 -0
- package/dist/drivers/registry.d.ts +70 -0
- package/dist/drivers/registry.d.ts.map +1 -0
- package/dist/drivers/registry.js +146 -0
- package/dist/drivers/registry.js.map +1 -0
- package/dist/drivers/steelseries/aerox3-hid.d.ts +53 -0
- package/dist/drivers/steelseries/aerox3-hid.d.ts.map +1 -0
- package/dist/drivers/steelseries/aerox3-hid.js +172 -0
- package/dist/drivers/steelseries/aerox3-hid.js.map +1 -0
- package/dist/drivers/steelseries/aerox5-hid.d.ts +54 -0
- package/dist/drivers/steelseries/aerox5-hid.d.ts.map +1 -0
- package/dist/drivers/steelseries/aerox5-hid.js +173 -0
- package/dist/drivers/steelseries/aerox5-hid.js.map +1 -0
- package/dist/drivers/steelseries/aerox5-wireless-hid.d.ts +69 -0
- package/dist/drivers/steelseries/aerox5-wireless-hid.d.ts.map +1 -0
- package/dist/drivers/steelseries/aerox5-wireless-hid.js +234 -0
- package/dist/drivers/steelseries/aerox5-wireless-hid.js.map +1 -0
- package/dist/drivers/steelseries/aerox9-wireless-hid.d.ts +70 -0
- package/dist/drivers/steelseries/aerox9-wireless-hid.d.ts.map +1 -0
- package/dist/drivers/steelseries/aerox9-wireless-hid.js +227 -0
- package/dist/drivers/steelseries/aerox9-wireless-hid.js.map +1 -0
- package/dist/drivers/steelseries/hid.d.ts +48 -0
- package/dist/drivers/steelseries/hid.d.ts.map +1 -0
- package/dist/drivers/steelseries/hid.js +157 -0
- package/dist/drivers/steelseries/hid.js.map +1 -0
- package/dist/drivers/steelseries/prime-mini-wireless-hid.d.ts +62 -0
- package/dist/drivers/steelseries/prime-mini-wireless-hid.d.ts.map +1 -0
- package/dist/drivers/steelseries/prime-mini-wireless-hid.js +215 -0
- package/dist/drivers/steelseries/prime-mini-wireless-hid.js.map +1 -0
- package/dist/drivers/steelseries/prime-plus-hid.d.ts +51 -0
- package/dist/drivers/steelseries/prime-plus-hid.d.ts.map +1 -0
- package/dist/drivers/steelseries/prime-plus-hid.js +150 -0
- package/dist/drivers/steelseries/prime-plus-hid.js.map +1 -0
- package/dist/drivers/steelseries/rival3-wireless-hid.d.ts +61 -0
- package/dist/drivers/steelseries/rival3-wireless-hid.d.ts.map +1 -0
- package/dist/drivers/steelseries/rival3-wireless-hid.js +185 -0
- package/dist/drivers/steelseries/rival3-wireless-hid.js.map +1 -0
- package/dist/drivers/steelseries/rival310-hid.d.ts +59 -0
- package/dist/drivers/steelseries/rival310-hid.d.ts.map +1 -0
- package/dist/drivers/steelseries/rival310-hid.js +182 -0
- package/dist/drivers/steelseries/rival310-hid.js.map +1 -0
- package/dist/drivers/steelseries/rival650-hid.d.ts +68 -0
- package/dist/drivers/steelseries/rival650-hid.d.ts.map +1 -0
- package/dist/drivers/steelseries/rival650-hid.js +209 -0
- package/dist/drivers/steelseries/rival650-hid.js.map +1 -0
- package/dist/drivers/steelseries/sensei-ten-hid.d.ts +62 -0
- package/dist/drivers/steelseries/sensei-ten-hid.d.ts.map +1 -0
- package/dist/drivers/steelseries/sensei-ten-hid.js +186 -0
- package/dist/drivers/steelseries/sensei-ten-hid.js.map +1 -0
- package/dist/drivers/teevolution/hid.d.ts +82 -0
- package/dist/drivers/teevolution/hid.d.ts.map +1 -0
- package/dist/drivers/teevolution/hid.js +607 -0
- package/dist/drivers/teevolution/hid.js.map +1 -0
- package/dist/drivers/vendors.d.ts +254 -0
- package/dist/drivers/vendors.d.ts.map +1 -0
- package/dist/drivers/vendors.js +587 -0
- package/dist/drivers/vendors.js.map +1 -0
- package/dist/drivers/vgn/hid.d.ts +33 -0
- package/dist/drivers/vgn/hid.d.ts.map +1 -0
- package/dist/drivers/vgn/hid.js +213 -0
- package/dist/drivers/vgn/hid.js.map +1 -0
- package/dist/drivers/wallhack/keyboard-hid.d.ts +28 -0
- package/dist/drivers/wallhack/keyboard-hid.d.ts.map +1 -0
- package/dist/drivers/wallhack/keyboard-hid.js +80 -0
- package/dist/drivers/wallhack/keyboard-hid.js.map +1 -0
- package/dist/drivers/wallhack/mouse-hid.d.ts +45 -0
- package/dist/drivers/wallhack/mouse-hid.d.ts.map +1 -0
- package/dist/drivers/wallhack/mouse-hid.js +288 -0
- package/dist/drivers/wallhack/mouse-hid.js.map +1 -0
- package/dist/drivers/webhid.d.ts +59 -0
- package/dist/drivers/webhid.d.ts.map +1 -0
- package/dist/drivers/webhid.js +2 -0
- package/dist/drivers/webhid.js.map +1 -0
- package/dist/drivers/wlmouse/hid.d.ts +81 -0
- package/dist/drivers/wlmouse/hid.d.ts.map +1 -0
- package/dist/drivers/wlmouse/hid.js +576 -0
- package/dist/drivers/wlmouse/hid.js.map +1 -0
- package/dist/drivers/wooting/hid.d.ts +86 -0
- package/dist/drivers/wooting/hid.d.ts.map +1 -0
- package/dist/drivers/wooting/hid.js +320 -0
- package/dist/drivers/wooting/hid.js.map +1 -0
- package/dist/drivers/zaunkoenig/hid.d.ts +31 -0
- package/dist/drivers/zaunkoenig/hid.d.ts.map +1 -0
- package/dist/drivers/zaunkoenig/hid.js +162 -0
- package/dist/drivers/zaunkoenig/hid.js.map +1 -0
- package/dist/endgame-gear/op1.d.ts +165 -0
- package/dist/endgame-gear/op1.d.ts.map +1 -0
- package/dist/endgame-gear/op1.js +384 -0
- package/dist/endgame-gear/op1.js.map +1 -0
- package/dist/endgame-gear/wireless.d.ts +93 -0
- package/dist/endgame-gear/wireless.d.ts.map +1 -0
- package/dist/endgame-gear/wireless.js +242 -0
- package/dist/endgame-gear/wireless.js.map +1 -0
- package/dist/fantech/index.d.ts +9 -0
- package/dist/fantech/index.d.ts.map +1 -0
- package/dist/fantech/index.js +11 -0
- package/dist/fantech/index.js.map +1 -0
- package/dist/finalmouse/index.d.ts +41 -0
- package/dist/finalmouse/index.d.ts.map +1 -0
- package/dist/finalmouse/index.js +106 -0
- package/dist/finalmouse/index.js.map +1 -0
- package/dist/gearhub/index.d.ts +210 -0
- package/dist/gearhub/index.d.ts.map +1 -0
- package/dist/gearhub/index.js +282 -0
- package/dist/gearhub/index.js.map +1 -0
- package/dist/glorious/index.d.ts +89 -0
- package/dist/glorious/index.d.ts.map +1 -0
- package/dist/glorious/index.js +236 -0
- package/dist/glorious/index.js.map +1 -0
- package/dist/glorious-classic/index.d.ts +103 -0
- package/dist/glorious-classic/index.d.ts.map +1 -0
- package/dist/glorious-classic/index.js +304 -0
- package/dist/glorious-classic/index.js.map +1 -0
- package/dist/gwolves/index.d.ts +82 -0
- package/dist/gwolves/index.d.ts.map +1 -0
- package/dist/gwolves/index.js +197 -0
- package/dist/gwolves/index.js.map +1 -0
- package/dist/hyperx/index.d.ts +181 -0
- package/dist/hyperx/index.d.ts.map +1 -0
- package/dist/hyperx/index.js +303 -0
- package/dist/hyperx/index.js.map +1 -0
- package/dist/incott/index.d.ts +1067 -0
- package/dist/incott/index.d.ts.map +1 -0
- package/dist/incott/index.js +1482 -0
- package/dist/incott/index.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/keychron/index.d.ts +194 -0
- package/dist/keychron/index.d.ts.map +1 -0
- package/dist/keychron/index.js +341 -0
- package/dist/keychron/index.js.map +1 -0
- package/dist/ksnake/index.d.ts +157 -0
- package/dist/ksnake/index.d.ts.map +1 -0
- package/dist/ksnake/index.js +314 -0
- package/dist/ksnake/index.js.map +1 -0
- package/dist/lamzu/atlantis.d.ts +237 -0
- package/dist/lamzu/atlantis.d.ts.map +1 -0
- package/dist/lamzu/atlantis.js +263 -0
- package/dist/lamzu/atlantis.js.map +1 -0
- package/dist/lamzu/index.d.ts +60 -0
- package/dist/lamzu/index.d.ts.map +1 -0
- package/dist/lamzu/index.js +86 -0
- package/dist/lamzu/index.js.map +1 -0
- package/dist/logitech/controls.d.ts +138 -0
- package/dist/logitech/controls.d.ts.map +1 -0
- package/dist/logitech/controls.js +184 -0
- package/dist/logitech/controls.js.map +1 -0
- package/dist/logitech/friendly-name.d.ts +50 -0
- package/dist/logitech/friendly-name.d.ts.map +1 -0
- package/dist/logitech/friendly-name.js +74 -0
- package/dist/logitech/friendly-name.js.map +1 -0
- package/dist/logitech/haptics.d.ts +97 -0
- package/dist/logitech/haptics.d.ts.map +1 -0
- package/dist/logitech/haptics.js +116 -0
- package/dist/logitech/haptics.js.map +1 -0
- package/dist/logitech/hosts.d.ts +55 -0
- package/dist/logitech/hosts.d.ts.map +1 -0
- package/dist/logitech/hosts.js +80 -0
- package/dist/logitech/hosts.js.map +1 -0
- package/dist/logitech/index.d.ts +127 -0
- package/dist/logitech/index.d.ts.map +1 -0
- package/dist/logitech/index.js +233 -0
- package/dist/logitech/index.js.map +1 -0
- package/dist/logitech/wheel.d.ts +117 -0
- package/dist/logitech/wheel.d.ts.map +1 -0
- package/dist/logitech/wheel.js +133 -0
- package/dist/logitech/wheel.js.map +1 -0
- package/dist/mchose/a5-gen1.d.ts +38 -0
- package/dist/mchose/a5-gen1.d.ts.map +1 -0
- package/dist/mchose/a5-gen1.js +60 -0
- package/dist/mchose/a5-gen1.js.map +1 -0
- package/dist/mchose/buttons.d.ts +66 -0
- package/dist/mchose/buttons.d.ts.map +1 -0
- package/dist/mchose/buttons.js +184 -0
- package/dist/mchose/buttons.js.map +1 -0
- package/dist/mchose/dock.d.ts +77 -0
- package/dist/mchose/dock.d.ts.map +1 -0
- package/dist/mchose/dock.js +133 -0
- package/dist/mchose/dock.js.map +1 -0
- package/dist/mchose/index.d.ts +299 -0
- package/dist/mchose/index.d.ts.map +1 -0
- package/dist/mchose/index.js +429 -0
- package/dist/mchose/index.js.map +1 -0
- package/dist/mchose/v3-buttons.d.ts +64 -0
- package/dist/mchose/v3-buttons.d.ts.map +1 -0
- package/dist/mchose/v3-buttons.js +258 -0
- package/dist/mchose/v3-buttons.js.map +1 -0
- package/dist/mchose/v3.d.ts +380 -0
- package/dist/mchose/v3.d.ts.map +1 -0
- package/dist/mchose/v3.js +608 -0
- package/dist/mchose/v3.js.map +1 -0
- package/dist/microsoft/index.d.ts +19 -0
- package/dist/microsoft/index.d.ts.map +1 -0
- package/dist/microsoft/index.js +22 -0
- package/dist/microsoft/index.js.map +1 -0
- package/dist/moddo/index.d.ts +72 -0
- package/dist/moddo/index.d.ts.map +1 -0
- package/dist/moddo/index.js +147 -0
- package/dist/moddo/index.js.map +1 -0
- package/dist/ninjutso/index.d.ts +74 -0
- package/dist/ninjutso/index.d.ts.map +1 -0
- package/dist/ninjutso/index.js +133 -0
- package/dist/ninjutso/index.js.map +1 -0
- package/dist/orbital/index.d.ts +14 -0
- package/dist/orbital/index.d.ts.map +1 -0
- package/dist/orbital/index.js +19 -0
- package/dist/orbital/index.js.map +1 -0
- package/dist/pulsar/index.d.ts +81 -0
- package/dist/pulsar/index.d.ts.map +1 -0
- package/dist/pulsar/index.js +185 -0
- package/dist/pulsar/index.js.map +1 -0
- package/dist/rawm/index.d.ts +55 -0
- package/dist/rawm/index.d.ts.map +1 -0
- package/dist/rawm/index.js +229 -0
- package/dist/rawm/index.js.map +1 -0
- package/dist/razer/codec.d.ts +504 -0
- package/dist/razer/codec.d.ts.map +1 -0
- package/dist/razer/codec.js +648 -0
- package/dist/razer/codec.js.map +1 -0
- package/dist/razer/devices.d.ts +156 -0
- package/dist/razer/devices.d.ts.map +1 -0
- package/dist/razer/devices.js +540 -0
- package/dist/razer/devices.js.map +1 -0
- package/dist/razer/index.d.ts +2 -0
- package/dist/razer/index.d.ts.map +1 -0
- package/dist/razer/index.js +2 -0
- package/dist/razer/index.js.map +1 -0
- package/dist/razer/v4.d.ts +19 -0
- package/dist/razer/v4.d.ts.map +1 -0
- package/dist/razer/v4.js +51 -0
- package/dist/razer/v4.js.map +1 -0
- package/dist/steelseries/aerox3.d.ts +236 -0
- package/dist/steelseries/aerox3.d.ts.map +1 -0
- package/dist/steelseries/aerox3.js +311 -0
- package/dist/steelseries/aerox3.js.map +1 -0
- package/dist/steelseries/aerox5-wireless.d.ts +300 -0
- package/dist/steelseries/aerox5-wireless.d.ts.map +1 -0
- package/dist/steelseries/aerox5-wireless.js +389 -0
- package/dist/steelseries/aerox5-wireless.js.map +1 -0
- package/dist/steelseries/aerox5.d.ts +228 -0
- package/dist/steelseries/aerox5.d.ts.map +1 -0
- package/dist/steelseries/aerox5.js +294 -0
- package/dist/steelseries/aerox5.js.map +1 -0
- package/dist/steelseries/aerox9-wireless.d.ts +213 -0
- package/dist/steelseries/aerox9-wireless.d.ts.map +1 -0
- package/dist/steelseries/aerox9-wireless.js +305 -0
- package/dist/steelseries/aerox9-wireless.js.map +1 -0
- package/dist/steelseries/devices.d.ts +82 -0
- package/dist/steelseries/devices.d.ts.map +1 -0
- package/dist/steelseries/devices.js +310 -0
- package/dist/steelseries/devices.js.map +1 -0
- package/dist/steelseries/index.d.ts +13 -0
- package/dist/steelseries/index.d.ts.map +1 -0
- package/dist/steelseries/index.js +13 -0
- package/dist/steelseries/index.js.map +1 -0
- package/dist/steelseries/prime-mini-wireless.d.ts +253 -0
- package/dist/steelseries/prime-mini-wireless.d.ts.map +1 -0
- package/dist/steelseries/prime-mini-wireless.js +351 -0
- package/dist/steelseries/prime-mini-wireless.js.map +1 -0
- package/dist/steelseries/prime-plus.d.ts +179 -0
- package/dist/steelseries/prime-plus.d.ts.map +1 -0
- package/dist/steelseries/prime-plus.js +275 -0
- package/dist/steelseries/prime-plus.js.map +1 -0
- package/dist/steelseries/rival3-wireless.d.ts +222 -0
- package/dist/steelseries/rival3-wireless.d.ts.map +1 -0
- package/dist/steelseries/rival3-wireless.js +308 -0
- package/dist/steelseries/rival3-wireless.js.map +1 -0
- package/dist/steelseries/rival3.d.ts +99 -0
- package/dist/steelseries/rival3.d.ts.map +1 -0
- package/dist/steelseries/rival3.js +147 -0
- package/dist/steelseries/rival3.js.map +1 -0
- package/dist/steelseries/rival310.d.ts +230 -0
- package/dist/steelseries/rival310.d.ts.map +1 -0
- package/dist/steelseries/rival310.js +322 -0
- package/dist/steelseries/rival310.js.map +1 -0
- package/dist/steelseries/rival650.d.ts +215 -0
- package/dist/steelseries/rival650.d.ts.map +1 -0
- package/dist/steelseries/rival650.js +284 -0
- package/dist/steelseries/rival650.js.map +1 -0
- package/dist/steelseries/sensei-ten.d.ts +283 -0
- package/dist/steelseries/sensei-ten.d.ts.map +1 -0
- package/dist/steelseries/sensei-ten.js +389 -0
- package/dist/steelseries/sensei-ten.js.map +1 -0
- package/dist/teevolution/index.d.ts +211 -0
- package/dist/teevolution/index.d.ts.map +1 -0
- package/dist/teevolution/index.js +458 -0
- package/dist/teevolution/index.js.map +1 -0
- package/dist/valkyrie/index.d.ts +19 -0
- package/dist/valkyrie/index.d.ts.map +1 -0
- package/dist/valkyrie/index.js +43 -0
- package/dist/valkyrie/index.js.map +1 -0
- package/dist/valkyrie/settings.d.ts +28 -0
- package/dist/valkyrie/settings.d.ts.map +1 -0
- package/dist/valkyrie/settings.js +84 -0
- package/dist/valkyrie/settings.js.map +1 -0
- package/dist/vgn/index.d.ts +59 -0
- package/dist/vgn/index.d.ts.map +1 -0
- package/dist/vgn/index.js +165 -0
- package/dist/vgn/index.js.map +1 -0
- package/dist/wallhack/index.d.ts +165 -0
- package/dist/wallhack/index.d.ts.map +1 -0
- package/dist/wallhack/index.js +226 -0
- package/dist/wallhack/index.js.map +1 -0
- package/dist/wlmouse/index.d.ts +4 -0
- package/dist/wlmouse/index.d.ts.map +1 -0
- package/dist/wlmouse/index.js +7 -0
- package/dist/wlmouse/index.js.map +1 -0
- package/dist/wooting/index.d.ts +225 -0
- package/dist/wooting/index.d.ts.map +1 -0
- package/dist/wooting/index.js +296 -0
- package/dist/wooting/index.js.map +1 -0
- package/dist/zaunkoenig/index.d.ts +49 -0
- package/dist/zaunkoenig/index.d.ts.map +1 -0
- package/dist/zaunkoenig/index.js +127 -0
- package/dist/zaunkoenig/index.js.map +1 -0
- package/package.json +193 -0
|
@@ -0,0 +1,2774 @@
|
|
|
1
|
+
import { LOGITECH_BLUETOOTH_FILTERS, LOGITECH_RECEIVER_FILTERS, LOGITECH_RECEIVER_PRODUCT_IDS } from "../vendors.js";
|
|
2
|
+
import { BOLT_INDEX_PROBE_TIMEOUT_MS, boltSupportScore, classifyHidpp20Probe, collapseBoltPeers, hasHidppBluetoothCollection, hasHidppLongCollection, hasHidppShortCollection, resolveBoltReportDevice, } from "./bolt.js";
|
|
3
|
+
import { LOGITECH_CHANGE_HOST, LOGITECH_FRIENDLY_NAME, LOGITECH_KEY_FLAG, LOGITECH_REPROG_CONTROLS, buildControlDiversionClearWrite, buildControlRemapWrite, decodeControlInfo, decodeControlReporting, logitechControlName, logitechTaskName, remappableControlTargets, LOGITECH_HAPTIC, LOGITECH_HAPTIC_EFFECTS, LOGITECH_HIRES_WHEEL, LOGITECH_HIRES_WHEEL_BIT, LOGITECH_HOSTS, LOGITECH_SMART_SHIFT, LOGITECH_SMART_SHIFT_OFF, LOGITECH_THUMB_WHEEL, buildFriendlyNameWrite, buildRatchetControlWrite, buildThumbWheelWrite, decodeHiresWheelCapabilities, decodeHiresWheelMode, decodeRatchetControl, decodeThumbWheelStatus, decodeThumbWheelSupportsInvert, encodeHiresWheelMode, buildHostSwitchWrite, decodeFriendlyNameChunk, decodeFriendlyNameLengths, decodeFriendlyNameText, decodeHostPaired, decodeHostsInfo, rejectFriendlyName, rejectHostSwitch, buildHapticConfigWrite, decodeHapticConfig, encodeHapticFlags, isLogitechHapticEffect, isLogitechHapticIntensity, DEVICE_INDEX_DIRECT, decodeBatteryLevelState, decodeReportRateBitmap, decodeUnifiedBatteryState, hidppDeviceIndexCandidates, hidppErrorForRequest, isBoltReceiverProduct, isDirectConnectProduct, isDirectConnection, OnboardOnlyError, withSoftwareId, } from "@openmouse/protocol/logitech";
|
|
4
|
+
export { collapseBoltPeers, hasHidppBluetoothCollection, hasHidppLongCollection, hasHidppShortCollection, } from "./bolt.js";
|
|
5
|
+
import { MODE_STATUS, buildModeStatusWriteMany, decodeModeStatus, } from "./mode-status.js";
|
|
6
|
+
import { ONBOARD_MODE, PROFILE_FN, PROFILE_NAME_MAX_CHARS, applyCrc, capabilitiesForFormat, decodeLiftOffLevel, decodeOnboardProfile, describeProfileFormat, dpiStageCapabilitiesForOptions, isProfileWritable, isLodWritableForProduct, encodeDpiStages, encodeButtonAssignment, encodeMacroButtonAssignment, encodeMacroSector, encodeProfileName, encodeReportRate, factoryProfileForFormat, validateDpiStagePlan, layoutForFormat, parseDirectory, parseProfilesInfo, profileCrc, reportRatesForDevice, setDirectoryEnabled, storedCrc, supportsProfileWriteProbe, validateBunnyHoppingMs, } from "./onboard-profiles.js";
|
|
7
|
+
import { encodeLogitechRgbEffect, encodeLogitechColorLedEffect, logitechColorLedLighting, logitechRgbLighting, } from "./rgb-effects.js";
|
|
8
|
+
/**
|
|
9
|
+
* Master switch for writing DPI slots into profile flash.
|
|
10
|
+
*
|
|
11
|
+
* Enabled after probeProfileWriteSequence passed on a Pro X Superlight 2
|
|
12
|
+
* (format 7): writing a changed byte to sector 0x0002 appeared on read-back,
|
|
13
|
+
* and restoring it appeared too, both with valid checksums. That proves the
|
|
14
|
+
* memoryAddrWrite/memoryWrite/memoryWriteEnd sequence lands rather than being
|
|
15
|
+
* silently dropped. Run the probe again before trusting a new format.
|
|
16
|
+
*/
|
|
17
|
+
export const PROFILE_DPI_WRITES_ENABLED = true;
|
|
18
|
+
/**
|
|
19
|
+
* A Logitech device that speaks HID++ but exposes no sensor, so it is not a
|
|
20
|
+
* mouse. Its own type so the shell can report it as a wrong choice rather than
|
|
21
|
+
* as a device that failed to read.
|
|
22
|
+
*/
|
|
23
|
+
/** No reply within the request window — nothing is listening on this index. */
|
|
24
|
+
class HidppTimeoutError extends Error {
|
|
25
|
+
constructor(message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "HidppTimeoutError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The mouse answered the live report-rate write with an explicit HID++
|
|
32
|
+
* rejection (as opposed to a timeout, or a confirmation that never arrived).
|
|
33
|
+
* setPollingRate uses this to decide whether falling back to the onboard
|
|
34
|
+
* profile write is safe — only a genuine rejection means the live write did
|
|
35
|
+
* not take effect.
|
|
36
|
+
*/
|
|
37
|
+
class LiveRateWriteRejectedError extends Error {
|
|
38
|
+
constructor(cause) {
|
|
39
|
+
super(cause instanceof Error ? cause.message : "The mouse rejected the live report-rate write.");
|
|
40
|
+
this.name = "LiveRateWriteRejectedError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export class NotAMouseError extends Error {
|
|
44
|
+
constructor(name) {
|
|
45
|
+
super(`${name} is not a mouse — it speaks Logitech's protocol but has no sensor to configure. Pick your mouse, or its receiver, instead.`);
|
|
46
|
+
this.name = "NotAMouseError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const LOGITECH_VENDOR_ID = 0x046d;
|
|
50
|
+
const KNOWN_RECEIVER_PRODUCT_IDS = new Set(LOGITECH_RECEIVER_PRODUCT_IDS);
|
|
51
|
+
/**
|
|
52
|
+
* Models whose 0x8090 mode-status feature drives the power-mode switch only.
|
|
53
|
+
* The status1 byte that carries the gaming-surface and LightForce fields is
|
|
54
|
+
* reserved on them and reads 0, which would otherwise decode as "Auto" and
|
|
55
|
+
* "Optical" and offer controls the mouse does not have. Keyed on the firmware
|
|
56
|
+
* model id, like the Superstrike detection.
|
|
57
|
+
*/
|
|
58
|
+
const MODE_STATUS_POWER_ONLY_MODEL_IDS = new Set([
|
|
59
|
+
"B03C40B10000", // G309 LIGHTSPEED
|
|
60
|
+
"407400000000", // G305 LIGHTSPEED
|
|
61
|
+
]);
|
|
62
|
+
/** Whether 0x8090 on this model is the power-mode-only variant. */
|
|
63
|
+
export function isPowerOnlyModeStatus(modelId) {
|
|
64
|
+
return MODE_STATUS_POWER_ONLY_MODEL_IDS.has(modelId ?? "");
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Whether the mouse exposes lift-off levels it can drive: only extended DPI
|
|
68
|
+
* (0x2202) carries one, and its byte 0 is the "no lift-off control" value, so
|
|
69
|
+
* a legacy-DPI mouse or a sensor reporting 0 advertises no levels.
|
|
70
|
+
*/
|
|
71
|
+
export function hasLiftOffControl(legacyDpi, lodByte) {
|
|
72
|
+
return !legacyDpi && lodByte !== null && lodByte !== 0;
|
|
73
|
+
}
|
|
74
|
+
const SHORT_REPORT_ID = 0x10;
|
|
75
|
+
const LONG_REPORT_ID = 0x11;
|
|
76
|
+
const REQUEST_TIMEOUT_MS = 6000;
|
|
77
|
+
/**
|
|
78
|
+
* A host switch succeeds by disconnecting, so the usual request timeout would
|
|
79
|
+
* spend six seconds waiting for an answer that cannot arrive before concluding
|
|
80
|
+
* the command worked. Long enough for a device that stays to acknowledge.
|
|
81
|
+
*/
|
|
82
|
+
const HOST_SWITCH_ACK_TIMEOUT_MS = 1500;
|
|
83
|
+
const FEATURE = {
|
|
84
|
+
deviceName: 0x0005,
|
|
85
|
+
firmware: 0x0003,
|
|
86
|
+
unifiedBattery: 0x1004,
|
|
87
|
+
// Pre-unified battery reporting, still the only one on HERO-era mice.
|
|
88
|
+
batteryStatus: 0x1000,
|
|
89
|
+
batteryVoltage: 0x1001,
|
|
90
|
+
adcMeasurement: 0x1f20,
|
|
91
|
+
extendedDpi: 0x2202,
|
|
92
|
+
extendedReportRate: 0x8061,
|
|
93
|
+
modeStatus: 0x8090,
|
|
94
|
+
rgbEffects: 0x8071,
|
|
95
|
+
perKeyLightingV2: 0x8081,
|
|
96
|
+
colorLedEffects: 0x8070,
|
|
97
|
+
// Legacy features used by HERO-era mice (e.g. G502 HERO / LIGHTSPEED,
|
|
98
|
+
// Proteus). Queried only when the extended equivalents are absent.
|
|
99
|
+
adjustableDpi: 0x2201,
|
|
100
|
+
reportRate: 0x8060,
|
|
101
|
+
onboardProfiles: 0x8100,
|
|
102
|
+
analogButtons: 0x1b0c,
|
|
103
|
+
haptic: 0x19b0,
|
|
104
|
+
smartShift: 0x2111,
|
|
105
|
+
hiresWheel: 0x2121,
|
|
106
|
+
thumbWheel: 0x2150,
|
|
107
|
+
friendlyName: 0x0007,
|
|
108
|
+
hostsInfo: 0x1815,
|
|
109
|
+
changeHost: 0x1814,
|
|
110
|
+
reprogControls: 0x1b04,
|
|
111
|
+
};
|
|
112
|
+
const REPORT_RATE_HZ = [125, 250, 500, 1000, 2000, 4000, 8000];
|
|
113
|
+
/**
|
|
114
|
+
* Extended DPI alone does not imply adjustable lift-off distance. Some mice,
|
|
115
|
+
* including the G309, expose 0x2202 but return the protocol's "no LOD control"
|
|
116
|
+
* value. Legacy DPI never carries LOD either.
|
|
117
|
+
*/
|
|
118
|
+
export function supportsLiveLiftOffControl(legacyDpi, liftOffDistance) {
|
|
119
|
+
return !legacyDpi && liftOffDistance !== null;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Resolves the active physical link from HID++ identity transport IDs. A
|
|
123
|
+
* receiver's USB PID does not match the paired mouse's Wireless transport ID;
|
|
124
|
+
* a mouse connected by cable matches its own USB transport ID. Direct-index
|
|
125
|
+
* probing remains the fallback for older devices that omit identity data.
|
|
126
|
+
*/
|
|
127
|
+
export function isWiredHidppConnection(productId, transportIds, directIndex) {
|
|
128
|
+
const activeId = productId.toString(16).padStart(4, "0").toUpperCase();
|
|
129
|
+
const activeTransport = Object.entries(transportIds)
|
|
130
|
+
.find(([, transportId]) => transportId.toUpperCase() === activeId)?.[0];
|
|
131
|
+
if (activeTransport !== undefined)
|
|
132
|
+
return activeTransport === "USB";
|
|
133
|
+
return directIndex;
|
|
134
|
+
}
|
|
135
|
+
const BATTERY_VOLTAGE_CURVE = [
|
|
136
|
+
[4186, 100], [4067, 90], [3989, 80], [3922, 70], [3859, 60],
|
|
137
|
+
[3811, 50], [3778, 40], [3751, 30], [3717, 20], [3671, 10],
|
|
138
|
+
[3646, 5], [3579, 2], [3500, 0],
|
|
139
|
+
];
|
|
140
|
+
export class LogitechHidppClient {
|
|
141
|
+
dpiOptionsCache = null;
|
|
142
|
+
dpiFeatureResolved = null;
|
|
143
|
+
rateFeatureResolved = null;
|
|
144
|
+
reportRateFeatureIndex = null;
|
|
145
|
+
supportedPollingRatesCache = null;
|
|
146
|
+
livePollingRateHz = null;
|
|
147
|
+
/** Discovered by resolveDeviceIndex; null until the mouse has answered. */
|
|
148
|
+
resolvedDeviceIndex = null;
|
|
149
|
+
/** Last format read from 0x8100, so a refusal can name it. */
|
|
150
|
+
profileFormatId = null;
|
|
151
|
+
wiredConnection = false;
|
|
152
|
+
/** Lift-off levels this device advertised; the single source of truth for both UI and validation. */
|
|
153
|
+
lodCapabilities = capabilitiesForFormat(null);
|
|
154
|
+
supportedLods = ["Medium", "High"];
|
|
155
|
+
rgbZone = null;
|
|
156
|
+
colorLedZones = [];
|
|
157
|
+
rgbLighting = null;
|
|
158
|
+
rgbClaimed = false;
|
|
159
|
+
rgbOriginalMode = "Unknown";
|
|
160
|
+
rateChangeWaiters = [];
|
|
161
|
+
/**
|
|
162
|
+
* Device used for HID++ feature sendReport. On Bolt this is the long-report
|
|
163
|
+
* collection (usage 2); elsewhere it is `device` itself.
|
|
164
|
+
*/
|
|
165
|
+
ioDevice = null;
|
|
166
|
+
listeningDevices = new Set();
|
|
167
|
+
onInputReport = (event) => {
|
|
168
|
+
if (event.reportId !== SHORT_REPORT_ID && event.reportId !== LONG_REPORT_ID) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const report = new Uint8Array(event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength));
|
|
172
|
+
if (report[0] === this.deviceIndex && report[1] === this.reportRateFeatureIndex && report[2] === 0x00 && report[3] === 0x01) {
|
|
173
|
+
const rate = REPORT_RATE_HZ[report[4] ?? -1];
|
|
174
|
+
if (rate) {
|
|
175
|
+
this.livePollingRateHz = rate;
|
|
176
|
+
const matchingRateWaiters = this.rateChangeWaiters.filter((waiter) => waiter.rate === rate);
|
|
177
|
+
this.rateChangeWaiters.splice(0, this.rateChangeWaiters.length, ...this.rateChangeWaiters.filter((waiter) => waiter.rate !== rate));
|
|
178
|
+
matchingRateWaiters.forEach((waiter) => waiter.resolve());
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const matchingIndex = this.waiters.findIndex((waiter) => report[0] === this.deviceIndex
|
|
182
|
+
&& report[1] === waiter.featureIndex
|
|
183
|
+
&& report[2] === withSoftwareId(waiter.functionId));
|
|
184
|
+
if (matchingIndex >= 0) {
|
|
185
|
+
this.waiters.splice(matchingIndex, 1)[0].resolve(report);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
// HID++ can emit a status notification between a write acknowledgement and
|
|
189
|
+
// the matching read response. Leave the pending request in place and wait.
|
|
190
|
+
if (report[0] === this.deviceIndex && (report[1] === 0x8f || report[1] === 0xff)) {
|
|
191
|
+
let failure = null;
|
|
192
|
+
const failedIndex = this.waiters.findIndex((waiter) => {
|
|
193
|
+
failure = hidppErrorForRequest(report, waiter.featureIndex, waiter.functionId);
|
|
194
|
+
return failure !== null;
|
|
195
|
+
});
|
|
196
|
+
if (failedIndex >= 0) {
|
|
197
|
+
this.waiters.splice(failedIndex, 1)[0].reject(new Error(failure ?? "The mouse rejected that request."));
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
waiters = [];
|
|
203
|
+
device;
|
|
204
|
+
constructor(device) {
|
|
205
|
+
this.device = device;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* True when the vendor interface belongs to the mouse itself instead of a
|
|
209
|
+
* receiver. Written as a getter because `useDefineForClassFields` runs
|
|
210
|
+
* field initializers before the `device` parameter property is assigned.
|
|
211
|
+
*/
|
|
212
|
+
get isDirectConnect() {
|
|
213
|
+
// A Bluetooth mouse also answers on 0xFF, because over BLE it really is the
|
|
214
|
+
// endpoint, but nothing this flag gates is true of it. Onboard-profile
|
|
215
|
+
// writes, short-report DPI and the "Wired USB" label all describe a *wired*
|
|
216
|
+
// vendor interface, so Bluetooth takes the receiver-style paths instead.
|
|
217
|
+
return !this.isBluetooth && isDirectConnection(this.resolvedDeviceIndex);
|
|
218
|
+
}
|
|
219
|
+
get isBoltReceiver() {
|
|
220
|
+
return isBoltReceiverProduct(this.device.productId);
|
|
221
|
+
}
|
|
222
|
+
/** @see hasHidppBluetoothCollection */
|
|
223
|
+
get isBluetooth() {
|
|
224
|
+
return hasHidppBluetoothCollection(this.device);
|
|
225
|
+
}
|
|
226
|
+
/** HID++ device index: a receiver pairing slot, or the mouse itself. */
|
|
227
|
+
get deviceIndex() {
|
|
228
|
+
return this.resolvedDeviceIndex ?? DEVICE_INDEX_DIRECT;
|
|
229
|
+
}
|
|
230
|
+
get reportDevice() {
|
|
231
|
+
return this.ioDevice ?? this.device;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Finds which HID++ device index this connection answers on.
|
|
235
|
+
*
|
|
236
|
+
* A mouse reached through its receiver answers on a pairing slot (usually
|
|
237
|
+
* 0x01 on Lightspeed; any of 1..6 on Bolt). The same mouse plugged in by
|
|
238
|
+
* cable answers as itself (0xFF). That cannot be read from the descriptors.
|
|
239
|
+
*
|
|
240
|
+
* Only a HID++ 2.0 reply (success or 2.0 error) counts. Bolt receivers answer
|
|
241
|
+
* HID++ 1.0 errors on empty slots and on 0xFF; treating those as "answered"
|
|
242
|
+
* made OpenMouse lock onto the receiver and report "invalid command".
|
|
243
|
+
* A G HUB merge can also push the mouse off slot 0x01, so receivers probe
|
|
244
|
+
* every pairing slot, not just the first.
|
|
245
|
+
*
|
|
246
|
+
* So ask. The root feature query is the cheapest request there is, and the
|
|
247
|
+
* wrong index simply times out.
|
|
248
|
+
*/
|
|
249
|
+
async resolveDeviceIndex(excluded, priorAnsweredWithoutSensor = false) {
|
|
250
|
+
if (this.resolvedDeviceIndex !== null)
|
|
251
|
+
return;
|
|
252
|
+
const receiverAttached = KNOWN_RECEIVER_PRODUCT_IDS.has(this.device.productId);
|
|
253
|
+
const candidates = hidppDeviceIndexCandidates(receiverAttached)
|
|
254
|
+
.filter((candidate) => !excluded?.has(candidate));
|
|
255
|
+
// A merged receiver can carry a keyboard on a lower slot than the mouse.
|
|
256
|
+
// Keyboards answer the same HID++ queries, so an answering slot only counts
|
|
257
|
+
// once it proves it has a sensor; a direct connection is latched on its
|
|
258
|
+
// first HID++2.0 answer without that extra round trip — correct for the
|
|
259
|
+
// common case, where a "direct connect" product id really does have one
|
|
260
|
+
// dedicated endpoint, but not universally true: confirmed on real
|
|
261
|
+
// hardware (a PRO X Superlight), DEVICE_INDEX_DIRECT can be an
|
|
262
|
+
// admin/pass-through endpoint that answers the root feature query with
|
|
263
|
+
// no sensor behind it, while DEVICE_INDEX_RECEIVER — the very next
|
|
264
|
+
// candidate — is the mouse itself. `readStatus()` catches that after the
|
|
265
|
+
// fact (its own dpiFeature check) and calls this again with the
|
|
266
|
+
// sensorless index in `excluded` (and `priorAnsweredWithoutSensor: true`,
|
|
267
|
+
// since that index answering-without-a-sensor is *why* it's excluded —
|
|
268
|
+
// this call's own loop never revisits it to rediscover that itself); on
|
|
269
|
+
// that retry there is no "trust the first answer" shortcut left to take,
|
|
270
|
+
// so every remaining candidate gets the same sensor check a
|
|
271
|
+
// receiver-attached probe always got.
|
|
272
|
+
let answeredWithoutSensor = priorAnsweredWithoutSensor;
|
|
273
|
+
for (const candidate of candidates) {
|
|
274
|
+
this.resolvedDeviceIndex = candidate;
|
|
275
|
+
const outcome = await this.probeHidpp20Root();
|
|
276
|
+
if (outcome !== "hidpp20")
|
|
277
|
+
continue;
|
|
278
|
+
if (!receiverAttached && !excluded)
|
|
279
|
+
return;
|
|
280
|
+
answeredWithoutSensor = true;
|
|
281
|
+
if (await this.hasDpiFeature())
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
this.resolvedDeviceIndex = null;
|
|
285
|
+
if (answeredWithoutSensor) {
|
|
286
|
+
throw new NotAMouseError(this.device.productName || "That Logitech device");
|
|
287
|
+
}
|
|
288
|
+
throw new Error(this.isBoltReceiver
|
|
289
|
+
? "No mouse answered on this Logi Bolt receiver. Move the mouse, then try again. Close Logi Options+ if it is open."
|
|
290
|
+
: "The mouse did not answer on any HID++ device index.");
|
|
291
|
+
}
|
|
292
|
+
/** Root getFeature(firmware) distinguishes HID++ 2.0 from receiver noise. */
|
|
293
|
+
async probeHidpp20Root() {
|
|
294
|
+
try {
|
|
295
|
+
await this.requestWithOptions(0x00, 0x00, [FEATURE.firmware >> 8, FEATURE.firmware & 0xff], { timeoutMs: this.isBoltReceiver ? BOLT_INDEX_PROBE_TIMEOUT_MS : REQUEST_TIMEOUT_MS });
|
|
296
|
+
return "hidpp20";
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
return classifyHidpp20Probe(error, error instanceof HidppTimeoutError);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Whether the slot currently probed exposes a DPI feature — the same sensor
|
|
304
|
+
* check readStatus uses, so a keyboard paired to a merged receiver is never
|
|
305
|
+
* mistaken for the mouse.
|
|
306
|
+
*/
|
|
307
|
+
async hasDpiFeature() {
|
|
308
|
+
const extended = await this.getFeature(FEATURE.extendedDpi).catch(() => null);
|
|
309
|
+
if (extended?.index)
|
|
310
|
+
return true;
|
|
311
|
+
const legacy = await this.getFeature(FEATURE.adjustableDpi).catch(() => null);
|
|
312
|
+
return (legacy?.index ?? 0) !== 0;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Any Logitech device speaking HID++, not just the receivers we happen to
|
|
316
|
+
* have listed. Whether it is a *mouse* cannot be told from descriptors —
|
|
317
|
+
* keyboards and headsets use the same transport — so that is decided after
|
|
318
|
+
* connecting, by asking for a DPI feature. See readStatus.
|
|
319
|
+
*/
|
|
320
|
+
static isSupported(device) {
|
|
321
|
+
if (device.vendorId !== LOGITECH_VENDOR_ID)
|
|
322
|
+
return false;
|
|
323
|
+
return hasHidppShortCollection(device)
|
|
324
|
+
|| hasHidppLongCollection(device)
|
|
325
|
+
|| hasHidppBluetoothCollection(device);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Prefer Bolt's long-report collection when both HID++ endpoints are present
|
|
329
|
+
* so feature traffic lands on the interface that can carry it.
|
|
330
|
+
*/
|
|
331
|
+
static supportScore(device) {
|
|
332
|
+
return boltSupportScore(device, this.isSupported(device));
|
|
333
|
+
}
|
|
334
|
+
/** @see collapseBoltPeers */
|
|
335
|
+
static collapseBoltPeers(devices) {
|
|
336
|
+
return collapseBoltPeers(devices);
|
|
337
|
+
}
|
|
338
|
+
/** Known receivers, kept as the fast path for the WebHID picker's filters. */
|
|
339
|
+
static isKnownReceiver(device) {
|
|
340
|
+
return device.vendorId === LOGITECH_VENDOR_ID
|
|
341
|
+
&& (KNOWN_RECEIVER_PRODUCT_IDS.has(device.productId) || isDirectConnectProduct(device.productId));
|
|
342
|
+
}
|
|
343
|
+
static async requestReceiver() {
|
|
344
|
+
if (!navigator.hid) {
|
|
345
|
+
throw new Error("WebHID is unavailable. Use Chrome or Edge on desktop.");
|
|
346
|
+
}
|
|
347
|
+
// The shared lists, not a second copy: a Bluetooth filter added to one and
|
|
348
|
+
// not the other is how the mouse stayed invisible on this path.
|
|
349
|
+
const devices = await navigator.hid.requestDevice({
|
|
350
|
+
filters: [...LOGITECH_RECEIVER_FILTERS, ...LOGITECH_BLUETOOTH_FILTERS],
|
|
351
|
+
});
|
|
352
|
+
const ranked = [...devices]
|
|
353
|
+
.filter((device) => this.isSupported(device))
|
|
354
|
+
.sort((left, right) => this.supportScore(right) - this.supportScore(left));
|
|
355
|
+
const device = ranked[0];
|
|
356
|
+
return device ? new LogitechHidppClient(device) : null;
|
|
357
|
+
}
|
|
358
|
+
static async reconnectAuthorizedReceiver() {
|
|
359
|
+
if (!navigator.hid) {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
const devices = this.collapseBoltPeers((await navigator.hid.getDevices()).filter((candidate) => this.isSupported(candidate)));
|
|
363
|
+
const ranked = [...devices].sort((left, right) => this.supportScore(right) - this.supportScore(left));
|
|
364
|
+
const device = ranked[0];
|
|
365
|
+
return device ? new LogitechHidppClient(device) : null;
|
|
366
|
+
}
|
|
367
|
+
async readStatus() {
|
|
368
|
+
await this.open();
|
|
369
|
+
// Which index answers depends on how the mouse is attached, not on which
|
|
370
|
+
// mouse it is, so it is discovered once per connection before anything else.
|
|
371
|
+
await this.resolveDeviceIndex();
|
|
372
|
+
const nameFeature = await this.getFeature(FEATURE.deviceName);
|
|
373
|
+
const firmwareFeature = await this.getFeature(FEATURE.firmware);
|
|
374
|
+
const batteryFeature = await this.getFeature(FEATURE.unifiedBattery);
|
|
375
|
+
const batteryStatusFeature = await this.getFeature(FEATURE.batteryStatus);
|
|
376
|
+
const batteryVoltageFeature = await this.getFeature(FEATURE.batteryVoltage);
|
|
377
|
+
const adcMeasurementFeature = await this.getFeature(FEATURE.adcMeasurement);
|
|
378
|
+
const dpiFeature = await this.resolveDpiFeature();
|
|
379
|
+
// Keyboards, headsets and receivers for other devices all speak HID++, so
|
|
380
|
+
// the picker cannot filter them out by descriptor. A sensor feature is what
|
|
381
|
+
// actually distinguishes a mouse, and it is only knowable once connected.
|
|
382
|
+
if (!dpiFeature.index) {
|
|
383
|
+
// `resolveDeviceIndex()`'s direct-connect fast path trusts the first
|
|
384
|
+
// HID++2.0-answering index without a sensor probe — cheap and correct
|
|
385
|
+
// for the common case, but confirmed wrong on real hardware (a PRO X
|
|
386
|
+
// Superlight whose DEVICE_INDEX_DIRECT is an admin/pass-through
|
|
387
|
+
// endpoint with no sensor while DEVICE_INDEX_RECEIVER, the very next
|
|
388
|
+
// candidate, is the mouse itself). Retry properly — this time with a
|
|
389
|
+
// sensor check on every remaining candidate — before concluding this
|
|
390
|
+
// interface really isn't a mouse.
|
|
391
|
+
const sensorless = this.resolvedDeviceIndex;
|
|
392
|
+
this.resolvedDeviceIndex = null;
|
|
393
|
+
await this.resolveDeviceIndex(sensorless === null ? undefined : new Set([sensorless]), sensorless !== null);
|
|
394
|
+
return this.readStatus();
|
|
395
|
+
}
|
|
396
|
+
const reportRateFeature = await this.resolveReportRateFeature();
|
|
397
|
+
const profilesFeature = await this.getFeature(FEATURE.onboardProfiles);
|
|
398
|
+
const analogButtonsFeature = await this.getFeature(FEATURE.analogButtons);
|
|
399
|
+
// HID++ receivers expect one request at a time. Keeping the sequence serial
|
|
400
|
+
// also makes every input report unambiguous to the WebHID event handler.
|
|
401
|
+
const name = await this.readName(nameFeature.index);
|
|
402
|
+
const identity = await this.readIdentity(firmwareFeature.index);
|
|
403
|
+
const battery = batteryFeature.index
|
|
404
|
+
// Ordered by how directly each reports charge: 0x1004 and 0x1000 give a
|
|
405
|
+
// percentage outright, while the voltage features only allow an estimate.
|
|
406
|
+
? await this.readBattery(batteryFeature.index)
|
|
407
|
+
: batteryStatusFeature.index
|
|
408
|
+
? await this.readBatteryLevelStatus(batteryStatusFeature.index)
|
|
409
|
+
: batteryVoltageFeature.index
|
|
410
|
+
? await this.readBatteryVoltage(batteryVoltageFeature.index)
|
|
411
|
+
: adcMeasurementFeature.index
|
|
412
|
+
? await this.readAdcMeasurement(adcMeasurementFeature.index)
|
|
413
|
+
: { percent: null, state: "Unknown", voltageMv: null };
|
|
414
|
+
if (batteryVoltageFeature.index && battery.voltageMv === undefined) {
|
|
415
|
+
battery.voltageMv = (await this.readBatteryVoltage(batteryVoltageFeature.index)).voltageMv;
|
|
416
|
+
}
|
|
417
|
+
else if (adcMeasurementFeature.index && battery.voltageMv === undefined) {
|
|
418
|
+
battery.voltageMv = (await this.readAdcMeasurement(adcMeasurementFeature.index)).voltageMv;
|
|
419
|
+
}
|
|
420
|
+
const dpiState = dpiFeature.legacy
|
|
421
|
+
? await this.readLegacyDpi(dpiFeature.index)
|
|
422
|
+
: await this.readDpi(dpiFeature.index);
|
|
423
|
+
const dpiCapabilities = dpiFeature.legacy
|
|
424
|
+
? { separateAxes: false, liftOff: false }
|
|
425
|
+
: await this.readDpiCapabilities(dpiFeature.index);
|
|
426
|
+
const supportsSeparateDpiAxes = dpiCapabilities.separateAxes;
|
|
427
|
+
// Productivity Bolt mice (MX Master 3S) expose DPI and battery but no
|
|
428
|
+
// 0x8060/0x8061 report-rate feature. Skip rather than throwing.
|
|
429
|
+
const supportedPollingRates = reportRateFeature.index
|
|
430
|
+
? reportRateFeature.legacy
|
|
431
|
+
? await this.readLegacyReportRates(reportRateFeature.index)
|
|
432
|
+
: await this.readSupportedPollingRates(reportRateFeature.index)
|
|
433
|
+
: [];
|
|
434
|
+
const pollingRateHz = reportRateFeature.index
|
|
435
|
+
? reportRateFeature.legacy
|
|
436
|
+
? await this.readLegacyReportRate(reportRateFeature.index)
|
|
437
|
+
: await this.readPollingRate(reportRateFeature.index)
|
|
438
|
+
: 0;
|
|
439
|
+
const profileState = await this.readProfileState(profilesFeature.index);
|
|
440
|
+
this.rgbOriginalMode = profileState.deviceMode;
|
|
441
|
+
const firmware = await this.readFirmware(firmwareFeature.index);
|
|
442
|
+
const analogButtonTuning = analogButtonsFeature.index
|
|
443
|
+
? await this.readAnalogButtonTuning(analogButtonsFeature.index)
|
|
444
|
+
: undefined;
|
|
445
|
+
const haptics = await this.readHapticConfig();
|
|
446
|
+
const friendly = await this.readFriendlyName();
|
|
447
|
+
const hosts = await this.readHostState();
|
|
448
|
+
const wheel = await this.readWheelState();
|
|
449
|
+
const modeStatusFeature = await this.getFeature(FEATURE.modeStatus);
|
|
450
|
+
const modeStatus = modeStatusFeature.index ? await this.readModeStatus(modeStatusFeature.index) : null;
|
|
451
|
+
// One extra request; the layout it selects is worth surfacing in diagnostics.
|
|
452
|
+
const onboardProfileFormat = profilesFeature.index
|
|
453
|
+
? await this.request(profilesFeature.index, PROFILE_FN.getInfo)
|
|
454
|
+
.then((reply) => describeProfileFormat(parseProfilesInfo(reply).profileFormatId))
|
|
455
|
+
.catch(() => null)
|
|
456
|
+
: null;
|
|
457
|
+
// Keyed on the reported profile format, not the model, so another mouse on
|
|
458
|
+
// the same format gets the same limits without being named here.
|
|
459
|
+
this.profileFormatId = onboardProfileFormat?.id ?? null;
|
|
460
|
+
this.lodCapabilities = capabilitiesForFormat(onboardProfileFormat?.id);
|
|
461
|
+
if (dpiCapabilities.liftOff && ![7, 8].includes(onboardProfileFormat?.id ?? -1)) {
|
|
462
|
+
// Solaar's 0x2202 definition for G502 X-family firmware advertises LOD
|
|
463
|
+
// independently of profile format and numbers Low/Medium/High from zero.
|
|
464
|
+
this.lodCapabilities = {
|
|
465
|
+
...this.lodCapabilities,
|
|
466
|
+
supportedLods: ["Low", "Medium", "High"],
|
|
467
|
+
lodEncoding: { Low: 0, Medium: 1, High: 2 },
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
this.supportedLods = [...this.lodCapabilities.supportedLods];
|
|
471
|
+
const wired = isWiredHidppConnection(this.device.productId, identity.transportIds, this.isDirectConnect);
|
|
472
|
+
this.wiredConnection = wired;
|
|
473
|
+
// A direct-connect mouse keeps its rate in the onboard profile, so it can
|
|
474
|
+
// only change once that format is verified and actually carries a
|
|
475
|
+
// report-rate field. Anything else stays read-only.
|
|
476
|
+
const profileRateWritable = this.isDirectConnect
|
|
477
|
+
&& isProfileWritable(this.profileFormatId)
|
|
478
|
+
&& capabilitiesForFormat(this.profileFormatId).reportRates !== null;
|
|
479
|
+
const liftOffDistance = decodeLiftOffLevel(dpiState.lod, this.lodCapabilities);
|
|
480
|
+
const hasLiveLiftOffControl = !dpiFeature.legacy && dpiCapabilities.liftOff;
|
|
481
|
+
const rgbFeature = await this.getFeature(FEATURE.rgbEffects);
|
|
482
|
+
const colorLedFeature = await this.getFeature(FEATURE.colorLedEffects);
|
|
483
|
+
const effectZones = rgbFeature.index
|
|
484
|
+
? [await this.readRgbLighting(rgbFeature.index)].filter((zone) => zone !== null)
|
|
485
|
+
: colorLedFeature.index ? await this.readColorLedLighting(colorLedFeature.index) : [];
|
|
486
|
+
const perLedFeature = await this.getFeature(FEATURE.perKeyLightingV2);
|
|
487
|
+
const perLedZones = perLedFeature.index ? await this.readPerLedLighting(perLedFeature.index, name) : [];
|
|
488
|
+
const lightingZones = [...effectZones, ...perLedZones];
|
|
489
|
+
const lighting = lightingZones[0] ?? null;
|
|
490
|
+
const rateLimits = this.lodCapabilities.reportRates;
|
|
491
|
+
const connectionRateCeiling = rateLimits
|
|
492
|
+
? (wired ? rateLimits.wiredMaxHz : rateLimits.wirelessMaxHz)
|
|
493
|
+
: null;
|
|
494
|
+
const effectiveSupportedPollingRates = connectionRateCeiling
|
|
495
|
+
? supportedPollingRates.filter((rate) => rate <= connectionRateCeiling)
|
|
496
|
+
: supportedPollingRates;
|
|
497
|
+
return {
|
|
498
|
+
brand: "Logitech",
|
|
499
|
+
name,
|
|
500
|
+
ui: {
|
|
501
|
+
family: "logitech-hidpp",
|
|
502
|
+
// Logitech allows Lift-off Distance modification only when Gaming Surface Mode is set to "on" or "auto"
|
|
503
|
+
lodRequiresSurface: true,
|
|
504
|
+
// Direct-connect mice report their rate but keep the writable copy in
|
|
505
|
+
// the onboard profile. It is editable again once the profile write path
|
|
506
|
+
// is available for that format.
|
|
507
|
+
pollingReadOnly: !reportRateFeature.index || (this.isDirectConnect && !profileRateWritable) ? true : undefined,
|
|
508
|
+
pollingNote: this.isDirectConnect
|
|
509
|
+
? profileRateWritable
|
|
510
|
+
? "Stored in this mouse's onboard profile — OpenMouse writes it there."
|
|
511
|
+
: "This mouse stores its polling rate in the onboard profile, so OpenMouse reads it without changing it."
|
|
512
|
+
: !reportRateFeature.index
|
|
513
|
+
? "This mouse does not expose a HID++ polling-rate control."
|
|
514
|
+
: undefined,
|
|
515
|
+
hideUnsupportedPollingRates: !reportRateFeature.index ? true : undefined,
|
|
516
|
+
},
|
|
517
|
+
batteryPercent: battery.percent,
|
|
518
|
+
batteryVoltageMv: battery.voltageMv ?? null,
|
|
519
|
+
batteryState: battery.state,
|
|
520
|
+
dpi: dpiState.dpi,
|
|
521
|
+
dpiY: dpiState.dpiY,
|
|
522
|
+
supportsSeparateDpiAxes,
|
|
523
|
+
analogButtonTuning,
|
|
524
|
+
liftOffDistance,
|
|
525
|
+
onboardProfileFormat,
|
|
526
|
+
// HID++ exposes no per-field support mask for status1. A power-only
|
|
527
|
+
// variant can leave it reserved at zero, which would falsely decode as
|
|
528
|
+
// Surface Auto and LightForce Optical. Only expose that control bank when
|
|
529
|
+
// the sensor positively reports the related live LOD capability; this is
|
|
530
|
+
// deliberately conservative and avoids a product/model exception.
|
|
531
|
+
...wheel,
|
|
532
|
+
friendlyName: friendly?.name ?? null,
|
|
533
|
+
friendlyNameMaxLength: friendly?.maxLength ?? null,
|
|
534
|
+
hostCount: hosts?.info.hostCount ?? null,
|
|
535
|
+
currentHost: hosts?.info.currentHost ?? null,
|
|
536
|
+
hostSlotsPaired: hosts?.paired ?? null,
|
|
537
|
+
hapticIntensity: haptics?.intensity ?? null,
|
|
538
|
+
hapticEnabled: haptics?.enabled ?? null,
|
|
539
|
+
hapticBatterySaving: haptics?.batterySaving ?? null,
|
|
540
|
+
gamingSurfaceMode: modeStatus === null || !hasLiveLiftOffControl
|
|
541
|
+
? null
|
|
542
|
+
: decodeModeStatus(modeStatus, MODE_STATUS.gamingSurface),
|
|
543
|
+
lightforceSwitchMode: modeStatus === null || !hasLiveLiftOffControl
|
|
544
|
+
? null
|
|
545
|
+
: decodeModeStatus(modeStatus, MODE_STATUS.lightforce),
|
|
546
|
+
lighting: lighting ?? undefined,
|
|
547
|
+
lightingZones: lightingZones.length ? lightingZones : undefined,
|
|
548
|
+
// Some profile formats have different wired and wireless ceilings. The
|
|
549
|
+
// active transport comes from HID++ identity rather than a USB PID.
|
|
550
|
+
pollingRateHz: connectionRateCeiling ? Math.min(pollingRateHz, connectionRateCeiling) : pollingRateHz,
|
|
551
|
+
supportedPollingRates: effectiveSupportedPollingRates,
|
|
552
|
+
// Lift-off distance is only reachable through extended DPI (0x2202). On a
|
|
553
|
+
// mouse that exposes just legacy 0x2201 there is nothing to drive, so
|
|
554
|
+
// report an empty set rather than offering buttons that can only fail.
|
|
555
|
+
// Otherwise the levels come from the profile format, which is where the
|
|
556
|
+
// count and the byte encoding are both established.
|
|
557
|
+
supportedLiftOffDistances: hasLiveLiftOffControl ? this.supportedLods : [],
|
|
558
|
+
connectionType: wired ? "Wired" : "Wireless",
|
|
559
|
+
// Without this the shell falls back to its "2.4 GHz receiver" wording,
|
|
560
|
+
// which is wrong for a mouse plugged straight into USB, and imprecise
|
|
561
|
+
// for Logi Bolt (BLE-based) versus Lightspeed.
|
|
562
|
+
connectionDetail: this.isDirectConnect
|
|
563
|
+
? "Wired USB"
|
|
564
|
+
: this.isBluetooth
|
|
565
|
+
? "Bluetooth"
|
|
566
|
+
: this.isBoltReceiver
|
|
567
|
+
? "Logi Bolt"
|
|
568
|
+
: undefined,
|
|
569
|
+
activeProfile: profileState.activeProfile,
|
|
570
|
+
deviceMode: profileState.deviceMode,
|
|
571
|
+
unitId: identity.unitId,
|
|
572
|
+
modelId: identity.modelId,
|
|
573
|
+
transportIds: identity.transportIds,
|
|
574
|
+
firmware,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
async close() {
|
|
578
|
+
if (this.rgbClaimed) {
|
|
579
|
+
const rgb = await this.getFeature(FEATURE.rgbEffects).catch(() => ({ index: 0, version: 0 }));
|
|
580
|
+
if (rgb.index)
|
|
581
|
+
await this.request(rgb.index, 0x50, 0x01, 0x00, 0x00).catch(() => undefined);
|
|
582
|
+
if (this.rgbOriginalMode === "Onboard")
|
|
583
|
+
await this.setOnboardMode("Onboard").catch(() => undefined);
|
|
584
|
+
this.rgbClaimed = false;
|
|
585
|
+
}
|
|
586
|
+
for (const device of this.listeningDevices) {
|
|
587
|
+
device.removeEventListener("inputreport", this.onInputReport);
|
|
588
|
+
if (device.opened) {
|
|
589
|
+
await device.close().catch(() => undefined);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
this.listeningDevices.clear();
|
|
593
|
+
this.ioDevice = null;
|
|
594
|
+
// A device can come back on a different slot, or be a different mouse
|
|
595
|
+
// entirely, so nothing read this session survives the disconnect.
|
|
596
|
+
this.hostStateCache = undefined;
|
|
597
|
+
this.controlInfoCache = undefined;
|
|
598
|
+
this.friendlyNameCache = undefined;
|
|
599
|
+
this.wheelCapabilityCache = undefined;
|
|
600
|
+
this.rgbZone = null;
|
|
601
|
+
this.colorLedZones = [];
|
|
602
|
+
this.rgbLighting = null;
|
|
603
|
+
}
|
|
604
|
+
async setLighting(lighting) {
|
|
605
|
+
if (lighting.hardwareZoneId !== undefined) {
|
|
606
|
+
const feature = await this.getFeature(FEATURE.perKeyLightingV2);
|
|
607
|
+
if (!feature.index)
|
|
608
|
+
throw new Error("This mouse does not expose per-LED lighting controls.");
|
|
609
|
+
const effects = await this.getFeature(FEATURE.rgbEffects);
|
|
610
|
+
if (effects.index && !this.rgbClaimed) {
|
|
611
|
+
const profiles = await this.getFeature(FEATURE.onboardProfiles);
|
|
612
|
+
if (profiles.index)
|
|
613
|
+
await this.setOnboardMode("Host");
|
|
614
|
+
await this.request(effects.index, 0x50, 0x01, 0x03, 0x04);
|
|
615
|
+
if (!this.rgbZone)
|
|
616
|
+
await this.readRgbLighting(effects.index);
|
|
617
|
+
// Disable the autonomous effect engine before painting individual
|
|
618
|
+
// cells. This is the G502 X PLUS prep sequence tested by Solaar.
|
|
619
|
+
await this.requestLong(effects.index, 0x10, [
|
|
620
|
+
0xff,
|
|
621
|
+
this.rgbZone?.effects.length ?? 0,
|
|
622
|
+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
623
|
+
0x01,
|
|
624
|
+
]);
|
|
625
|
+
this.rgbClaimed = true;
|
|
626
|
+
}
|
|
627
|
+
const color = Number.parseInt((lighting.color ?? "#000000").slice(1), 16);
|
|
628
|
+
const enabledColor = lighting.mode === "Off" ? 0 : color;
|
|
629
|
+
await this.requestLong(feature.index, 0x10, [
|
|
630
|
+
lighting.hardwareZoneId,
|
|
631
|
+
(enabledColor >> 16) & 0xff,
|
|
632
|
+
(enabledColor >> 8) & 0xff,
|
|
633
|
+
enabledColor & 0xff,
|
|
634
|
+
]);
|
|
635
|
+
await this.request(feature.index, 0x70, 0x00);
|
|
636
|
+
return { ...lighting, mode: lighting.mode === "Off" ? "Off" : "Static", writeOnly: true };
|
|
637
|
+
}
|
|
638
|
+
const colorFeature = await this.getFeature(FEATURE.colorLedEffects);
|
|
639
|
+
const colorZone = this.colorLedZones.find((zone) => (logitechColorLedLighting(zone)?.zone) === lighting.zone);
|
|
640
|
+
if (colorFeature.index && colorZone) {
|
|
641
|
+
const payload = encodeLogitechColorLedEffect(colorZone, lighting);
|
|
642
|
+
if (!payload)
|
|
643
|
+
throw new Error("That lighting effect was not advertised for this zone.");
|
|
644
|
+
await this.requestLong(colorFeature.index, 0x30, payload);
|
|
645
|
+
return { ...lighting, writeOnly: !colorZone.readable };
|
|
646
|
+
}
|
|
647
|
+
const feature = await this.getFeature(FEATURE.rgbEffects);
|
|
648
|
+
if (!feature.index)
|
|
649
|
+
throw new Error("This mouse does not expose RGB Effects controls.");
|
|
650
|
+
if (!this.rgbZone)
|
|
651
|
+
await this.readRgbLighting(feature.index);
|
|
652
|
+
if (!this.rgbZone || lighting.zone !== this.rgbLighting?.zone) {
|
|
653
|
+
throw new Error(`This mouse has no \"${lighting.zone}\" lighting zone.`);
|
|
654
|
+
}
|
|
655
|
+
const payload = encodeLogitechRgbEffect(this.rgbZone, lighting);
|
|
656
|
+
if (!payload)
|
|
657
|
+
throw new Error("That RGB effect was not advertised by this mouse.");
|
|
658
|
+
if (!this.rgbClaimed) {
|
|
659
|
+
const profiles = await this.getFeature(FEATURE.onboardProfiles);
|
|
660
|
+
if (profiles.index)
|
|
661
|
+
await this.setOnboardMode("Host");
|
|
662
|
+
// Solaar SetSWControl(set=1, software mode=3, monitor NVConfig=4).
|
|
663
|
+
await this.request(feature.index, 0x50, 0x01, 0x03, 0x04);
|
|
664
|
+
this.rgbClaimed = true;
|
|
665
|
+
}
|
|
666
|
+
await this.requestLong(feature.index, 0x10, payload);
|
|
667
|
+
this.rgbLighting = { ...lighting };
|
|
668
|
+
return this.rgbLighting;
|
|
669
|
+
}
|
|
670
|
+
async readColorLedLighting(featureIndex) {
|
|
671
|
+
// The feature can be advertised (a nonzero index from root discovery)
|
|
672
|
+
// without actually being readable — confirmed on a Pro X Superlight,
|
|
673
|
+
// which has no RGB lighting at all yet still resolves this feature index
|
|
674
|
+
// and rejects the info read with HID++ error 0x05. Every other optional
|
|
675
|
+
// read in readStatus degrades gracefully instead of throwing (see the
|
|
676
|
+
// per-zone current-color read further down, and the profile-format probe
|
|
677
|
+
// in readStatus itself); this one didn't, so it took the whole connection
|
|
678
|
+
// down with it.
|
|
679
|
+
const info = await this.request(featureIndex, 0x00).catch(() => null);
|
|
680
|
+
if (!info)
|
|
681
|
+
return [];
|
|
682
|
+
const count = Math.min(info[3] ?? 0, 8);
|
|
683
|
+
const readable = ((((info[6] ?? 0) << 8) | (info[7] ?? 0)) & 1) !== 0;
|
|
684
|
+
this.colorLedZones = [];
|
|
685
|
+
const lighting = [];
|
|
686
|
+
for (let index = 0; index < count; index += 1) {
|
|
687
|
+
const zoneReply = await this.request(featureIndex, 0x10, index, 0xff, 0x00);
|
|
688
|
+
const zone = {
|
|
689
|
+
index,
|
|
690
|
+
location: ((zoneReply[4] ?? 0) << 8) | (zoneReply[5] ?? 0),
|
|
691
|
+
readable,
|
|
692
|
+
effects: [],
|
|
693
|
+
};
|
|
694
|
+
const effectCount = Math.min(zoneReply[6] ?? 0, 32);
|
|
695
|
+
for (let effectIndex = 0; effectIndex < effectCount; effectIndex += 1) {
|
|
696
|
+
const effect = await this.request(featureIndex, 0x20, index, effectIndex, 0x00);
|
|
697
|
+
zone.effects.push({
|
|
698
|
+
index: effect[4] ?? effectIndex,
|
|
699
|
+
id: ((effect[5] ?? 0) << 8) | (effect[6] ?? 0),
|
|
700
|
+
period: ((effect[9] ?? 0) << 8) | (effect[10] ?? 0),
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
this.colorLedZones.push(zone);
|
|
704
|
+
const current = readable ? await this.request(featureIndex, 0xe0, index).catch(() => null) : null;
|
|
705
|
+
const mapped = logitechColorLedLighting(zone, current ? [...current.slice(4, 15)] : undefined);
|
|
706
|
+
if (mapped)
|
|
707
|
+
lighting.push(mapped);
|
|
708
|
+
}
|
|
709
|
+
return lighting;
|
|
710
|
+
}
|
|
711
|
+
async setPollingRate(pollingRateHz) {
|
|
712
|
+
const rateLimits = this.lodCapabilities.reportRates;
|
|
713
|
+
const connectionRateCeiling = rateLimits
|
|
714
|
+
? (this.wiredConnection ? rateLimits.wiredMaxHz : rateLimits.wirelessMaxHz)
|
|
715
|
+
: null;
|
|
716
|
+
if (connectionRateCeiling !== null && pollingRateHz > connectionRateCeiling) {
|
|
717
|
+
throw new Error(`This connection supports up to ${connectionRateCeiling} Hz.`);
|
|
718
|
+
}
|
|
719
|
+
const resolved = await this.resolveReportRateFeature();
|
|
720
|
+
if (!this.isDirectConnect) {
|
|
721
|
+
if (resolved.legacy) {
|
|
722
|
+
return this.setLegacyReportRate(resolved.index, pollingRateHz);
|
|
723
|
+
}
|
|
724
|
+
return this.setExtendedReportRate(resolved.index, pollingRateHz);
|
|
725
|
+
}
|
|
726
|
+
// A direct-connect mouse's persistent rate also lives in the onboard
|
|
727
|
+
// profile, and one Logitech generation (the Superlight-era boards this
|
|
728
|
+
// fallback was built for) rejects the live 0x8060/0x8061 write outright
|
|
729
|
+
// with HID++ error 0x02. But every other Logitech tool (Solaar, libratbag)
|
|
730
|
+
// always uses the live feature unconditionally and reports no such
|
|
731
|
+
// rejection on other generations — so try it first here too, the same as
|
|
732
|
+
// a non-direct-connect mouse, and only fall back to the profile write
|
|
733
|
+
// (one sector erase/write cycle) if the live attempt actually fails. That
|
|
734
|
+
// gives a device whose generation accepts the live write a real shot at
|
|
735
|
+
// it instead of skipping straight to a profile format that may not even
|
|
736
|
+
// be writable yet (e.g. format 8's Superstrike).
|
|
737
|
+
if (resolved.index) {
|
|
738
|
+
try {
|
|
739
|
+
if (resolved.legacy) {
|
|
740
|
+
return await this.setLegacyReportRate(resolved.index, pollingRateHz);
|
|
741
|
+
}
|
|
742
|
+
return await this.setExtendedReportRate(resolved.index, pollingRateHz);
|
|
743
|
+
}
|
|
744
|
+
catch (error) {
|
|
745
|
+
// Only an explicit on-device rejection of the write itself falls back
|
|
746
|
+
// to the profile write. A confirmation timeout (the write was ACKed
|
|
747
|
+
// but the follow-up notification never arrived, or the legacy path
|
|
748
|
+
// read back a stale rate) is not proof the write failed, so retrying
|
|
749
|
+
// via a second, profile-based write there risks writing over a rate
|
|
750
|
+
// the mouse already accepted. HidppTimeoutError is the transport-
|
|
751
|
+
// level "no reply at all" case, equally not a rejection.
|
|
752
|
+
if (!(error instanceof LiveRateWriteRejectedError))
|
|
753
|
+
throw error;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
// No live feature at all, or the live write was explicitly rejected.
|
|
757
|
+
// encodeReportRate validates the value against the format the mouse
|
|
758
|
+
// reported, so a genuinely unsupported rate still surfaces there.
|
|
759
|
+
await this.writeActiveProfile({ reportRateWiredHz: pollingRateHz });
|
|
760
|
+
return pollingRateHz;
|
|
761
|
+
}
|
|
762
|
+
async setExtendedReportRate(featureIndex, pollingRateHz) {
|
|
763
|
+
const rateIndex = REPORT_RATE_HZ.indexOf(pollingRateHz);
|
|
764
|
+
if (rateIndex < 0) {
|
|
765
|
+
throw new Error("Unsupported polling rate.");
|
|
766
|
+
}
|
|
767
|
+
await this.ensureHostControl();
|
|
768
|
+
const feature = featureIndex || (await this.getFeature(FEATURE.extendedReportRate)).index;
|
|
769
|
+
if (!feature) {
|
|
770
|
+
throw new Error("This mouse does not expose report-rate controls.");
|
|
771
|
+
}
|
|
772
|
+
const confirmation = this.waitForRateChange(pollingRateHz);
|
|
773
|
+
try {
|
|
774
|
+
await this.request(feature, 0x30, rateIndex);
|
|
775
|
+
}
|
|
776
|
+
catch (error) {
|
|
777
|
+
throw error instanceof HidppTimeoutError ? error : new LiveRateWriteRejectedError(error);
|
|
778
|
+
}
|
|
779
|
+
await confirmation;
|
|
780
|
+
return pollingRateHz;
|
|
781
|
+
}
|
|
782
|
+
async getDpiOptions() {
|
|
783
|
+
if (this.dpiOptionsCache) {
|
|
784
|
+
return this.dpiOptionsCache;
|
|
785
|
+
}
|
|
786
|
+
const resolved = await this.resolveDpiFeature();
|
|
787
|
+
if (!resolved.index) {
|
|
788
|
+
throw new Error("This mouse does not expose DPI controls.");
|
|
789
|
+
}
|
|
790
|
+
if (resolved.legacy) {
|
|
791
|
+
const advertised = await this.readLegacyDpiList(resolved.index);
|
|
792
|
+
const formatLimits = capabilitiesForFormat(this.profileFormatId).dpiStages;
|
|
793
|
+
// Empty legacy lists fall back to the profile format's captured grid,
|
|
794
|
+
// never to a model/PID-specific table.
|
|
795
|
+
this.dpiOptionsCache = advertised.length === 0 && formatLimits
|
|
796
|
+
? Array.from({ length: Math.floor((formatLimits.maxDpi - formatLimits.minDpi) / formatLimits.stepDpi) + 1 }, (_, step) => formatLimits.minDpi + step * formatLimits.stepDpi)
|
|
797
|
+
: advertised;
|
|
798
|
+
return this.dpiOptionsCache;
|
|
799
|
+
}
|
|
800
|
+
const feature = { index: resolved.index };
|
|
801
|
+
const bytes = [];
|
|
802
|
+
for (let page = 0; page < 32; page += 1) {
|
|
803
|
+
const reply = await this.request(feature.index, 0x20, 0x00, 0x00, page);
|
|
804
|
+
bytes.push(...reply.slice(6));
|
|
805
|
+
if (bytes.some((value, index) => index > 0 && bytes[index - 1] === 0 && value === 0)) {
|
|
806
|
+
break;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
const options = [];
|
|
810
|
+
for (let index = 0; index + 1 < bytes.length;) {
|
|
811
|
+
const value = (bytes[index] << 8) | bytes[index + 1];
|
|
812
|
+
if (value === 0)
|
|
813
|
+
break;
|
|
814
|
+
if (value >> 13 === 0b111) {
|
|
815
|
+
const step = value & 0x1fff;
|
|
816
|
+
const last = ((bytes[index + 2] ?? 0) << 8) | (bytes[index + 3] ?? 0);
|
|
817
|
+
const first = options.at(-1);
|
|
818
|
+
if (!first || !last || last <= first) {
|
|
819
|
+
throw new Error("The mouse returned an invalid DPI range.");
|
|
820
|
+
}
|
|
821
|
+
for (let dpi = first + step; dpi <= last; dpi += step)
|
|
822
|
+
options.push(dpi);
|
|
823
|
+
index += 4;
|
|
824
|
+
}
|
|
825
|
+
else {
|
|
826
|
+
options.push(value);
|
|
827
|
+
index += 2;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
this.dpiOptionsCache = options;
|
|
831
|
+
return options;
|
|
832
|
+
}
|
|
833
|
+
async setDpi(dpi, dpiY = dpi) {
|
|
834
|
+
const resolved = await this.resolveDpiFeature();
|
|
835
|
+
if (resolved.legacy) {
|
|
836
|
+
const legacyOptions = await this.getDpiOptions();
|
|
837
|
+
if (!legacyOptions.includes(dpi)) {
|
|
838
|
+
throw new Error(`${dpi} DPI is not advertised by this mouse.`);
|
|
839
|
+
}
|
|
840
|
+
return this.setLegacyDpi(resolved.index, dpi);
|
|
841
|
+
}
|
|
842
|
+
const options = await this.getDpiOptions();
|
|
843
|
+
if (!options.includes(dpi) || !options.includes(dpiY)) {
|
|
844
|
+
throw new Error(`${dpi}/${dpiY} DPI is not advertised by this mouse.`);
|
|
845
|
+
}
|
|
846
|
+
await this.ensureHostControl();
|
|
847
|
+
const feature = await this.getFeature(FEATURE.extendedDpi);
|
|
848
|
+
const current = await this.readDpiConfiguration(feature.index);
|
|
849
|
+
await this.requestLong(feature.index, 0x60, [
|
|
850
|
+
0x00,
|
|
851
|
+
dpi >> 8,
|
|
852
|
+
dpi & 0xff,
|
|
853
|
+
dpiY >> 8,
|
|
854
|
+
dpiY & 0xff,
|
|
855
|
+
current.lod,
|
|
856
|
+
]);
|
|
857
|
+
const confirmed = await this.readDpiConfiguration(feature.index);
|
|
858
|
+
if (confirmed.x !== dpi || confirmed.y !== dpiY) {
|
|
859
|
+
throw new Error(`The mouse kept ${confirmed.x}/${confirmed.y} DPI instead of ${dpi}/${dpiY} DPI.`);
|
|
860
|
+
}
|
|
861
|
+
return confirmed.x;
|
|
862
|
+
}
|
|
863
|
+
async setLiftOffDistance(liftOffDistance) {
|
|
864
|
+
// Validate against the levels this device advertised, so adding a model
|
|
865
|
+
// means describing it in readStatus rather than editing this check.
|
|
866
|
+
if (!this.supportedLods.includes(liftOffDistance)) {
|
|
867
|
+
throw new Error(`This mouse supports only ${this.supportedLods.join(" and ")} lift-off distance.`);
|
|
868
|
+
}
|
|
869
|
+
const lod = this.lodCapabilities.lodEncoding[liftOffDistance];
|
|
870
|
+
await this.ensureHostControl();
|
|
871
|
+
const feature = await this.getFeature(FEATURE.extendedDpi);
|
|
872
|
+
if (!feature.index) {
|
|
873
|
+
throw new Error("This mouse does not expose lift-off-distance controls.");
|
|
874
|
+
}
|
|
875
|
+
const current = await this.readDpiConfiguration(feature.index);
|
|
876
|
+
await this.requestLong(feature.index, 0x60, [
|
|
877
|
+
0x00,
|
|
878
|
+
current.x >> 8,
|
|
879
|
+
current.x & 0xff,
|
|
880
|
+
current.y >> 8,
|
|
881
|
+
current.y & 0xff,
|
|
882
|
+
lod,
|
|
883
|
+
]);
|
|
884
|
+
const confirmed = await this.readDpiConfiguration(feature.index);
|
|
885
|
+
const result = decodeLiftOffLevel(confirmed.lod, this.lodCapabilities);
|
|
886
|
+
if (result !== liftOffDistance) {
|
|
887
|
+
throw new Error(`The mouse kept ${result ?? "an unknown"} lift-off distance instead of ${liftOffDistance}.`);
|
|
888
|
+
}
|
|
889
|
+
return result;
|
|
890
|
+
}
|
|
891
|
+
async setAnalogButtonTuning(button, tuning) {
|
|
892
|
+
const feature = await this.getFeature(FEATURE.analogButtons);
|
|
893
|
+
if (!feature.index) {
|
|
894
|
+
throw new Error("This Logitech mouse does not expose hall-effect button tuning.");
|
|
895
|
+
}
|
|
896
|
+
const current = await this.readAnalogButtonTuning(feature.index);
|
|
897
|
+
const capabilities = current.buttons[button];
|
|
898
|
+
if (!capabilities) {
|
|
899
|
+
throw new Error("This mouse does not expose tuning for that button.");
|
|
900
|
+
}
|
|
901
|
+
if (!Number.isInteger(tuning.actuation) || tuning.actuation < 1 || tuning.actuation > current.maxActuation
|
|
902
|
+
|| !Number.isInteger(tuning.rapidTrigger) || tuning.rapidTrigger < 1 || tuning.rapidTrigger > current.maxRapidTrigger
|
|
903
|
+
|| !Number.isInteger(tuning.haptics) || tuning.haptics < 0 || tuning.haptics > current.maxHaptics) {
|
|
904
|
+
throw new Error("One or more hall-effect button values are outside the mouse's supported range.");
|
|
905
|
+
}
|
|
906
|
+
// HID++ 0x1B0C stores logical values in bits 7..2. Bit 0 of rapid trigger
|
|
907
|
+
// is a firmware-managed sensitivity flag, so it must survive the write.
|
|
908
|
+
const currentWire = await this.request(feature.index, 0x20, button);
|
|
909
|
+
await this.requestLong(feature.index, 0x10, [
|
|
910
|
+
button,
|
|
911
|
+
tuning.actuation << 2,
|
|
912
|
+
(tuning.rapidTrigger << 2) | ((currentWire[5] ?? 0) & 0x01),
|
|
913
|
+
tuning.haptics << 2,
|
|
914
|
+
]);
|
|
915
|
+
const confirmed = await this.readAnalogButtonTuning(feature.index);
|
|
916
|
+
const result = confirmed.buttons[button];
|
|
917
|
+
if (!result || result.actuation !== tuning.actuation || result.rapidTrigger !== tuning.rapidTrigger || result.haptics !== tuning.haptics) {
|
|
918
|
+
throw new Error("The mouse did not confirm the hall-effect button settings.");
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Switches the mouse between running from profile flash and being driven live
|
|
923
|
+
* by software. This is what G HUB's "Onboard Memory Mode" toggle does.
|
|
924
|
+
*/
|
|
925
|
+
async setOnboardMode(mode) {
|
|
926
|
+
const feature = await this.getFeature(FEATURE.onboardProfiles);
|
|
927
|
+
if (!feature.index) {
|
|
928
|
+
throw new Error("This mouse does not expose onboard-profile controls.");
|
|
929
|
+
}
|
|
930
|
+
const target = mode === "Onboard" ? ONBOARD_MODE.onboard : ONBOARD_MODE.host;
|
|
931
|
+
await this.request(feature.index, PROFILE_FN.setMode, target);
|
|
932
|
+
const confirmed = (await this.request(feature.index, PROFILE_FN.getMode))[3];
|
|
933
|
+
if (confirmed !== target) {
|
|
934
|
+
throw new Error(`The mouse stayed in ${confirmed === ONBOARD_MODE.onboard ? "onboard" : "host"} mode.`);
|
|
935
|
+
}
|
|
936
|
+
return mode;
|
|
937
|
+
}
|
|
938
|
+
/** Switches which stored profile the mouse runs. Volatile — no flash write. */
|
|
939
|
+
async setCurrentProfile(sector) {
|
|
940
|
+
const feature = await this.getFeature(FEATURE.onboardProfiles);
|
|
941
|
+
if (!feature.index) {
|
|
942
|
+
throw new Error("This mouse does not expose onboard-profile controls.");
|
|
943
|
+
}
|
|
944
|
+
await this.request(feature.index, PROFILE_FN.setCurrentProfile, (sector >> 8) & 0xff, sector & 0xff);
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Enables or disables a profile slot.
|
|
948
|
+
*
|
|
949
|
+
* WRITES FLASH. The directory sector is read, one flag byte changed, the
|
|
950
|
+
* checksum recomputed and the whole sector written back — one erase/write
|
|
951
|
+
* cycle per call. See docs/logitech-onboard-profiles.md. The write sequence
|
|
952
|
+
* itself has not been verified on hardware; callers should confirm with the
|
|
953
|
+
* user first.
|
|
954
|
+
*/
|
|
955
|
+
async setProfileEnabled(sector, enabled) {
|
|
956
|
+
const feature = await this.getFeature(FEATURE.onboardProfiles);
|
|
957
|
+
if (!feature.index) {
|
|
958
|
+
throw new Error("This mouse does not expose onboard-profile controls.");
|
|
959
|
+
}
|
|
960
|
+
const info = parseProfilesInfo(await this.request(feature.index, PROFILE_FN.getInfo));
|
|
961
|
+
const format = describeProfileFormat(info.profileFormatId);
|
|
962
|
+
if (!format.verified) {
|
|
963
|
+
throw new Error(`Profile format ${format.id} has not been verified on hardware; refusing to write.`);
|
|
964
|
+
}
|
|
965
|
+
const sectorSize = info.sectorSize > 0 && info.sectorSize <= 1024 ? info.sectorSize : 255;
|
|
966
|
+
const directory = await this.readProfileSector(feature.index, 0x0000, sectorSize);
|
|
967
|
+
if (profileCrc(directory) !== storedCrc(directory)) {
|
|
968
|
+
throw new Error("The profile directory failed its checksum; refusing to write.");
|
|
969
|
+
}
|
|
970
|
+
const updated = setDirectoryEnabled(directory, sector, enabled);
|
|
971
|
+
if (updated.every((byte, index) => byte === directory[index])) {
|
|
972
|
+
return; // already in the requested state: never spend a write cycle on a no-op
|
|
973
|
+
}
|
|
974
|
+
await this.writeProfileSector(feature.index, 0x0000, updated);
|
|
975
|
+
const confirmed = await this.readProfileSector(feature.index, 0x0000, sectorSize);
|
|
976
|
+
const entry = parseDirectory(confirmed).find((candidate) => candidate.sector === sector);
|
|
977
|
+
if (!entry || entry.enabled !== enabled) {
|
|
978
|
+
throw new Error("The mouse did not confirm the new profile state.");
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Restores every onboard profile byte-for-byte to the vendor's captured
|
|
983
|
+
* defaults, then leaves only the first profile enabled and running.
|
|
984
|
+
*
|
|
985
|
+
* WRITES FLASH. Support is deliberately limited to a profile format and
|
|
986
|
+
* sector geometry for which a complete vendor reset was captured. Using a
|
|
987
|
+
* decoded subset here would leave unknown button or lighting settings behind.
|
|
988
|
+
*/
|
|
989
|
+
async resetAllOnboardProfiles() {
|
|
990
|
+
await this.open();
|
|
991
|
+
const feature = await this.getFeature(FEATURE.onboardProfiles);
|
|
992
|
+
if (!feature.index) {
|
|
993
|
+
throw new Error("This Logitech mouse does not expose onboard-profile controls.");
|
|
994
|
+
}
|
|
995
|
+
const info = parseProfilesInfo(await this.request(feature.index, PROFILE_FN.getInfo));
|
|
996
|
+
const sectorSize = info.sectorSize > 0 && info.sectorSize <= 1024 ? info.sectorSize : 255;
|
|
997
|
+
const factoryProfile = factoryProfileForFormat(info.profileFormatId, sectorSize);
|
|
998
|
+
if (!factoryProfile) {
|
|
999
|
+
throw new Error(`A complete factory reset has not been captured for profile format ${info.profileFormatId}.`);
|
|
1000
|
+
}
|
|
1001
|
+
if (profileCrc(factoryProfile) !== storedCrc(factoryProfile)) {
|
|
1002
|
+
throw new Error("The built-in factory profile failed its checksum; refusing to write.");
|
|
1003
|
+
}
|
|
1004
|
+
const directory = await this.readProfileSector(feature.index, 0x0000, sectorSize);
|
|
1005
|
+
if (profileCrc(directory) !== storedCrc(directory)) {
|
|
1006
|
+
throw new Error("The profile directory failed its checksum; refusing to reset.");
|
|
1007
|
+
}
|
|
1008
|
+
const entries = parseDirectory(directory);
|
|
1009
|
+
if (entries.length === 0)
|
|
1010
|
+
throw new Error("The mouse reported no onboard profiles to reset.");
|
|
1011
|
+
const firstSector = entries[0].sector;
|
|
1012
|
+
// Host mode keeps the mouse from loading a sector while it is being
|
|
1013
|
+
// replaced. The operation deliberately finishes in the vendor-reset state:
|
|
1014
|
+
// onboard mode, first sector current, and only that sector enabled.
|
|
1015
|
+
await this.setOnboardMode("Host");
|
|
1016
|
+
let finished = false;
|
|
1017
|
+
try {
|
|
1018
|
+
for (const entry of entries) {
|
|
1019
|
+
const existing = await this.readProfileSector(feature.index, entry.sector, sectorSize);
|
|
1020
|
+
if (!existing.every((byte, index) => byte === factoryProfile[index])) {
|
|
1021
|
+
await this.writeProfileSector(feature.index, entry.sector, factoryProfile);
|
|
1022
|
+
}
|
|
1023
|
+
const confirmed = await this.readProfileSector(feature.index, entry.sector, sectorSize);
|
|
1024
|
+
if (!confirmed.every((byte, index) => byte === factoryProfile[index])) {
|
|
1025
|
+
throw new Error(`Profile ${entry.sector} did not confirm its factory reset.`);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
// Make the future current profile selectable before disabling the others.
|
|
1029
|
+
let updatedDirectory = setDirectoryEnabled(directory, firstSector, true);
|
|
1030
|
+
if (!updatedDirectory.every((byte, index) => byte === directory[index])) {
|
|
1031
|
+
await this.writeProfileSector(feature.index, 0x0000, updatedDirectory);
|
|
1032
|
+
}
|
|
1033
|
+
await this.request(feature.index, PROFILE_FN.setCurrentProfile, (firstSector >> 8) & 0xff, firstSector & 0xff);
|
|
1034
|
+
for (const entry of entries) {
|
|
1035
|
+
updatedDirectory = setDirectoryEnabled(updatedDirectory, entry.sector, entry.sector === firstSector);
|
|
1036
|
+
}
|
|
1037
|
+
if (!updatedDirectory.every((byte, index) => byte === directory[index])) {
|
|
1038
|
+
await this.writeProfileSector(feature.index, 0x0000, updatedDirectory);
|
|
1039
|
+
}
|
|
1040
|
+
const confirmedDirectory = await this.readProfileSector(feature.index, 0x0000, sectorSize);
|
|
1041
|
+
if (!confirmedDirectory.every((byte, index) => byte === updatedDirectory[index])) {
|
|
1042
|
+
throw new Error("The mouse did not confirm the reset profile directory.");
|
|
1043
|
+
}
|
|
1044
|
+
await this.setOnboardMode("Onboard");
|
|
1045
|
+
const current = await this.request(feature.index, PROFILE_FN.getCurrentProfile);
|
|
1046
|
+
const currentSector = ((current[3] ?? 0) << 8) | (current[4] ?? 0);
|
|
1047
|
+
if (currentSector !== firstSector) {
|
|
1048
|
+
throw new Error("The profiles were reset, but the mouse did not select the first profile.");
|
|
1049
|
+
}
|
|
1050
|
+
finished = true;
|
|
1051
|
+
}
|
|
1052
|
+
finally {
|
|
1053
|
+
// A failed sector write must not strand the mouse in software-controlled
|
|
1054
|
+
// host mode. Preserve the original error if recovery also fails.
|
|
1055
|
+
if (!finished)
|
|
1056
|
+
await this.setOnboardMode("Onboard").catch(() => undefined);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* Sets the bunny-hop timeout on the active profile.
|
|
1061
|
+
*
|
|
1062
|
+
* WRITES FLASH — one erase/write cycle. Stored as ms / 10, with 0 meaning
|
|
1063
|
+
* off; G HUB's range is 100-1000 ms. The encoding is confirmed on hardware,
|
|
1064
|
+
* but the write sequence itself is not, so callers should confirm first.
|
|
1065
|
+
*/
|
|
1066
|
+
/**
|
|
1067
|
+
* Reads the active profile and the context needed to write it back. Every
|
|
1068
|
+
* profile write starts here so the format check, the checksum check and the
|
|
1069
|
+
* sector-size fallback cannot drift apart between settings.
|
|
1070
|
+
*/
|
|
1071
|
+
async openActiveProfile(targetSector) {
|
|
1072
|
+
const feature = await this.getFeature(FEATURE.onboardProfiles);
|
|
1073
|
+
if (!feature.index) {
|
|
1074
|
+
throw new Error("This mouse does not expose onboard-profile controls.");
|
|
1075
|
+
}
|
|
1076
|
+
const info = parseProfilesInfo(await this.request(feature.index, PROFILE_FN.getInfo));
|
|
1077
|
+
const format = describeProfileFormat(info.profileFormatId);
|
|
1078
|
+
if (!isProfileWritable(format.id)) {
|
|
1079
|
+
throw new Error(`Profile format ${format.id} profile-content writes have not been verified on hardware.`);
|
|
1080
|
+
}
|
|
1081
|
+
// A profile can be edited without the mouse being switched to it, so the
|
|
1082
|
+
// caller names the sector; only the default follows the running profile.
|
|
1083
|
+
let sector = targetSector;
|
|
1084
|
+
if (sector === undefined) {
|
|
1085
|
+
const active = await this.request(feature.index, PROFILE_FN.getCurrentProfile);
|
|
1086
|
+
sector = ((active[3] ?? 0) << 8) | (active[4] ?? 0);
|
|
1087
|
+
}
|
|
1088
|
+
const sectorSize = info.sectorSize > 0 && info.sectorSize <= 1024 ? info.sectorSize : 255;
|
|
1089
|
+
const profile = await this.readProfileSector(feature.index, sector, sectorSize);
|
|
1090
|
+
if (profileCrc(profile) !== storedCrc(profile)) {
|
|
1091
|
+
throw new Error("The profile failed its checksum; refusing to write.");
|
|
1092
|
+
}
|
|
1093
|
+
return { featureIndex: feature.index, sector, sectorSize, formatId: format.id, profile };
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* Writes every changed setting of the active profile in one sector write.
|
|
1097
|
+
*
|
|
1098
|
+
* Flash is erased a whole sector at a time, so settings that share the sector
|
|
1099
|
+
* must be applied together: writing them one at a time would cost an erase
|
|
1100
|
+
* cycle each, and each write would be built from a read the previous one had
|
|
1101
|
+
* already invalidated.
|
|
1102
|
+
*/
|
|
1103
|
+
async writeActiveProfile(values) {
|
|
1104
|
+
const { featureIndex, sector, sectorSize, formatId, profile } = await this.openActiveProfile(values.sector);
|
|
1105
|
+
let updated = profile.slice();
|
|
1106
|
+
const macroBackups = [];
|
|
1107
|
+
for (const macro of values.buttonMacros ?? []) {
|
|
1108
|
+
const info = parseProfilesInfo(await this.request(featureIndex, PROFILE_FN.getInfo));
|
|
1109
|
+
if (info.macroFormatId === 0)
|
|
1110
|
+
throw new Error("This mouse does not advertise onboard macro storage.");
|
|
1111
|
+
const directoryBytes = await this.readProfileSector(featureIndex, 0, Math.min(sectorSize, 64));
|
|
1112
|
+
const profileSectors = new Set(parseDirectory(directoryBytes).map((entry) => entry.sector));
|
|
1113
|
+
const referenced = new Set();
|
|
1114
|
+
for (const profileSector of profileSectors) {
|
|
1115
|
+
const raw = await this.readProfileSector(featureIndex, profileSector, sectorSize);
|
|
1116
|
+
const decoded = decodeOnboardProfile(raw, formatId, { sector: profileSector, enabled: true }, false);
|
|
1117
|
+
for (const assignment of [...decoded.buttonAssignments, ...decoded.gShiftAssignments]) {
|
|
1118
|
+
if (assignment.raw[0] === 0x00 && assignment.raw[2] > 0)
|
|
1119
|
+
referenced.add(assignment.raw[2]);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
const currentAssignments = macro.layer === "primary"
|
|
1123
|
+
? decodeOnboardProfile(updated, formatId, { sector, enabled: true }, false).buttonAssignments
|
|
1124
|
+
: decodeOnboardProfile(updated, formatId, { sector, enabled: true }, false).gShiftAssignments;
|
|
1125
|
+
const currentMacro = currentAssignments[macro.button]?.raw;
|
|
1126
|
+
let macroSector = currentMacro?.[0] === 0x00 ? currentMacro[2] : undefined;
|
|
1127
|
+
if (!macroSector) {
|
|
1128
|
+
for (let candidate = info.profileCount + 1; candidate < info.sectorCount; candidate += 1) {
|
|
1129
|
+
if (profileSectors.has(candidate) || referenced.has(candidate))
|
|
1130
|
+
continue;
|
|
1131
|
+
const bytes = await this.readProfileSector(featureIndex, candidate, sectorSize);
|
|
1132
|
+
if (bytes.every((byte) => byte === 0xff) || bytes.every((byte, index) => byte === 0xff || (index === bytes.length - 1 && byte === 0x00))) {
|
|
1133
|
+
macroSector = candidate;
|
|
1134
|
+
break;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
if (!macroSector)
|
|
1139
|
+
throw new Error("No free onboard macro sector is available.");
|
|
1140
|
+
const backup = await this.readProfileSector(featureIndex, macroSector, sectorSize);
|
|
1141
|
+
const encoded = encodeMacroSector(sectorSize, macro.steps);
|
|
1142
|
+
try {
|
|
1143
|
+
await this.writeProfileSector(featureIndex, macroSector, encoded);
|
|
1144
|
+
const confirmed = await this.readProfileSector(featureIndex, macroSector, sectorSize);
|
|
1145
|
+
if (!confirmed.every((byte, index) => byte === encoded[index]))
|
|
1146
|
+
throw new Error("The mouse did not store the macro as written.");
|
|
1147
|
+
macroBackups.push({ sector: macroSector, bytes: backup });
|
|
1148
|
+
updated = encodeMacroButtonAssignment(updated, formatId, macro.layer, macro.button, macroSector);
|
|
1149
|
+
}
|
|
1150
|
+
catch (error) {
|
|
1151
|
+
await this.writeProfileSector(featureIndex, macroSector, backup).catch(() => undefined);
|
|
1152
|
+
throw error;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
if (values.bunnyHoppingMs !== null && values.bunnyHoppingMs !== undefined) {
|
|
1156
|
+
const invalid = validateBunnyHoppingMs(values.bunnyHoppingMs);
|
|
1157
|
+
if (invalid)
|
|
1158
|
+
throw new Error(invalid);
|
|
1159
|
+
const offset = layoutForFormat(formatId).bunnyHopping;
|
|
1160
|
+
if (offset === null) {
|
|
1161
|
+
throw new Error("This profile format has no bunny-hop setting.");
|
|
1162
|
+
}
|
|
1163
|
+
updated[offset] = values.bunnyHoppingMs / 10;
|
|
1164
|
+
}
|
|
1165
|
+
if (values.dpiStages) {
|
|
1166
|
+
const liveDpiOptions = await this.getDpiOptions();
|
|
1167
|
+
const dpiCapabilities = dpiStageCapabilitiesForOptions(capabilitiesForFormat(formatId).dpiStages, liveDpiOptions);
|
|
1168
|
+
const invalid = validateDpiStagePlan(values.dpiStages, dpiCapabilities, formatId < 6);
|
|
1169
|
+
if (invalid)
|
|
1170
|
+
throw new Error(invalid);
|
|
1171
|
+
for (const [index, stage] of values.dpiStages.stages.entries()) {
|
|
1172
|
+
if (!liveDpiOptions.includes(stage.x) || !liveDpiOptions.includes(stage.y)) {
|
|
1173
|
+
throw new Error(`Slot ${index + 1} uses a DPI value the connected mouse did not advertise.`);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
const writeLod = isLodWritableForProduct(formatId, this.device.productId);
|
|
1177
|
+
updated = encodeDpiStages(updated, formatId, values.dpiStages, dpiCapabilities, writeLod);
|
|
1178
|
+
}
|
|
1179
|
+
// The two links are stored separately, so each is set on its own.
|
|
1180
|
+
if (values.reportRateWirelessHz) {
|
|
1181
|
+
const liveRates = await this.getSupportedPollingRateOptions();
|
|
1182
|
+
const allowed = reportRatesForDevice(capabilitiesForFormat(formatId).reportRates, "wireless", liveRates, this.wiredConnection ? "wired" : "wireless", formatId < 6);
|
|
1183
|
+
if (!allowed.includes(values.reportRateWirelessHz)) {
|
|
1184
|
+
throw new Error("The connected mouse did not advertise that profile report rate.");
|
|
1185
|
+
}
|
|
1186
|
+
updated = encodeReportRate(updated, formatId, "wireless", values.reportRateWirelessHz);
|
|
1187
|
+
}
|
|
1188
|
+
if (values.reportRateWiredHz) {
|
|
1189
|
+
const liveRates = await this.getSupportedPollingRateOptions();
|
|
1190
|
+
const allowed = reportRatesForDevice(capabilitiesForFormat(formatId).reportRates, "wired", liveRates, this.wiredConnection ? "wired" : "wireless", formatId < 6);
|
|
1191
|
+
if (!allowed.includes(values.reportRateWiredHz)) {
|
|
1192
|
+
throw new Error("The connected mouse did not advertise that profile report rate.");
|
|
1193
|
+
}
|
|
1194
|
+
updated = encodeReportRate(updated, formatId, "wired", values.reportRateWiredHz);
|
|
1195
|
+
}
|
|
1196
|
+
if (values.name !== null && values.name !== undefined) {
|
|
1197
|
+
updated = encodeProfileName(updated, formatId, values.name);
|
|
1198
|
+
}
|
|
1199
|
+
for (const assignment of values.buttonAssignments ?? []) {
|
|
1200
|
+
updated = encodeButtonAssignment(updated, formatId, assignment.layer, assignment.button, assignment.binding);
|
|
1201
|
+
}
|
|
1202
|
+
applyCrc(updated);
|
|
1203
|
+
// Encoding is deterministic, so an identical result means nothing changed
|
|
1204
|
+
// and the write cycle can be skipped entirely.
|
|
1205
|
+
if (updated.every((byte, index) => byte === profile[index]))
|
|
1206
|
+
return;
|
|
1207
|
+
try {
|
|
1208
|
+
await this.writeProfileSector(featureIndex, sector, updated);
|
|
1209
|
+
const confirmed = await this.readProfileSector(featureIndex, sector, sectorSize);
|
|
1210
|
+
if (!confirmed.every((byte, index) => byte === updated[index])) {
|
|
1211
|
+
throw new Error("The mouse did not store the profile as written.");
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
catch (error) {
|
|
1215
|
+
// The profile sector is the pointer to the macro. Restore it first so an
|
|
1216
|
+
// interrupted commit cannot leave a corrupt or half-linked assignment.
|
|
1217
|
+
await this.writeProfileSector(featureIndex, sector, profile).catch(() => undefined);
|
|
1218
|
+
for (const backup of macroBackups) {
|
|
1219
|
+
await this.writeProfileSector(featureIndex, backup.sector, backup.bytes).catch(() => undefined);
|
|
1220
|
+
}
|
|
1221
|
+
throw error;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
async setBunnyHoppingMs(milliseconds) {
|
|
1225
|
+
await this.writeActiveProfile({ bunnyHoppingMs: milliseconds });
|
|
1226
|
+
}
|
|
1227
|
+
async setProfileDpiStages(plan) {
|
|
1228
|
+
await this.writeActiveProfile({ dpiStages: plan });
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* memoryAddrWrite declares the destination and length, memoryWrite streams
|
|
1232
|
+
* 16 bytes at a time, memoryWriteEnd commits and validates the checksum.
|
|
1233
|
+
*/
|
|
1234
|
+
async writeProfileSector(featureIndex, sector, bytes) {
|
|
1235
|
+
await this.requestLong(featureIndex, PROFILE_FN.memoryAddrWrite, [
|
|
1236
|
+
(sector >> 8) & 0xff,
|
|
1237
|
+
sector & 0xff,
|
|
1238
|
+
0x00,
|
|
1239
|
+
0x00,
|
|
1240
|
+
(bytes.length >> 8) & 0xff,
|
|
1241
|
+
bytes.length & 0xff,
|
|
1242
|
+
]);
|
|
1243
|
+
for (let offset = 0; offset < bytes.length; offset += 16) {
|
|
1244
|
+
const chunk = new Uint8Array(16).fill(0xff);
|
|
1245
|
+
chunk.set(bytes.slice(offset, offset + 16));
|
|
1246
|
+
await this.requestLong(featureIndex, PROFILE_FN.memoryWrite, [...chunk]);
|
|
1247
|
+
}
|
|
1248
|
+
await this.request(featureIndex, PROFILE_FN.memoryWriteEnd);
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Proves the profile write sequence on hardware.
|
|
1252
|
+
*
|
|
1253
|
+
* A no-op rewrite would be useless: writing a sector's own bytes back reads
|
|
1254
|
+
* identical whether the write landed or was silently ignored. So this flips
|
|
1255
|
+
* one byte, checks the change actually appears, then restores it and checks
|
|
1256
|
+
* the restore appears too. Both halves must pass.
|
|
1257
|
+
*
|
|
1258
|
+
* The target is an unused sector — disabled, and never the active profile —
|
|
1259
|
+
* so nothing the user relies on is at risk, and the original bytes are held
|
|
1260
|
+
* in memory for restore if a step fails midway. Cost is two erase cycles of
|
|
1261
|
+
* roughly 100,000, on a sector that is not in use.
|
|
1262
|
+
*/
|
|
1263
|
+
async probeProfileWriteSequence() {
|
|
1264
|
+
await this.open();
|
|
1265
|
+
const feature = await this.getFeature(FEATURE.onboardProfiles);
|
|
1266
|
+
if (!feature.index) {
|
|
1267
|
+
throw new Error("This mouse does not expose onboard-profile controls.");
|
|
1268
|
+
}
|
|
1269
|
+
const info = parseProfilesInfo(await this.request(feature.index, PROFILE_FN.getInfo));
|
|
1270
|
+
const sectorSize = info.sectorSize > 0 && info.sectorSize <= 1024 ? info.sectorSize : 255;
|
|
1271
|
+
const active = await this.request(feature.index, PROFILE_FN.getCurrentProfile);
|
|
1272
|
+
const currentSector = ((active[3] ?? 0) << 8) | (active[4] ?? 0);
|
|
1273
|
+
const directory = parseDirectory(await this.readProfileSector(feature.index, 0x0000, sectorSize));
|
|
1274
|
+
const target = directory.find((entry) => !entry.enabled && entry.sector !== currentSector);
|
|
1275
|
+
if (!target) {
|
|
1276
|
+
throw new Error("No spare profile to test on. Disable a profile you are not using, then run this again.");
|
|
1277
|
+
}
|
|
1278
|
+
const original = await this.readProfileSector(feature.index, target.sector, sectorSize);
|
|
1279
|
+
if (profileCrc(original) !== storedCrc(original)) {
|
|
1280
|
+
throw new Error("The spare profile failed its checksum; refusing to write to it.");
|
|
1281
|
+
}
|
|
1282
|
+
// Padding is the safest byte to disturb: it carries no setting, and if the
|
|
1283
|
+
// restore fails the profile is disabled anyway.
|
|
1284
|
+
let offset = -1;
|
|
1285
|
+
for (let index = original.length - 3; index >= 0; index -= 1) {
|
|
1286
|
+
if (original[index] === 0xff) {
|
|
1287
|
+
offset = index;
|
|
1288
|
+
break;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
if (offset < 0)
|
|
1292
|
+
offset = original.length - 3;
|
|
1293
|
+
const originalByte = original[offset] ?? 0xff;
|
|
1294
|
+
const probeByte = originalByte ^ 0x01;
|
|
1295
|
+
const probe = original.slice();
|
|
1296
|
+
probe[offset] = probeByte;
|
|
1297
|
+
applyCrc(probe);
|
|
1298
|
+
const steps = [];
|
|
1299
|
+
const runStep = async (name, expected) => {
|
|
1300
|
+
await this.writeProfileSector(feature.index, target.sector, expected);
|
|
1301
|
+
const readBack = await this.readProfileSector(feature.index, target.sector, sectorSize);
|
|
1302
|
+
const mismatches = [];
|
|
1303
|
+
for (let index = 0; index < expected.length; index += 1) {
|
|
1304
|
+
const want = expected[index] ?? 0;
|
|
1305
|
+
const got = readBack[index] ?? 0;
|
|
1306
|
+
if (want !== got && mismatches.length < 16)
|
|
1307
|
+
mismatches.push({ offset: index, expected: want, actual: got });
|
|
1308
|
+
}
|
|
1309
|
+
const checksumValid = profileCrc(readBack) === storedCrc(readBack);
|
|
1310
|
+
steps.push({ name, ok: mismatches.length === 0 && checksumValid, checksumValid, mismatches });
|
|
1311
|
+
return mismatches.length === 0;
|
|
1312
|
+
};
|
|
1313
|
+
const changed = await runStep("write probe byte", probe);
|
|
1314
|
+
const restored = await runStep("restore original", original);
|
|
1315
|
+
return {
|
|
1316
|
+
sector: target.sector,
|
|
1317
|
+
sectorSize,
|
|
1318
|
+
offset,
|
|
1319
|
+
originalByte,
|
|
1320
|
+
probeByte,
|
|
1321
|
+
steps,
|
|
1322
|
+
// Both halves matter: the first shows a write takes effect, the second
|
|
1323
|
+
// shows it is repeatable and leaves the sector as it was found.
|
|
1324
|
+
ok: changed && restored,
|
|
1325
|
+
restored,
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Reads and retains everything required to recover a legacy format-2/3/4 profile before
|
|
1330
|
+
* the semantic write probe starts. The UI copies this object first.
|
|
1331
|
+
*/
|
|
1332
|
+
async prepareProfileContentWriteProbe() {
|
|
1333
|
+
await this.open();
|
|
1334
|
+
const feature = await this.getFeature(FEATURE.onboardProfiles);
|
|
1335
|
+
if (!feature.index)
|
|
1336
|
+
throw new Error("This mouse does not expose onboard-profile controls.");
|
|
1337
|
+
const info = parseProfilesInfo(await this.request(feature.index, PROFILE_FN.getInfo));
|
|
1338
|
+
if (!supportsProfileWriteProbe(info.profileFormatId)) {
|
|
1339
|
+
throw new Error(`The guided content probe currently supports profile formats 2, 3, and 4, not ${info.profileFormatId}.`);
|
|
1340
|
+
}
|
|
1341
|
+
const sectorSize = info.sectorSize > 0 && info.sectorSize <= 1024 ? info.sectorSize : 255;
|
|
1342
|
+
const modeReply = await this.request(feature.index, PROFILE_FN.getMode);
|
|
1343
|
+
const originalMode = modeReply[3] === ONBOARD_MODE.onboard ? "Onboard" : "Host";
|
|
1344
|
+
const currentReply = await this.request(feature.index, PROFILE_FN.getCurrentProfile);
|
|
1345
|
+
const originalCurrentSector = ((currentReply[3] ?? 0) << 8) | (currentReply[4] ?? 0);
|
|
1346
|
+
const directory = await this.readProfileSector(feature.index, 0x0000, sectorSize);
|
|
1347
|
+
if (profileCrc(directory) !== storedCrc(directory))
|
|
1348
|
+
throw new Error("The profile directory checksum is invalid.");
|
|
1349
|
+
const target = parseDirectory(directory).find((entry) => entry.enabled) ?? parseDirectory(directory)[0];
|
|
1350
|
+
if (!target)
|
|
1351
|
+
throw new Error("The mouse reported no profile to test.");
|
|
1352
|
+
const profile = await this.readProfileSector(feature.index, target.sector, sectorSize);
|
|
1353
|
+
if (profileCrc(profile) !== storedCrc(profile))
|
|
1354
|
+
throw new Error("The profile checksum is invalid.");
|
|
1355
|
+
const dpiFeature = await this.resolveDpiFeature();
|
|
1356
|
+
const rateFeature = await this.resolveReportRateFeature();
|
|
1357
|
+
if (!dpiFeature.index || !dpiFeature.legacy || !rateFeature.index || !rateFeature.legacy) {
|
|
1358
|
+
throw new Error("Format-4 verification requires legacy DPI and report-rate features.");
|
|
1359
|
+
}
|
|
1360
|
+
return {
|
|
1361
|
+
formatId: info.profileFormatId,
|
|
1362
|
+
sector: target.sector,
|
|
1363
|
+
sectorSize,
|
|
1364
|
+
originalMode,
|
|
1365
|
+
originalCurrentSector,
|
|
1366
|
+
directory,
|
|
1367
|
+
profile,
|
|
1368
|
+
dpiOptions: await this.getDpiOptions(),
|
|
1369
|
+
reportRates: await this.readLegacyReportRates(rateFeature.index),
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Applies temporary format-2/3/4 name, DPI and rate values, confirms each sector
|
|
1374
|
+
* read-back, confirms live DPI/rate, and restores the original after every
|
|
1375
|
+
* step. The caller must copy the backup before invoking this method.
|
|
1376
|
+
*/
|
|
1377
|
+
async runProfileContentWriteProbe(backup) {
|
|
1378
|
+
await this.open();
|
|
1379
|
+
if (!supportsProfileWriteProbe(backup.formatId)) {
|
|
1380
|
+
throw new Error("Only profile formats 2, 3, and 4 are supported by this probe.");
|
|
1381
|
+
}
|
|
1382
|
+
const feature = await this.getFeature(FEATURE.onboardProfiles);
|
|
1383
|
+
if (!feature.index)
|
|
1384
|
+
throw new Error("This mouse does not expose onboard-profile controls.");
|
|
1385
|
+
const info = parseProfilesInfo(await this.request(feature.index, PROFILE_FN.getInfo));
|
|
1386
|
+
if (info.profileFormatId !== backup.formatId || info.sectorSize !== backup.sectorSize) {
|
|
1387
|
+
throw new Error("The connected mouse no longer matches the copied backup.");
|
|
1388
|
+
}
|
|
1389
|
+
const sameBytes = (left, right) => left.length === right.length && left.every((byte, index) => byte === right[index]);
|
|
1390
|
+
const currentDirectory = await this.readProfileSector(feature.index, 0x0000, backup.sectorSize);
|
|
1391
|
+
const currentProfile = await this.readProfileSector(feature.index, backup.sector, backup.sectorSize);
|
|
1392
|
+
if (!sameBytes(currentDirectory, backup.directory) || !sameBytes(currentProfile, backup.profile)) {
|
|
1393
|
+
throw new Error("The profile changed after the recovery backup was copied; take a new backup.");
|
|
1394
|
+
}
|
|
1395
|
+
const dpiFeature = await this.resolveDpiFeature();
|
|
1396
|
+
const rateFeature = await this.resolveReportRateFeature();
|
|
1397
|
+
if (!dpiFeature.index || !dpiFeature.legacy || !rateFeature.index || !rateFeature.legacy) {
|
|
1398
|
+
throw new Error("The required legacy DPI or report-rate feature disappeared.");
|
|
1399
|
+
}
|
|
1400
|
+
const original = decodeOnboardProfile(backup.profile, backup.formatId, { sector: backup.sector, enabled: true }, false);
|
|
1401
|
+
const temporaryName = original.name === "OM_VERIFY" ? "OM_VERIFY_2" : "OM_VERIFY";
|
|
1402
|
+
const currentDpi = original.dpiStages[original.defaultDpiIndex ?? 0]?.x;
|
|
1403
|
+
const temporaryDpi = backup.dpiOptions.includes(1000) && currentDpi !== 1000
|
|
1404
|
+
? 1000
|
|
1405
|
+
: backup.dpiOptions.find((dpi) => dpi !== currentDpi);
|
|
1406
|
+
const currentRate = original.reportRateWired;
|
|
1407
|
+
const temporaryRate = backup.reportRates.includes(500) && currentRate !== 500
|
|
1408
|
+
? 500
|
|
1409
|
+
: backup.reportRates.find((rate) => rate !== currentRate);
|
|
1410
|
+
if (!temporaryDpi || !temporaryRate || original.defaultDpiIndex === null || original.dpiStages.length === 0) {
|
|
1411
|
+
throw new Error("The mouse did not advertise an alternate safe DPI and polling rate.");
|
|
1412
|
+
}
|
|
1413
|
+
const dpiStages = original.dpiStages.map((stage) => ({ ...stage }));
|
|
1414
|
+
dpiStages[original.defaultDpiIndex] = { x: temporaryDpi, y: temporaryDpi, lod: 0 };
|
|
1415
|
+
const runtimeDpiLimits = {
|
|
1416
|
+
maxStages: 5,
|
|
1417
|
+
minDpi: Math.min(...backup.dpiOptions),
|
|
1418
|
+
maxDpi: Math.max(...backup.dpiOptions),
|
|
1419
|
+
// The selected probe value is checked against the exact advertised list
|
|
1420
|
+
// above. A unit step avoids pretending a sparse device list is uniform.
|
|
1421
|
+
stepDpi: 1,
|
|
1422
|
+
};
|
|
1423
|
+
const maximumRate = Math.max(...backup.reportRates);
|
|
1424
|
+
const runtimeRateLimits = { wirelessMaxHz: maximumRate, wiredMaxHz: maximumRate };
|
|
1425
|
+
const candidates = [
|
|
1426
|
+
{
|
|
1427
|
+
setting: "name",
|
|
1428
|
+
intended: temporaryName,
|
|
1429
|
+
bytes: encodeProfileName(backup.profile, backup.formatId, temporaryName, PROFILE_NAME_MAX_CHARS),
|
|
1430
|
+
confirmLive: null,
|
|
1431
|
+
},
|
|
1432
|
+
{
|
|
1433
|
+
setting: "dpi",
|
|
1434
|
+
intended: `${temporaryDpi} DPI`,
|
|
1435
|
+
bytes: encodeDpiStages(backup.profile, backup.formatId, { stages: dpiStages, defaultIndex: original.defaultDpiIndex }, runtimeDpiLimits),
|
|
1436
|
+
confirmLive: async () => (await this.readLegacyDpi(dpiFeature.index)).dpi === temporaryDpi,
|
|
1437
|
+
},
|
|
1438
|
+
{
|
|
1439
|
+
setting: "polling",
|
|
1440
|
+
intended: `${temporaryRate} Hz`,
|
|
1441
|
+
bytes: encodeReportRate(backup.profile, backup.formatId, "wired", temporaryRate, runtimeRateLimits),
|
|
1442
|
+
confirmLive: async () => (await this.readLegacyReportRate(rateFeature.index)) === temporaryRate,
|
|
1443
|
+
},
|
|
1444
|
+
];
|
|
1445
|
+
const steps = [];
|
|
1446
|
+
for (const candidate of candidates) {
|
|
1447
|
+
const step = {
|
|
1448
|
+
setting: candidate.setting,
|
|
1449
|
+
intended: candidate.intended,
|
|
1450
|
+
storedExactly: false,
|
|
1451
|
+
liveConfirmed: null,
|
|
1452
|
+
restored: false,
|
|
1453
|
+
error: null,
|
|
1454
|
+
};
|
|
1455
|
+
try {
|
|
1456
|
+
await this.setOnboardMode("Host");
|
|
1457
|
+
await this.writeProfileSector(feature.index, backup.sector, candidate.bytes);
|
|
1458
|
+
const readBack = await this.readProfileSector(feature.index, backup.sector, backup.sectorSize);
|
|
1459
|
+
step.storedExactly = sameBytes(readBack, candidate.bytes)
|
|
1460
|
+
&& profileCrc(readBack) === storedCrc(readBack);
|
|
1461
|
+
if (!step.storedExactly)
|
|
1462
|
+
throw new Error("The temporary sector did not read back exactly.");
|
|
1463
|
+
if (candidate.confirmLive) {
|
|
1464
|
+
// Legacy devices reject setCurrentProfile while host mode is active.
|
|
1465
|
+
// Enter onboard mode first, matching the normal profile selector.
|
|
1466
|
+
await this.setOnboardMode("Onboard");
|
|
1467
|
+
await this.setCurrentProfile(backup.sector);
|
|
1468
|
+
step.liveConfirmed = await candidate.confirmLive();
|
|
1469
|
+
if (!step.liveConfirmed)
|
|
1470
|
+
throw new Error("The live HID++ value did not match the temporary profile value.");
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
catch (error) {
|
|
1474
|
+
step.error = error instanceof Error ? error.message : "The verification step failed.";
|
|
1475
|
+
}
|
|
1476
|
+
finally {
|
|
1477
|
+
await this.setOnboardMode("Host").catch(() => undefined);
|
|
1478
|
+
try {
|
|
1479
|
+
await this.writeProfileSector(feature.index, backup.sector, backup.profile);
|
|
1480
|
+
const restored = await this.readProfileSector(feature.index, backup.sector, backup.sectorSize);
|
|
1481
|
+
step.restored = sameBytes(restored, backup.profile)
|
|
1482
|
+
&& profileCrc(restored) === storedCrc(restored);
|
|
1483
|
+
}
|
|
1484
|
+
catch (error) {
|
|
1485
|
+
step.error ??= error instanceof Error ? error.message : "Could not restore the original profile.";
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
steps.push(step);
|
|
1489
|
+
if (!step.restored)
|
|
1490
|
+
break;
|
|
1491
|
+
}
|
|
1492
|
+
let modeRestored = false;
|
|
1493
|
+
try {
|
|
1494
|
+
// setCurrentProfile is an onboard-mode operation on legacy hardware. If
|
|
1495
|
+
// the mouse started in host mode, briefly select its original sector in
|
|
1496
|
+
// onboard mode and only then return it to host mode.
|
|
1497
|
+
await this.setOnboardMode("Onboard");
|
|
1498
|
+
if (backup.originalCurrentSector > 0)
|
|
1499
|
+
await this.setCurrentProfile(backup.originalCurrentSector);
|
|
1500
|
+
if (backup.originalMode === "Host")
|
|
1501
|
+
await this.setOnboardMode("Host");
|
|
1502
|
+
const mode = await this.request(feature.index, PROFILE_FN.getMode);
|
|
1503
|
+
modeRestored = mode[3] === (backup.originalMode === "Onboard" ? ONBOARD_MODE.onboard : ONBOARD_MODE.host);
|
|
1504
|
+
}
|
|
1505
|
+
catch {
|
|
1506
|
+
modeRestored = false;
|
|
1507
|
+
}
|
|
1508
|
+
const finalProfile = await this.readProfileSector(feature.index, backup.sector, backup.sectorSize).catch(() => null);
|
|
1509
|
+
const restored = finalProfile !== null && sameBytes(finalProfile, backup.profile)
|
|
1510
|
+
&& profileCrc(finalProfile) === storedCrc(finalProfile);
|
|
1511
|
+
const ok = steps.length === candidates.length
|
|
1512
|
+
&& steps.every((step) => step.storedExactly && step.restored && step.error === null
|
|
1513
|
+
&& (step.liveConfirmed === null || step.liveConfirmed))
|
|
1514
|
+
&& restored && modeRestored;
|
|
1515
|
+
return { backup, steps, restored, modeRestored, ok };
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* Reads and decodes every onboard profile. Deliberately not part of
|
|
1519
|
+
* readStatus: a full pass is ~80 HID++ round trips, far too slow for the
|
|
1520
|
+
* refresh loop, so the UI loads it on demand.
|
|
1521
|
+
*/
|
|
1522
|
+
async readOnboardProfiles() {
|
|
1523
|
+
await this.open();
|
|
1524
|
+
const feature = await this.getFeature(FEATURE.onboardProfiles);
|
|
1525
|
+
if (!feature.index)
|
|
1526
|
+
return [];
|
|
1527
|
+
const infoReply = await this.request(feature.index, PROFILE_FN.getInfo);
|
|
1528
|
+
const info = parseProfilesInfo(infoReply);
|
|
1529
|
+
const buttonCount = Math.min(infoReply[8] ?? 0, 16);
|
|
1530
|
+
if (!info.profileFormatId)
|
|
1531
|
+
return [];
|
|
1532
|
+
const active = await this.request(feature.index, PROFILE_FN.getCurrentProfile);
|
|
1533
|
+
const currentSector = ((active[3] ?? 0) << 8) | (active[4] ?? 0);
|
|
1534
|
+
const sectorSize = info.sectorSize > 0 && info.sectorSize <= 1024 ? info.sectorSize : 255;
|
|
1535
|
+
const directory = parseDirectory(await this.readProfileSector(feature.index, 0x0000, Math.min(sectorSize, 64)));
|
|
1536
|
+
const profiles = [];
|
|
1537
|
+
for (const entry of directory) {
|
|
1538
|
+
const bytes = await this.readProfileSector(feature.index, entry.sector, sectorSize);
|
|
1539
|
+
const profile = decodeOnboardProfile(bytes, info.profileFormatId, entry, entry.sector === currentSector);
|
|
1540
|
+
if (buttonCount > 0) {
|
|
1541
|
+
profile.buttonAssignments = profile.buttonAssignments.slice(0, buttonCount);
|
|
1542
|
+
profile.gShiftAssignments = profile.gShiftAssignments.slice(0, buttonCount);
|
|
1543
|
+
}
|
|
1544
|
+
profiles.push(profile);
|
|
1545
|
+
}
|
|
1546
|
+
return profiles;
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Collects a self-contained, read-only dump for validating any profile
|
|
1550
|
+
* format. Unlike readOnboardProfiles, this keeps the full directory and the
|
|
1551
|
+
* raw protocol replies so a maintainer can verify geometry and parsing rather
|
|
1552
|
+
* than trusting OpenMouse's current assumptions.
|
|
1553
|
+
*/
|
|
1554
|
+
async readOnboardProfileVerification() {
|
|
1555
|
+
await this.open();
|
|
1556
|
+
const feature = await this.getFeature(FEATURE.onboardProfiles);
|
|
1557
|
+
if (!feature.index) {
|
|
1558
|
+
throw new Error("This mouse does not expose onboard-profile controls.");
|
|
1559
|
+
}
|
|
1560
|
+
const infoReply = await this.request(feature.index, PROFILE_FN.getInfo);
|
|
1561
|
+
const info = parseProfilesInfo(infoReply);
|
|
1562
|
+
if (!info.profileFormatId) {
|
|
1563
|
+
throw new Error("The mouse did not report an onboard-profile format.");
|
|
1564
|
+
}
|
|
1565
|
+
const sectorSize = info.sectorSize > 0 && info.sectorSize <= 1024 ? info.sectorSize : 255;
|
|
1566
|
+
const modeReply = await this.request(feature.index, PROFILE_FN.getMode);
|
|
1567
|
+
const modeValue = modeReply[3] ?? 0;
|
|
1568
|
+
const mode = modeValue === ONBOARD_MODE.onboard
|
|
1569
|
+
? "Onboard"
|
|
1570
|
+
: modeValue === ONBOARD_MODE.host
|
|
1571
|
+
? "Host"
|
|
1572
|
+
: "Unknown";
|
|
1573
|
+
const currentProfileReply = await this.request(feature.index, PROFILE_FN.getCurrentProfile);
|
|
1574
|
+
const currentSector = ((currentProfileReply[3] ?? 0) << 8) | (currentProfileReply[4] ?? 0);
|
|
1575
|
+
// The directory is evidence too: its flags establish which sectors exist,
|
|
1576
|
+
// and its checksum confirms that the complete sector was read correctly.
|
|
1577
|
+
const directory = await this.readProfileSector(feature.index, 0x0000, sectorSize);
|
|
1578
|
+
const entries = parseDirectory(directory);
|
|
1579
|
+
const profiles = [];
|
|
1580
|
+
for (const entry of entries) {
|
|
1581
|
+
const bytes = await this.readProfileSector(feature.index, entry.sector, sectorSize);
|
|
1582
|
+
profiles.push(decodeOnboardProfile(bytes, info.profileFormatId, entry, entry.sector === currentSector));
|
|
1583
|
+
}
|
|
1584
|
+
const dpiCapabilities = await this.readDpiVerificationCapability();
|
|
1585
|
+
const reportRateCapabilities = await this.readReportRateVerificationCapability();
|
|
1586
|
+
return {
|
|
1587
|
+
info,
|
|
1588
|
+
infoReply,
|
|
1589
|
+
mode,
|
|
1590
|
+
modeValue,
|
|
1591
|
+
modeReply,
|
|
1592
|
+
currentSector,
|
|
1593
|
+
currentProfileReply,
|
|
1594
|
+
directory,
|
|
1595
|
+
directoryCrcValid: profileCrc(directory) === storedCrc(directory),
|
|
1596
|
+
profiles,
|
|
1597
|
+
dpiCapabilities,
|
|
1598
|
+
reportRateCapabilities,
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1601
|
+
async verificationFeature(extendedId, legacyId) {
|
|
1602
|
+
for (const [featureId, kind] of [[extendedId, "extended"], [legacyId, "legacy"]]) {
|
|
1603
|
+
try {
|
|
1604
|
+
const featureReply = await this.request(0x00, 0x00, featureId >> 8, featureId & 0xff);
|
|
1605
|
+
const featureIndex = featureReply[3] ?? 0;
|
|
1606
|
+
if (!featureIndex)
|
|
1607
|
+
continue;
|
|
1608
|
+
return {
|
|
1609
|
+
featureId,
|
|
1610
|
+
featureIndex,
|
|
1611
|
+
featureVersion: featureReply[6] ?? 0,
|
|
1612
|
+
kind,
|
|
1613
|
+
replies: [{ name: "getFeature", bytes: featureReply }],
|
|
1614
|
+
decodedValues: [],
|
|
1615
|
+
error: null,
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
catch {
|
|
1619
|
+
// Missing optional features must not prevent the profile dump.
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
return null;
|
|
1623
|
+
}
|
|
1624
|
+
async readDpiVerificationCapability() {
|
|
1625
|
+
const resolved = await this.verificationFeature(FEATURE.extendedDpi, FEATURE.adjustableDpi);
|
|
1626
|
+
if (!resolved)
|
|
1627
|
+
return null;
|
|
1628
|
+
const result = resolved;
|
|
1629
|
+
try {
|
|
1630
|
+
if (result.kind === "legacy") {
|
|
1631
|
+
result.replies.push({
|
|
1632
|
+
name: "getSensorDpiList",
|
|
1633
|
+
bytes: await this.request(result.featureIndex, 0x10, 0x00),
|
|
1634
|
+
});
|
|
1635
|
+
result.replies.push({
|
|
1636
|
+
name: "getSensorDpi",
|
|
1637
|
+
bytes: await this.request(result.featureIndex, 0x20, 0x00),
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
else {
|
|
1641
|
+
result.replies.push({
|
|
1642
|
+
name: "getSensorCapabilities",
|
|
1643
|
+
bytes: await this.request(result.featureIndex, 0x10, 0x00),
|
|
1644
|
+
});
|
|
1645
|
+
const listBytes = [];
|
|
1646
|
+
for (let page = 0; page < 32; page += 1) {
|
|
1647
|
+
const reply = await this.request(result.featureIndex, 0x20, 0x00, 0x00, page);
|
|
1648
|
+
result.replies.push({ name: `getSensorDpiList page ${page}`, bytes: reply });
|
|
1649
|
+
listBytes.push(...reply.slice(6));
|
|
1650
|
+
if (listBytes.some((value, index) => index > 0 && listBytes[index - 1] === 0 && value === 0))
|
|
1651
|
+
break;
|
|
1652
|
+
}
|
|
1653
|
+
result.replies.push({
|
|
1654
|
+
name: "getSensorDpi",
|
|
1655
|
+
bytes: await this.request(result.featureIndex, 0x50),
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
result.decodedValues = await this.getDpiOptions();
|
|
1659
|
+
}
|
|
1660
|
+
catch (error) {
|
|
1661
|
+
result.error = error instanceof Error ? error.message : "Could not read DPI capabilities.";
|
|
1662
|
+
}
|
|
1663
|
+
return result;
|
|
1664
|
+
}
|
|
1665
|
+
async readReportRateVerificationCapability() {
|
|
1666
|
+
const resolved = await this.verificationFeature(FEATURE.extendedReportRate, FEATURE.reportRate);
|
|
1667
|
+
if (!resolved)
|
|
1668
|
+
return null;
|
|
1669
|
+
const result = resolved;
|
|
1670
|
+
try {
|
|
1671
|
+
if (result.kind === "legacy") {
|
|
1672
|
+
const listReply = await this.request(result.featureIndex, 0x00);
|
|
1673
|
+
result.replies.push({ name: "getReportRateList", bytes: listReply });
|
|
1674
|
+
result.replies.push({
|
|
1675
|
+
name: "getReportRate",
|
|
1676
|
+
bytes: await this.request(result.featureIndex, 0x10),
|
|
1677
|
+
});
|
|
1678
|
+
result.decodedValues = decodeReportRateBitmap(listReply[3] ?? 0);
|
|
1679
|
+
}
|
|
1680
|
+
else {
|
|
1681
|
+
const listReply = await this.request(result.featureIndex, 0x10);
|
|
1682
|
+
result.replies.push({ name: "getSupportedReportRates", bytes: listReply });
|
|
1683
|
+
result.replies.push({
|
|
1684
|
+
name: "getReportRate",
|
|
1685
|
+
bytes: await this.request(result.featureIndex, 0x20),
|
|
1686
|
+
});
|
|
1687
|
+
const flags = ((listReply[3] ?? 0) << 8) | (listReply[4] ?? 0);
|
|
1688
|
+
result.decodedValues = REPORT_RATE_HZ.filter((_rate, index) => (flags & (1 << index)) !== 0);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
catch (error) {
|
|
1692
|
+
result.error = error instanceof Error ? error.message : "Could not read report-rate capabilities.";
|
|
1693
|
+
}
|
|
1694
|
+
return result;
|
|
1695
|
+
}
|
|
1696
|
+
/**
|
|
1697
|
+
* memoryRead returns 16 bytes per call and rejects a read running past the
|
|
1698
|
+
* end of the sector, so a sector length that is not a multiple of 16 needs its
|
|
1699
|
+
* tail fetched from `length - 16`, overlapping the previous chunk.
|
|
1700
|
+
*/
|
|
1701
|
+
async readProfileSector(featureIndex, sector, length) {
|
|
1702
|
+
const buffer = new Uint8Array(length);
|
|
1703
|
+
const readChunk = async (offset) => {
|
|
1704
|
+
const reply = await this.requestLong(featureIndex, PROFILE_FN.memoryRead, [
|
|
1705
|
+
(sector >> 8) & 0xff,
|
|
1706
|
+
sector & 0xff,
|
|
1707
|
+
(offset >> 8) & 0xff,
|
|
1708
|
+
offset & 0xff,
|
|
1709
|
+
]);
|
|
1710
|
+
buffer.set(reply.slice(3, 3 + Math.min(16, length - offset)), offset);
|
|
1711
|
+
};
|
|
1712
|
+
let offset = 0;
|
|
1713
|
+
for (; offset + 16 <= length; offset += 16) {
|
|
1714
|
+
await readChunk(offset);
|
|
1715
|
+
}
|
|
1716
|
+
if (offset < length) {
|
|
1717
|
+
await readChunk(length - 16);
|
|
1718
|
+
}
|
|
1719
|
+
return buffer;
|
|
1720
|
+
}
|
|
1721
|
+
/**
|
|
1722
|
+
* Haptic configuration, or null on a device without feature 0x19B0. Only the
|
|
1723
|
+
* MX Master 4 is known to carry it.
|
|
1724
|
+
*/
|
|
1725
|
+
async readHapticConfig() {
|
|
1726
|
+
const feature = await this.getFeature(FEATURE.haptic);
|
|
1727
|
+
if (!feature.index)
|
|
1728
|
+
return null;
|
|
1729
|
+
const reply = await this.request(feature.index, LOGITECH_HAPTIC.get).catch(() => null);
|
|
1730
|
+
return reply ? decodeHapticConfig(reply.slice(3)) : null;
|
|
1731
|
+
}
|
|
1732
|
+
/**
|
|
1733
|
+
* Read-modify-write of the two-byte pair, then verify. Both fields share the
|
|
1734
|
+
* write, so a caller changing one has to supply the other exactly as the
|
|
1735
|
+
* device reports it, and bits 2-7 of the flag byte ride through untouched.
|
|
1736
|
+
*/
|
|
1737
|
+
async writeHapticConfig(change) {
|
|
1738
|
+
const feature = await this.getFeature(FEATURE.haptic);
|
|
1739
|
+
if (!feature.index)
|
|
1740
|
+
throw new Error("This mouse has no haptic feature.");
|
|
1741
|
+
const current = decodeHapticConfig((await this.request(feature.index, LOGITECH_HAPTIC.get)).slice(3));
|
|
1742
|
+
if (!current)
|
|
1743
|
+
throw new Error("The mouse gave no answer when its haptic settings were read.");
|
|
1744
|
+
const flagByte = change.flag
|
|
1745
|
+
? encodeHapticFlags(current.flagByte, change.flag.name, change.flag.on)
|
|
1746
|
+
: current.flagByte;
|
|
1747
|
+
const intensity = change.intensity ?? current.intensity;
|
|
1748
|
+
const reply = await this.request(feature.index, LOGITECH_HAPTIC.set, ...buildHapticConfigWrite(flagByte, intensity));
|
|
1749
|
+
const confirmed = decodeHapticConfig(reply.slice(3));
|
|
1750
|
+
if (!confirmed)
|
|
1751
|
+
throw new Error("The mouse gave no answer to the haptic write.");
|
|
1752
|
+
return confirmed;
|
|
1753
|
+
}
|
|
1754
|
+
/**
|
|
1755
|
+
* Scroll-wheel state across 0x2111, 0x2121 and 0x2150. Every field stays
|
|
1756
|
+
* absent rather than guessed when its feature is missing, so a mouse
|
|
1757
|
+
* without a thumb wheel does not get a control that can only fail.
|
|
1758
|
+
*/
|
|
1759
|
+
async readWheelState() {
|
|
1760
|
+
const smartShift = await this.getFeature(FEATURE.smartShift);
|
|
1761
|
+
const ratchetReply = smartShift.index
|
|
1762
|
+
? await this.request(smartShift.index, LOGITECH_SMART_SHIFT.get).catch(() => null)
|
|
1763
|
+
: null;
|
|
1764
|
+
const ratchet = ratchetReply ? decodeRatchetControl(ratchetReply.slice(3)) : null;
|
|
1765
|
+
const wheel = await this.getFeature(FEATURE.hiresWheel);
|
|
1766
|
+
let hiResScroll = null;
|
|
1767
|
+
let invertScroll = null;
|
|
1768
|
+
let supportsInvertScroll = this.wheelCapabilityCache?.supportsInvertScroll ?? false;
|
|
1769
|
+
let wheelRatchetEngaged = null;
|
|
1770
|
+
if (wheel.index) {
|
|
1771
|
+
// Whether the wheel can invert is a property of the hardware, so it is
|
|
1772
|
+
// read once rather than on every refresh.
|
|
1773
|
+
if (this.wheelCapabilityCache === undefined) {
|
|
1774
|
+
const capabilityReply = await this.request(wheel.index, LOGITECH_HIRES_WHEEL.capabilities).catch(() => null);
|
|
1775
|
+
const capabilities = capabilityReply ? decodeHiresWheelCapabilities(capabilityReply.slice(3)) : null;
|
|
1776
|
+
supportsInvertScroll = capabilities?.supportsInvert ?? false;
|
|
1777
|
+
}
|
|
1778
|
+
const modeReply = await this.request(wheel.index, LOGITECH_HIRES_WHEEL.get).catch(() => null);
|
|
1779
|
+
if (modeReply) {
|
|
1780
|
+
const mode = decodeHiresWheelMode(modeReply[3] ?? 0);
|
|
1781
|
+
hiResScroll = mode.hiRes;
|
|
1782
|
+
invertScroll = supportsInvertScroll ? mode.inverted : null;
|
|
1783
|
+
}
|
|
1784
|
+
const stateReply = await this.request(wheel.index, LOGITECH_HIRES_WHEEL.ratchetState).catch(() => null);
|
|
1785
|
+
wheelRatchetEngaged = stateReply ? (stateReply[3] ?? 0) === 1 : null;
|
|
1786
|
+
}
|
|
1787
|
+
const thumb = await this.readThumbWheelState();
|
|
1788
|
+
// Seeded once both halves have been read, so a refresh never asks again.
|
|
1789
|
+
this.wheelCapabilityCache ??= {
|
|
1790
|
+
supportsInvertScroll,
|
|
1791
|
+
supportsThumbWheelInvert: thumb.supportsThumbWheelInvert,
|
|
1792
|
+
};
|
|
1793
|
+
return {
|
|
1794
|
+
wheelMode: ratchet?.mode ?? null,
|
|
1795
|
+
smartShiftThreshold: ratchet?.threshold ?? null,
|
|
1796
|
+
hiResScroll,
|
|
1797
|
+
invertScroll,
|
|
1798
|
+
supportsInvertScroll,
|
|
1799
|
+
wheelRatchetEngaged,
|
|
1800
|
+
...thumb,
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1803
|
+
async readThumbWheelState() {
|
|
1804
|
+
const feature = await this.getFeature(FEATURE.thumbWheel);
|
|
1805
|
+
if (!feature.index)
|
|
1806
|
+
return { thumbWheelInverted: null, supportsThumbWheelInvert: false };
|
|
1807
|
+
let supports = this.wheelCapabilityCache?.supportsThumbWheelInvert ?? false;
|
|
1808
|
+
if (this.wheelCapabilityCache === undefined) {
|
|
1809
|
+
const info = await this.request(feature.index, LOGITECH_THUMB_WHEEL.info).catch(() => null);
|
|
1810
|
+
supports = info ? decodeThumbWheelSupportsInvert(info.slice(3)) === true : false;
|
|
1811
|
+
}
|
|
1812
|
+
const status = await this.request(feature.index, LOGITECH_THUMB_WHEEL.get).catch(() => null);
|
|
1813
|
+
const decoded = status ? decodeThumbWheelStatus(status.slice(3)) : null;
|
|
1814
|
+
return { thumbWheelInverted: decoded?.inverted ?? null, supportsThumbWheelInvert: supports };
|
|
1815
|
+
}
|
|
1816
|
+
/** 0x2111 carries all three bytes, so each setter changes only its field. */
|
|
1817
|
+
async writeRatchetControl(change) {
|
|
1818
|
+
const feature = await this.getFeature(FEATURE.smartShift);
|
|
1819
|
+
if (!feature.index)
|
|
1820
|
+
throw new Error("This mouse has no SmartShift feature.");
|
|
1821
|
+
const current = decodeRatchetControl((await this.request(feature.index, LOGITECH_SMART_SHIFT.get)).slice(3));
|
|
1822
|
+
if (!current)
|
|
1823
|
+
throw new Error("The mouse gave no answer when its wheel settings were read.");
|
|
1824
|
+
const reply = await this.request(feature.index, LOGITECH_SMART_SHIFT.set, ...buildRatchetControlWrite(current, change));
|
|
1825
|
+
const confirmed = decodeRatchetControl(reply.slice(3));
|
|
1826
|
+
if (!confirmed)
|
|
1827
|
+
throw new Error("The mouse gave no answer to the wheel write.");
|
|
1828
|
+
return confirmed;
|
|
1829
|
+
}
|
|
1830
|
+
async setWheelMode(mode) {
|
|
1831
|
+
const confirmed = await this.writeRatchetControl({ mode });
|
|
1832
|
+
if (confirmed.mode !== mode)
|
|
1833
|
+
throw new Error(`The mouse kept the wheel in ${confirmed.mode} mode.`);
|
|
1834
|
+
return mode;
|
|
1835
|
+
}
|
|
1836
|
+
/** Passing null disables SmartShift; the ratchet mode is preserved either way. */
|
|
1837
|
+
async setSmartShiftThreshold(threshold) {
|
|
1838
|
+
const value = threshold === null ? LOGITECH_SMART_SHIFT_OFF : Math.round(threshold);
|
|
1839
|
+
if (!Number.isInteger(value) || value < 0 || value > 0xff) {
|
|
1840
|
+
throw new Error("A SmartShift threshold must be a whole number between 0 and 255.");
|
|
1841
|
+
}
|
|
1842
|
+
const confirmed = await this.writeRatchetControl({ threshold: value });
|
|
1843
|
+
if (confirmed.threshold !== value) {
|
|
1844
|
+
throw new Error(`The mouse kept a SmartShift threshold of ${confirmed.threshold}.`);
|
|
1845
|
+
}
|
|
1846
|
+
return confirmed.threshold;
|
|
1847
|
+
}
|
|
1848
|
+
/**
|
|
1849
|
+
* Flips one bit of the 0x2121 mode byte. Diversion is read and carried
|
|
1850
|
+
* through: setting it routes the wheel to HID++ and stops it scrolling, and
|
|
1851
|
+
* clearing it would take that away from whatever set it.
|
|
1852
|
+
*/
|
|
1853
|
+
async writeWheelModeBit(bit, on) {
|
|
1854
|
+
const feature = await this.getFeature(FEATURE.hiresWheel);
|
|
1855
|
+
if (!feature.index)
|
|
1856
|
+
throw new Error("This mouse has no hi-resolution wheel feature.");
|
|
1857
|
+
const current = (await this.request(feature.index, LOGITECH_HIRES_WHEEL.get))[3] ?? 0;
|
|
1858
|
+
const reply = await this.request(feature.index, LOGITECH_HIRES_WHEEL.set, encodeHiresWheelMode(current, bit, on));
|
|
1859
|
+
return reply[3] ?? 0;
|
|
1860
|
+
}
|
|
1861
|
+
async setHiResScroll(enabled) {
|
|
1862
|
+
const mode = decodeHiresWheelMode(await this.writeWheelModeBit(LOGITECH_HIRES_WHEEL_BIT.hiRes, enabled));
|
|
1863
|
+
if (mode.hiRes !== enabled)
|
|
1864
|
+
throw new Error("The mouse kept its previous scrolling mode.");
|
|
1865
|
+
return mode.hiRes;
|
|
1866
|
+
}
|
|
1867
|
+
async setInvertScroll(inverted) {
|
|
1868
|
+
const mode = decodeHiresWheelMode(await this.writeWheelModeBit(LOGITECH_HIRES_WHEEL_BIT.invert, inverted));
|
|
1869
|
+
if (mode.inverted !== inverted)
|
|
1870
|
+
throw new Error("The mouse kept its previous scroll direction.");
|
|
1871
|
+
return mode.inverted;
|
|
1872
|
+
}
|
|
1873
|
+
/**
|
|
1874
|
+
* Inverts the thumb wheel. Logi Options+ sets the diversion bit to implement
|
|
1875
|
+
* horizontal scrolling, so it is read and preserved rather than rewritten.
|
|
1876
|
+
*/
|
|
1877
|
+
async setThumbWheelInverted(inverted) {
|
|
1878
|
+
const feature = await this.getFeature(FEATURE.thumbWheel);
|
|
1879
|
+
if (!feature.index)
|
|
1880
|
+
throw new Error("This mouse has no thumb wheel.");
|
|
1881
|
+
const status = decodeThumbWheelStatus((await this.request(feature.index, LOGITECH_THUMB_WHEEL.get)).slice(3));
|
|
1882
|
+
if (!status)
|
|
1883
|
+
throw new Error("The mouse gave no answer when its thumb wheel was read.");
|
|
1884
|
+
await this.request(feature.index, LOGITECH_THUMB_WHEEL.set, ...buildThumbWheelWrite(status, inverted));
|
|
1885
|
+
/*
|
|
1886
|
+
* Confirmed by re-reading rather than from the write's own reply. Unlike
|
|
1887
|
+
* 0x2111, 0x2121 and 0x19B0, this feature does not echo the values it was
|
|
1888
|
+
* given — its reply reads as all zeros, which made a write of "not
|
|
1889
|
+
* inverted" appear to succeed and "inverted" appear to fail while both
|
|
1890
|
+
* had actually taken effect.
|
|
1891
|
+
*/
|
|
1892
|
+
const after = decodeThumbWheelStatus((await this.request(feature.index, LOGITECH_THUMB_WHEEL.get)).slice(3));
|
|
1893
|
+
if (after?.inverted !== inverted) {
|
|
1894
|
+
throw new Error("The mouse kept its previous thumb-wheel direction.");
|
|
1895
|
+
}
|
|
1896
|
+
return after.inverted;
|
|
1897
|
+
}
|
|
1898
|
+
/** The editable name, or null on a device without feature 0x0007. */
|
|
1899
|
+
async readFriendlyName() {
|
|
1900
|
+
if (this.friendlyNameCache !== undefined)
|
|
1901
|
+
return this.friendlyNameCache;
|
|
1902
|
+
const feature = await this.getFeature(FEATURE.friendlyName);
|
|
1903
|
+
if (!feature.index)
|
|
1904
|
+
return (this.friendlyNameCache = null);
|
|
1905
|
+
const header = await this.request(feature.index, LOGITECH_FRIENDLY_NAME.lengths).catch(() => null);
|
|
1906
|
+
const lengths = header ? decodeFriendlyNameLengths(header.slice(3)) : null;
|
|
1907
|
+
if (!lengths)
|
|
1908
|
+
return null;
|
|
1909
|
+
if (lengths.length === 0)
|
|
1910
|
+
return (this.friendlyNameCache = { name: "", maxLength: lengths.maxLength });
|
|
1911
|
+
const characters = [];
|
|
1912
|
+
while (characters.length < lengths.length) {
|
|
1913
|
+
const chunk = await this.request(feature.index, LOGITECH_FRIENDLY_NAME.get, characters.length);
|
|
1914
|
+
const decoded = decodeFriendlyNameChunk(chunk.slice(3), lengths.length - characters.length);
|
|
1915
|
+
if (decoded.length === 0)
|
|
1916
|
+
break;
|
|
1917
|
+
characters.push(...decoded);
|
|
1918
|
+
}
|
|
1919
|
+
return (this.friendlyNameCache = { name: decodeFriendlyNameText(characters), maxLength: lengths.maxLength });
|
|
1920
|
+
}
|
|
1921
|
+
/**
|
|
1922
|
+
* Renames the device, then reads the name back. The read-back is not
|
|
1923
|
+
* ceremony: firmware that acknowledges a write and quietly keeps its old
|
|
1924
|
+
* value would otherwise look like a successful rename.
|
|
1925
|
+
*/
|
|
1926
|
+
async setFriendlyName(name) {
|
|
1927
|
+
const feature = await this.getFeature(FEATURE.friendlyName);
|
|
1928
|
+
if (!feature.index)
|
|
1929
|
+
throw new Error("This mouse cannot be renamed.");
|
|
1930
|
+
const header = await this.request(feature.index, LOGITECH_FRIENDLY_NAME.lengths);
|
|
1931
|
+
const lengths = decodeFriendlyNameLengths(header.slice(3));
|
|
1932
|
+
if (!lengths)
|
|
1933
|
+
throw new Error("This mouse did not report how long a name it accepts.");
|
|
1934
|
+
const rejection = rejectFriendlyName(name, lengths.maxLength);
|
|
1935
|
+
if (rejection === "empty")
|
|
1936
|
+
throw new Error("A name cannot be empty.");
|
|
1937
|
+
if (rejection === "non-ascii")
|
|
1938
|
+
throw new Error("A name may only contain plain ASCII characters.");
|
|
1939
|
+
if (rejection !== null) {
|
|
1940
|
+
throw new Error(`This mouse allows at most ${lengths.maxLength} characters.`);
|
|
1941
|
+
}
|
|
1942
|
+
await this.requestLong(feature.index, LOGITECH_FRIENDLY_NAME.set, buildFriendlyNameWrite(name));
|
|
1943
|
+
// The confirmation has to reach the mouse, not the value from before this
|
|
1944
|
+
// write — a cache answering here would confirm nothing at all.
|
|
1945
|
+
this.friendlyNameCache = undefined;
|
|
1946
|
+
const confirmed = await this.readFriendlyName();
|
|
1947
|
+
if (confirmed?.name !== name.trim()) {
|
|
1948
|
+
throw new Error(`The mouse kept the name "${confirmed?.name ?? ""}".`);
|
|
1949
|
+
}
|
|
1950
|
+
return confirmed.name;
|
|
1951
|
+
}
|
|
1952
|
+
/**
|
|
1953
|
+
* Easy-Switch slots, or null without feature 0x1815. Slot indices are
|
|
1954
|
+
* zero-based here as they are on the wire; the button under the mouse counts
|
|
1955
|
+
* from one, so anything user-facing has to add one.
|
|
1956
|
+
*/
|
|
1957
|
+
async readHostState() {
|
|
1958
|
+
if (this.hostStateCache !== undefined)
|
|
1959
|
+
return this.hostStateCache;
|
|
1960
|
+
const feature = await this.getFeature(FEATURE.hostsInfo);
|
|
1961
|
+
if (!feature.index)
|
|
1962
|
+
return (this.hostStateCache = null);
|
|
1963
|
+
const reply = await this.request(feature.index, LOGITECH_HOSTS.info).catch(() => null);
|
|
1964
|
+
const info = reply ? decodeHostsInfo(reply.slice(3)) : null;
|
|
1965
|
+
// A failed read is left uncached so the next refresh tries again, rather
|
|
1966
|
+
// than a transient timeout hiding the control for the whole session.
|
|
1967
|
+
if (!info)
|
|
1968
|
+
return null;
|
|
1969
|
+
const paired = [];
|
|
1970
|
+
for (let slot = 0; slot < info.hostCount; slot += 1) {
|
|
1971
|
+
const entry = await this.request(feature.index, LOGITECH_HOSTS.host, slot).catch(() => null);
|
|
1972
|
+
// A slot that will not describe itself counts as empty, never as
|
|
1973
|
+
// switchable — this has to fail towards refusing the switch.
|
|
1974
|
+
paired.push(entry ? decodeHostPaired(entry.slice(3)) === true : false);
|
|
1975
|
+
}
|
|
1976
|
+
return (this.hostStateCache = { info, paired });
|
|
1977
|
+
}
|
|
1978
|
+
/**
|
|
1979
|
+
* Asks the mouse to move to another Easy-Switch slot.
|
|
1980
|
+
*
|
|
1981
|
+
* Named for what it can prove. A successful switch disconnects this host, so
|
|
1982
|
+
* there is no state left to read back and no way to confirm the mouse
|
|
1983
|
+
* arrived — disconnection IS the expected success path, not a failure.
|
|
1984
|
+
* Resolving means the command reached the device: either it acknowledged, or
|
|
1985
|
+
* it left before an acknowledgement could be observed and the report was
|
|
1986
|
+
* accepted by the transport.
|
|
1987
|
+
*
|
|
1988
|
+
* An empty slot is refused outright. Switching there leaves the mouse
|
|
1989
|
+
* unreachable until someone presses the button on its underside.
|
|
1990
|
+
*/
|
|
1991
|
+
async requestHostSwitch(slot) {
|
|
1992
|
+
const state = await this.readHostState();
|
|
1993
|
+
const rejection = rejectHostSwitch(slot, state?.info ?? null, state?.paired ?? []);
|
|
1994
|
+
if (rejection === "no-hosts")
|
|
1995
|
+
throw new Error("This mouse does not report Easy-Switch hosts.");
|
|
1996
|
+
if (rejection === "already-current")
|
|
1997
|
+
throw new Error("The mouse is already on that computer.");
|
|
1998
|
+
if (rejection === "empty-slot") {
|
|
1999
|
+
throw new Error(`Computer ${slot + 1} has nothing paired to it. Switching there would leave the mouse `
|
|
2000
|
+
+ "unreachable until you press the button underneath it.");
|
|
2001
|
+
}
|
|
2002
|
+
if (rejection !== null) {
|
|
2003
|
+
throw new Error(`Computer ${slot + 1} is not one of this mouse's slots.`);
|
|
2004
|
+
}
|
|
2005
|
+
const feature = await this.getFeature(FEATURE.changeHost);
|
|
2006
|
+
if (!feature.index)
|
|
2007
|
+
throw new Error("This mouse has no 0x1814 CHANGE HOST feature.");
|
|
2008
|
+
try {
|
|
2009
|
+
await this.requestWithOptions(feature.index, LOGITECH_CHANGE_HOST.set, buildHostSwitchWrite(slot), { timeoutMs: HOST_SWITCH_ACK_TIMEOUT_MS });
|
|
2010
|
+
}
|
|
2011
|
+
catch (error) {
|
|
2012
|
+
// The mouse leaving mid-request is the command working. Only a failure
|
|
2013
|
+
// to hand the report to the transport means it never got there.
|
|
2014
|
+
// A timeout here is the mouse having gone: the report reached the
|
|
2015
|
+
// transport and no answer can arrive from a device that is no longer
|
|
2016
|
+
// this host's. Anything else — a refusal, or sendReport failing — means
|
|
2017
|
+
// the command did not take effect and must surface.
|
|
2018
|
+
if (error instanceof HidppTimeoutError)
|
|
2019
|
+
return;
|
|
2020
|
+
throw error;
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
/**
|
|
2024
|
+
* Reads every reprogrammable control, what it currently does, and where it
|
|
2025
|
+
* may legally be pointed.
|
|
2026
|
+
*
|
|
2027
|
+
* Two round-trips per control, which is why this is not part of readStatus:
|
|
2028
|
+
* on an MX Master 4 that is eighteen more exchanges, enough on a wireless
|
|
2029
|
+
* link to start timing other reads out. Call it on connect and after a
|
|
2030
|
+
* write, not on a poll.
|
|
2031
|
+
*
|
|
2032
|
+
* The control table itself never changes for a given device, so it is read
|
|
2033
|
+
* once per connection; only the reporting is re-read.
|
|
2034
|
+
*/
|
|
2035
|
+
async readButtons() {
|
|
2036
|
+
const feature = await this.getFeature(FEATURE.reprogControls);
|
|
2037
|
+
if (!feature.index)
|
|
2038
|
+
return [];
|
|
2039
|
+
if (this.controlInfoCache === undefined) {
|
|
2040
|
+
const count = (await this.request(feature.index, LOGITECH_REPROG_CONTROLS.count))[3] ?? 0;
|
|
2041
|
+
const infos = [];
|
|
2042
|
+
for (let index = 0; index < count; index += 1) {
|
|
2043
|
+
const reply = await this.request(feature.index, LOGITECH_REPROG_CONTROLS.info, index);
|
|
2044
|
+
const info = decodeControlInfo(reply.slice(3));
|
|
2045
|
+
// A control that will not describe itself is dropped rather than
|
|
2046
|
+
// guessed at: without its group mask there is no way to know what it
|
|
2047
|
+
// may be remapped to, and offering a target the firmware refuses is
|
|
2048
|
+
// worse than not offering the control at all.
|
|
2049
|
+
if (info)
|
|
2050
|
+
infos.push(info);
|
|
2051
|
+
}
|
|
2052
|
+
this.controlInfoCache = infos;
|
|
2053
|
+
}
|
|
2054
|
+
const infos = this.controlInfoCache;
|
|
2055
|
+
const controls = [];
|
|
2056
|
+
for (const info of infos) {
|
|
2057
|
+
const reply = await this.request(feature.index, LOGITECH_REPROG_CONTROLS.reporting, info.controlId >> 8, info.controlId & 0xff);
|
|
2058
|
+
const reporting = decodeControlReporting(reply.slice(3));
|
|
2059
|
+
if (!reporting)
|
|
2060
|
+
continue;
|
|
2061
|
+
controls.push({
|
|
2062
|
+
...info,
|
|
2063
|
+
...reporting,
|
|
2064
|
+
name: logitechControlName(info.controlId),
|
|
2065
|
+
taskName: logitechTaskName(info.taskId),
|
|
2066
|
+
reprogrammable: (info.flags & LOGITECH_KEY_FLAG.reprogrammable) !== 0,
|
|
2067
|
+
virtual: (info.flags & LOGITECH_KEY_FLAG.virtual) !== 0,
|
|
2068
|
+
remappableTo: remappableControlTargets(info, infos),
|
|
2069
|
+
});
|
|
2070
|
+
}
|
|
2071
|
+
return controls;
|
|
2072
|
+
}
|
|
2073
|
+
/**
|
|
2074
|
+
* Points one button at another control's action.
|
|
2075
|
+
*
|
|
2076
|
+
* The device is asked what is legal before anything is written, and the
|
|
2077
|
+
* result is read back afterwards — firmware that acknowledges a remap and
|
|
2078
|
+
* keeps its old mapping would otherwise look like a success.
|
|
2079
|
+
*/
|
|
2080
|
+
async setButtonMapping(controlId, targetControlId) {
|
|
2081
|
+
const feature = await this.getFeature(FEATURE.reprogControls);
|
|
2082
|
+
if (!feature.index)
|
|
2083
|
+
throw new Error("This mouse does not expose reprogrammable controls.");
|
|
2084
|
+
const before = await this.readButtons();
|
|
2085
|
+
const control = before.find((candidate) => candidate.controlId === controlId);
|
|
2086
|
+
if (!control)
|
|
2087
|
+
throw new Error("That control is not present on this mouse.");
|
|
2088
|
+
if (!control.reprogrammable || !control.remappableTo.includes(targetControlId)) {
|
|
2089
|
+
throw new Error(`${control.name} cannot be remapped to ${logitechControlName(targetControlId)}.`);
|
|
2090
|
+
}
|
|
2091
|
+
await this.requestLong(feature.index, LOGITECH_REPROG_CONTROLS.setReporting, buildControlRemapWrite(controlId, targetControlId));
|
|
2092
|
+
const after = await this.readButtons();
|
|
2093
|
+
const confirmed = after.find((candidate) => candidate.controlId === controlId);
|
|
2094
|
+
if (confirmed?.mappedTo !== targetControlId) {
|
|
2095
|
+
throw new Error(`The mouse kept ${control.name} pointing at `
|
|
2096
|
+
+ `${logitechControlName(confirmed?.mappedTo ?? 0)}.`);
|
|
2097
|
+
}
|
|
2098
|
+
return after;
|
|
2099
|
+
}
|
|
2100
|
+
/**
|
|
2101
|
+
* Hands every diverted button back to the hardware.
|
|
2102
|
+
*
|
|
2103
|
+
* A diverted button sends HID++ notifications instead of acting, which is
|
|
2104
|
+
* how a vendor application implements behaviours of its own. Logi Options+
|
|
2105
|
+
* uses the temporary flag and the mouse clears that itself once Options+
|
|
2106
|
+
* stops — but the persistent flag survives, so a button left that way stays
|
|
2107
|
+
* dead until something clears it. That something has to exist somewhere.
|
|
2108
|
+
*/
|
|
2109
|
+
async clearButtonDiversion() {
|
|
2110
|
+
const feature = await this.getFeature(FEATURE.reprogControls);
|
|
2111
|
+
if (!feature.index)
|
|
2112
|
+
throw new Error("This mouse does not expose reprogrammable controls.");
|
|
2113
|
+
const before = await this.readButtons();
|
|
2114
|
+
const diverted = before.filter((control) => control.diverted);
|
|
2115
|
+
if (!diverted.length)
|
|
2116
|
+
return before;
|
|
2117
|
+
for (const control of diverted) {
|
|
2118
|
+
await this.requestLong(feature.index, LOGITECH_REPROG_CONTROLS.setReporting, buildControlDiversionClearWrite(control.controlId));
|
|
2119
|
+
}
|
|
2120
|
+
const after = await this.readButtons();
|
|
2121
|
+
const stuck = after.filter((control) => control.diverted);
|
|
2122
|
+
if (stuck.length) {
|
|
2123
|
+
throw new Error(`The mouse kept ${stuck.map((control) => control.name).join(", ")} diverted.`);
|
|
2124
|
+
}
|
|
2125
|
+
// Clearing a diversion must not have disturbed where a button points.
|
|
2126
|
+
for (const control of before) {
|
|
2127
|
+
const now = after.find((candidate) => candidate.controlId === control.controlId);
|
|
2128
|
+
if (now && now.mappedTo !== control.mappedTo) {
|
|
2129
|
+
throw new Error(`Restoring ${control.name} unexpectedly changed what it does.`);
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
return after;
|
|
2133
|
+
}
|
|
2134
|
+
/** Sets haptic strength, leaving the flag byte as the device reports it. */
|
|
2135
|
+
async setHapticIntensity(intensity) {
|
|
2136
|
+
if (!isLogitechHapticIntensity(intensity)) {
|
|
2137
|
+
throw new Error("Haptic strength must be a whole number between 0 and 100.");
|
|
2138
|
+
}
|
|
2139
|
+
const confirmed = await this.writeHapticConfig({ intensity });
|
|
2140
|
+
if (confirmed.intensity !== intensity) {
|
|
2141
|
+
throw new Error(`The mouse kept a haptic strength of ${confirmed.intensity}.`);
|
|
2142
|
+
}
|
|
2143
|
+
return confirmed.intensity;
|
|
2144
|
+
}
|
|
2145
|
+
async setHapticEnabled(enabled) {
|
|
2146
|
+
const confirmed = await this.writeHapticConfig({ flag: { name: "enabled", on: enabled } });
|
|
2147
|
+
if (confirmed.enabled !== enabled) {
|
|
2148
|
+
throw new Error(`The mouse kept haptics ${confirmed.enabled ? "on" : "off"}.`);
|
|
2149
|
+
}
|
|
2150
|
+
return confirmed.enabled;
|
|
2151
|
+
}
|
|
2152
|
+
async setHapticBatterySaving(enabled) {
|
|
2153
|
+
const confirmed = await this.writeHapticConfig({ flag: { name: "batterySaving", on: enabled } });
|
|
2154
|
+
if (confirmed.batterySaving !== enabled) {
|
|
2155
|
+
throw new Error(`The mouse kept haptic battery saving ${confirmed.batterySaving ? "on" : "off"}.`);
|
|
2156
|
+
}
|
|
2157
|
+
return confirmed.batterySaving;
|
|
2158
|
+
}
|
|
2159
|
+
/**
|
|
2160
|
+
* Fires the motor once at whatever strength is set. Nothing persists, so
|
|
2161
|
+
* this is safe to use as feedback. The reply reports whether the motor was
|
|
2162
|
+
* already running, which says nothing about the effect itself.
|
|
2163
|
+
*/
|
|
2164
|
+
async playHapticEffect(effect = LOGITECH_HAPTIC_EFFECTS.strengthSample) {
|
|
2165
|
+
if (!isLogitechHapticEffect(effect)) {
|
|
2166
|
+
throw new Error(`This mouse has no haptic effect 0x${effect.toString(16)}.`);
|
|
2167
|
+
}
|
|
2168
|
+
const feature = await this.getFeature(FEATURE.haptic);
|
|
2169
|
+
if (!feature.index)
|
|
2170
|
+
throw new Error("This mouse has no haptic feature.");
|
|
2171
|
+
await this.request(feature.index, LOGITECH_HAPTIC.play, effect);
|
|
2172
|
+
}
|
|
2173
|
+
async setGamingSurfaceMode(mode) {
|
|
2174
|
+
await this.setModeStatus({ gamingSurface: mode });
|
|
2175
|
+
return mode;
|
|
2176
|
+
}
|
|
2177
|
+
async setLightforceSwitchMode(mode) {
|
|
2178
|
+
await this.setModeStatus({ lightforce: mode });
|
|
2179
|
+
return mode;
|
|
2180
|
+
}
|
|
2181
|
+
/**
|
|
2182
|
+
* Read-modify-write of modeStatus1, then verify. Both fields share the byte,
|
|
2183
|
+
* so passing them together writes once instead of twice; the change mask
|
|
2184
|
+
* stops the firmware touching bits neither field names.
|
|
2185
|
+
* Reading first keeps this correct if a future field spans bits we do not know about.
|
|
2186
|
+
*/
|
|
2187
|
+
async setModeStatus(values) {
|
|
2188
|
+
const requested = [];
|
|
2189
|
+
if (values.gamingSurface) {
|
|
2190
|
+
requested.push({ field: MODE_STATUS.gamingSurface, mode: values.gamingSurface, label: "gaming surface mode" });
|
|
2191
|
+
}
|
|
2192
|
+
if (values.lightforce) {
|
|
2193
|
+
requested.push({ field: MODE_STATUS.lightforce, mode: values.lightforce, label: "LightForce switch mode" });
|
|
2194
|
+
}
|
|
2195
|
+
if (requested.length === 0)
|
|
2196
|
+
return;
|
|
2197
|
+
const label = requested[0]?.label ?? "mode status";
|
|
2198
|
+
const feature = await this.getFeature(FEATURE.modeStatus);
|
|
2199
|
+
if (!feature.index) {
|
|
2200
|
+
throw new Error(`This mouse does not expose ${label} controls.`);
|
|
2201
|
+
}
|
|
2202
|
+
const current = await this.readModeStatus(feature.index);
|
|
2203
|
+
if (current === null) {
|
|
2204
|
+
throw new Error(`The mouse did not report its current ${label}.`);
|
|
2205
|
+
}
|
|
2206
|
+
// Where the firmware persists 0x8090 is unknown, so it may cost a write
|
|
2207
|
+
// cycle. Re-selecting the current value is a no-op worth skipping.
|
|
2208
|
+
const changes = requested.filter((update) => decodeModeStatus(current, update.field) !== update.mode);
|
|
2209
|
+
if (changes.length === 0)
|
|
2210
|
+
return;
|
|
2211
|
+
await this.requestLong(feature.index, MODE_STATUS.set, buildModeStatusWriteMany(current, changes));
|
|
2212
|
+
const readBack = await this.readModeStatus(feature.index);
|
|
2213
|
+
for (const update of changes) {
|
|
2214
|
+
const confirmed = readBack === null ? null : decodeModeStatus(readBack, update.field);
|
|
2215
|
+
if (confirmed !== update.mode) {
|
|
2216
|
+
throw new Error(`The mouse kept ${confirmed ?? "an unknown"} ${update.label} instead of ${update.mode}.`);
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
/** Byte 4 of getModeStatus, carrying both the surface and LightForce fields. */
|
|
2221
|
+
async readModeStatus(featureIndex) {
|
|
2222
|
+
const reply = await this.request(featureIndex, MODE_STATUS.get).catch(() => null);
|
|
2223
|
+
return reply ? (reply[4] ?? 0) : null;
|
|
2224
|
+
}
|
|
2225
|
+
async open() {
|
|
2226
|
+
await this.bindBoltIoDevice();
|
|
2227
|
+
const devices = new Set([this.device, this.reportDevice]);
|
|
2228
|
+
for (const device of devices) {
|
|
2229
|
+
if (!device.opened) {
|
|
2230
|
+
await device.open();
|
|
2231
|
+
}
|
|
2232
|
+
if (!this.listeningDevices.has(device)) {
|
|
2233
|
+
device.addEventListener("inputreport", this.onInputReport);
|
|
2234
|
+
this.listeningDevices.add(device);
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
async bindBoltIoDevice() {
|
|
2239
|
+
const peers = typeof navigator !== "undefined" && navigator.hid
|
|
2240
|
+
? await navigator.hid.getDevices()
|
|
2241
|
+
: [];
|
|
2242
|
+
this.ioDevice = resolveBoltReportDevice(this.device, peers);
|
|
2243
|
+
}
|
|
2244
|
+
/** Prefer extended DPI (0x2202); fall back to legacy Adjustable DPI (0x2201). */
|
|
2245
|
+
async resolveDpiFeature() {
|
|
2246
|
+
if (this.dpiFeatureResolved)
|
|
2247
|
+
return this.dpiFeatureResolved;
|
|
2248
|
+
const extended = await this.getFeature(FEATURE.extendedDpi);
|
|
2249
|
+
if (extended.index) {
|
|
2250
|
+
return (this.dpiFeatureResolved = { index: extended.index, legacy: false });
|
|
2251
|
+
}
|
|
2252
|
+
const legacy = await this.getFeature(FEATURE.adjustableDpi);
|
|
2253
|
+
return (this.dpiFeatureResolved = { index: legacy.index, legacy: true });
|
|
2254
|
+
}
|
|
2255
|
+
/** Prefer extended report rate (0x8061); fall back to legacy Report Rate (0x8060). */
|
|
2256
|
+
async resolveReportRateFeature() {
|
|
2257
|
+
if (this.rateFeatureResolved)
|
|
2258
|
+
return this.rateFeatureResolved;
|
|
2259
|
+
const extended = await this.getFeature(FEATURE.extendedReportRate);
|
|
2260
|
+
if (extended.index) {
|
|
2261
|
+
return (this.rateFeatureResolved = { index: extended.index, legacy: false });
|
|
2262
|
+
}
|
|
2263
|
+
const legacy = await this.getFeature(FEATURE.reportRate);
|
|
2264
|
+
return (this.rateFeatureResolved = { index: legacy.index, legacy: true });
|
|
2265
|
+
}
|
|
2266
|
+
// --- Legacy Adjustable DPI (0x2201) ---------------------------------------
|
|
2267
|
+
// These mice expose a single sensor and no adjustable lift-off distance.
|
|
2268
|
+
async readLegacyDpi(featureIndex) {
|
|
2269
|
+
if (!featureIndex) {
|
|
2270
|
+
throw new Error("This Logitech mouse does not expose DPI controls.");
|
|
2271
|
+
}
|
|
2272
|
+
// getSensorDpi(sensor 0): reply data = [sensorIdx, dpiHi, dpiLo, defaultHi, defaultLo].
|
|
2273
|
+
const reply = await this.request(featureIndex, 0x20, 0x00);
|
|
2274
|
+
const dpi = ((reply[4] ?? 0) << 8) | (reply[5] ?? 0);
|
|
2275
|
+
return { dpi, dpiY: dpi, lod: null };
|
|
2276
|
+
}
|
|
2277
|
+
async readLegacyDpiList(featureIndex) {
|
|
2278
|
+
if (!featureIndex)
|
|
2279
|
+
return [];
|
|
2280
|
+
// getSensorDpiList(sensor 0): reply data = [sensorIdx, ...uint16 BE values].
|
|
2281
|
+
// A value with the top three bits set marks a range: (0xE000 | step), with
|
|
2282
|
+
// the previous value as the minimum and the following value as the maximum.
|
|
2283
|
+
const reply = await this.request(featureIndex, 0x10, 0x00);
|
|
2284
|
+
const bytes = [...reply.slice(4)];
|
|
2285
|
+
const options = [];
|
|
2286
|
+
for (let index = 0; index + 1 < bytes.length;) {
|
|
2287
|
+
const value = (bytes[index] << 8) | bytes[index + 1];
|
|
2288
|
+
if (value === 0)
|
|
2289
|
+
break;
|
|
2290
|
+
if (value >> 13 === 0b111) {
|
|
2291
|
+
const step = value & 0x1fff;
|
|
2292
|
+
const last = ((bytes[index + 2] ?? 0) << 8) | (bytes[index + 3] ?? 0);
|
|
2293
|
+
const first = options.at(-1);
|
|
2294
|
+
if (!first || !last || last <= first)
|
|
2295
|
+
break;
|
|
2296
|
+
for (let dpi = first + step; dpi <= last; dpi += step)
|
|
2297
|
+
options.push(dpi);
|
|
2298
|
+
index += 4;
|
|
2299
|
+
}
|
|
2300
|
+
else {
|
|
2301
|
+
options.push(value);
|
|
2302
|
+
index += 2;
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
return options;
|
|
2306
|
+
}
|
|
2307
|
+
async setLegacyDpi(featureIndex, dpi) {
|
|
2308
|
+
await this.ensureHostControl();
|
|
2309
|
+
// setSensorDpi(sensor 0, dpi): params = [sensorIdx, dpiHi, dpiLo]. Three
|
|
2310
|
+
// Direct endpoints use the short form; receiver-attached mice use long.
|
|
2311
|
+
if (this.isDirectConnect) {
|
|
2312
|
+
await this.request(featureIndex, 0x30, 0x00, dpi >> 8, dpi & 0xff);
|
|
2313
|
+
}
|
|
2314
|
+
else {
|
|
2315
|
+
await this.requestLong(featureIndex, 0x30, [0x00, dpi >> 8, dpi & 0xff]);
|
|
2316
|
+
}
|
|
2317
|
+
const confirmed = await this.readLegacyDpi(featureIndex);
|
|
2318
|
+
if (confirmed.dpi !== dpi) {
|
|
2319
|
+
throw new Error(`The mouse kept ${confirmed.dpi} DPI instead of ${dpi} DPI.`);
|
|
2320
|
+
}
|
|
2321
|
+
return confirmed.dpi;
|
|
2322
|
+
}
|
|
2323
|
+
// --- Legacy Report Rate (0x8060) ------------------------------------------
|
|
2324
|
+
// Rates are expressed in milliseconds (1 ms = 1000 Hz).
|
|
2325
|
+
async readLegacyReportRates(featureIndex) {
|
|
2326
|
+
if (!featureIndex)
|
|
2327
|
+
return [];
|
|
2328
|
+
// getReportRateList: reply data byte 0 is a bitmap where bit i => (i + 1) ms.
|
|
2329
|
+
const reply = await this.request(featureIndex, 0x00);
|
|
2330
|
+
return decodeReportRateBitmap(reply[3] ?? 0);
|
|
2331
|
+
}
|
|
2332
|
+
async getSupportedPollingRateOptions() {
|
|
2333
|
+
if (this.supportedPollingRatesCache !== null)
|
|
2334
|
+
return this.supportedPollingRatesCache;
|
|
2335
|
+
const feature = await this.resolveReportRateFeature();
|
|
2336
|
+
const rates = feature.legacy
|
|
2337
|
+
? await this.readLegacyReportRates(feature.index)
|
|
2338
|
+
: await this.readSupportedPollingRates(feature.index);
|
|
2339
|
+
this.supportedPollingRatesCache = rates;
|
|
2340
|
+
return rates;
|
|
2341
|
+
}
|
|
2342
|
+
async readLegacyReportRate(featureIndex) {
|
|
2343
|
+
if (!featureIndex) {
|
|
2344
|
+
throw new Error("This Logitech mouse does not expose report-rate controls.");
|
|
2345
|
+
}
|
|
2346
|
+
// getReportRate: reply data byte 0 is the current rate in milliseconds.
|
|
2347
|
+
const reply = await this.request(featureIndex, 0x10);
|
|
2348
|
+
const rateMs = reply[3] ?? 0;
|
|
2349
|
+
if (!rateMs) {
|
|
2350
|
+
throw new Error("The mouse returned an unknown report-rate value.");
|
|
2351
|
+
}
|
|
2352
|
+
return Math.round(1000 / rateMs);
|
|
2353
|
+
}
|
|
2354
|
+
async setLegacyReportRate(featureIndex, pollingRateHz) {
|
|
2355
|
+
if (!featureIndex) {
|
|
2356
|
+
throw new Error("This mouse does not expose report-rate controls.");
|
|
2357
|
+
}
|
|
2358
|
+
const rateMs = Math.round(1000 / pollingRateHz);
|
|
2359
|
+
if (rateMs < 1 || rateMs > 8) {
|
|
2360
|
+
throw new Error("Unsupported polling rate.");
|
|
2361
|
+
}
|
|
2362
|
+
await this.ensureHostControl();
|
|
2363
|
+
// setReportRate(rateMs).
|
|
2364
|
+
try {
|
|
2365
|
+
await this.request(featureIndex, 0x20, rateMs);
|
|
2366
|
+
}
|
|
2367
|
+
catch (error) {
|
|
2368
|
+
throw error instanceof HidppTimeoutError ? error : new LiveRateWriteRejectedError(error);
|
|
2369
|
+
}
|
|
2370
|
+
const confirmed = await this.readLegacyReportRate(featureIndex);
|
|
2371
|
+
if (confirmed !== pollingRateHz) {
|
|
2372
|
+
throw new Error(`The mouse kept ${confirmed} Hz instead of ${pollingRateHz} Hz.`);
|
|
2373
|
+
}
|
|
2374
|
+
return confirmed;
|
|
2375
|
+
}
|
|
2376
|
+
/**
|
|
2377
|
+
* Values that cannot change while a connection is open. Easy-Switch is the
|
|
2378
|
+
* clearest case: the slot count is fixed and the current slot changing IS
|
|
2379
|
+
* the connection ending, because that is what switching host does. The
|
|
2380
|
+
* friendly name only moves when something renames it, and this client drops
|
|
2381
|
+
* the entry after its own write.
|
|
2382
|
+
*
|
|
2383
|
+
* Re-reading these was costing six round-trips of radio every refresh, on
|
|
2384
|
+
* top of a feature lookup per read. On a wireless mouse that is enough for
|
|
2385
|
+
* some reads to time out, which surfaces as controls vanishing and coming
|
|
2386
|
+
* back a few seconds later.
|
|
2387
|
+
*/
|
|
2388
|
+
hostStateCache;
|
|
2389
|
+
controlInfoCache;
|
|
2390
|
+
friendlyNameCache;
|
|
2391
|
+
wheelCapabilityCache;
|
|
2392
|
+
async getFeature(featureId) {
|
|
2393
|
+
const reply = await this.request(0x00, 0x00, featureId >> 8, featureId & 0xff);
|
|
2394
|
+
const feature = { index: reply[3] ?? 0, version: reply[6] ?? 0 };
|
|
2395
|
+
if (featureId === FEATURE.extendedReportRate)
|
|
2396
|
+
this.reportRateFeatureIndex = feature.index;
|
|
2397
|
+
return feature;
|
|
2398
|
+
}
|
|
2399
|
+
async readName(featureIndex) {
|
|
2400
|
+
const header = await this.request(featureIndex, 0x00);
|
|
2401
|
+
const nameLength = header[3] ?? 0;
|
|
2402
|
+
const fragments = [];
|
|
2403
|
+
for (let offset = 0; offset < nameLength; offset += 16) {
|
|
2404
|
+
const reply = await this.request(featureIndex, 0x10, offset);
|
|
2405
|
+
fragments.push(...reply.slice(3, 3 + Math.min(16, nameLength - offset)));
|
|
2406
|
+
}
|
|
2407
|
+
return new TextDecoder().decode(new Uint8Array(fragments));
|
|
2408
|
+
}
|
|
2409
|
+
/**
|
|
2410
|
+
* 0x1004 UNIFIED_BATTERY getStatus: [stateOfCharge, batteryLevel,
|
|
2411
|
+
* chargingStatus, externalPower].
|
|
2412
|
+
*
|
|
2413
|
+
* The charging enum here is NOT the one 0x1000 uses, which is what this
|
|
2414
|
+
* previously decoded: on 0x1004, 2 means charging slowly and 4 means a
|
|
2415
|
+
* charging fault, where on 0x1000 those are "almost full" and "charging
|
|
2416
|
+
* slowly". A slow charge was therefore reported as almost full.
|
|
2417
|
+
*/
|
|
2418
|
+
async readBattery(featureIndex) {
|
|
2419
|
+
const reply = await this.request(featureIndex, 0x10);
|
|
2420
|
+
const percentage = reply[3];
|
|
2421
|
+
return {
|
|
2422
|
+
percent: percentage <= 100 ? percentage : null,
|
|
2423
|
+
state: decodeUnifiedBatteryState(reply[5] ?? -1),
|
|
2424
|
+
};
|
|
2425
|
+
}
|
|
2426
|
+
/**
|
|
2427
|
+
* 0x1000 BATTERY_LEVEL_STATUS getBatteryLevelStatus:
|
|
2428
|
+
* [dischargeLevel, nextLevel, batteryStatus].
|
|
2429
|
+
*
|
|
2430
|
+
* Older mice expose only this. Without it they reported no battery at all,
|
|
2431
|
+
* because the driver probed 0x1004, 0x1001 and 0x1f20 and nothing else.
|
|
2432
|
+
*/
|
|
2433
|
+
async readBatteryLevelStatus(featureIndex) {
|
|
2434
|
+
const reply = await this.request(featureIndex, 0x00);
|
|
2435
|
+
const percentage = reply[3] ?? 0xff;
|
|
2436
|
+
// A mouse that reports level in coarse steps sends 0 with a status, which
|
|
2437
|
+
// is a real reading; only an out-of-range value means "no percentage".
|
|
2438
|
+
return {
|
|
2439
|
+
percent: percentage <= 100 ? percentage : null,
|
|
2440
|
+
state: decodeBatteryLevelState(reply[5] ?? -1),
|
|
2441
|
+
};
|
|
2442
|
+
}
|
|
2443
|
+
async readBatteryVoltage(featureIndex) {
|
|
2444
|
+
const reply = await this.request(featureIndex, 0x00);
|
|
2445
|
+
const voltageMv = ((reply[3] ?? 0) << 8) | (reply[4] ?? 0);
|
|
2446
|
+
const flags = reply[5] ?? 0;
|
|
2447
|
+
const charging = (flags & 0x80) !== 0;
|
|
2448
|
+
const full = charging && (flags & 0x03) === 0x02;
|
|
2449
|
+
const state = full
|
|
2450
|
+
? "Full"
|
|
2451
|
+
: (flags & 0x10) !== 0
|
|
2452
|
+
? "Charging slowly"
|
|
2453
|
+
: charging
|
|
2454
|
+
? "Charging"
|
|
2455
|
+
: "Discharging";
|
|
2456
|
+
return {
|
|
2457
|
+
percent: voltageMv ? this.estimateBatteryPercent(voltageMv) : null,
|
|
2458
|
+
state,
|
|
2459
|
+
voltageMv: voltageMv || null,
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
async readAdcMeasurement(featureIndex) {
|
|
2463
|
+
const reply = await this.request(featureIndex, 0x00);
|
|
2464
|
+
const voltageMv = ((reply[3] ?? 0) << 8) | (reply[4] ?? 0);
|
|
2465
|
+
const flags = reply[5] ?? 0;
|
|
2466
|
+
if ((flags & 0x01) === 0 || voltageMv === 0) {
|
|
2467
|
+
return { percent: null, state: "Unknown", voltageMv: null };
|
|
2468
|
+
}
|
|
2469
|
+
return {
|
|
2470
|
+
percent: this.estimateBatteryPercent(voltageMv),
|
|
2471
|
+
state: (flags & 0x02) !== 0 ? "Charging" : "Discharging",
|
|
2472
|
+
voltageMv,
|
|
2473
|
+
};
|
|
2474
|
+
}
|
|
2475
|
+
estimateBatteryPercent(voltageMv) {
|
|
2476
|
+
if (voltageMv >= BATTERY_VOLTAGE_CURVE[0][0])
|
|
2477
|
+
return 100;
|
|
2478
|
+
if (voltageMv <= BATTERY_VOLTAGE_CURVE.at(-1)[0])
|
|
2479
|
+
return 0;
|
|
2480
|
+
for (let index = 0; index < BATTERY_VOLTAGE_CURVE.length - 1; index += 1) {
|
|
2481
|
+
const [highMv, highPercent] = BATTERY_VOLTAGE_CURVE[index];
|
|
2482
|
+
const [lowMv, lowPercent] = BATTERY_VOLTAGE_CURVE[index + 1];
|
|
2483
|
+
if (voltageMv >= lowMv) {
|
|
2484
|
+
return Math.round(lowPercent + ((highPercent - lowPercent) * (voltageMv - lowMv)) / (highMv - lowMv));
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
return 0;
|
|
2488
|
+
}
|
|
2489
|
+
async readDpi(featureIndex) {
|
|
2490
|
+
if (!featureIndex) {
|
|
2491
|
+
throw new Error("This Logitech mouse does not expose extended DPI controls.");
|
|
2492
|
+
}
|
|
2493
|
+
const configuration = await this.readDpiConfiguration(featureIndex);
|
|
2494
|
+
// The raw byte is returned rather than a level: which byte means which
|
|
2495
|
+
// level depends on the profile format, which is read later in readStatus.
|
|
2496
|
+
return { dpi: configuration.x, dpiY: configuration.y, lod: configuration.lod };
|
|
2497
|
+
}
|
|
2498
|
+
async readAnalogButtonTuning(featureIndex) {
|
|
2499
|
+
const capabilities = await this.request(featureIndex, 0x00);
|
|
2500
|
+
const buttonCount = Math.min(capabilities[4] ?? 0, 2);
|
|
2501
|
+
const maxActuation = (capabilities[5] ?? 0) >> 2;
|
|
2502
|
+
const maxRapidTrigger = (capabilities[6] ?? 0) >> 2;
|
|
2503
|
+
const maxHaptics = (capabilities[7] ?? 0) >> 2;
|
|
2504
|
+
if (!buttonCount || !maxActuation || !maxRapidTrigger) {
|
|
2505
|
+
throw new Error("The mouse returned invalid hall-effect button capabilities.");
|
|
2506
|
+
}
|
|
2507
|
+
const buttons = [];
|
|
2508
|
+
for (let button = 0; button < buttonCount; button += 1) {
|
|
2509
|
+
const reply = await this.request(featureIndex, 0x20, button);
|
|
2510
|
+
buttons.push({
|
|
2511
|
+
actuation: (reply[4] ?? 0) >> 2,
|
|
2512
|
+
rapidTrigger: (reply[5] ?? 0) >> 2,
|
|
2513
|
+
haptics: (reply[6] ?? 0) >> 2,
|
|
2514
|
+
});
|
|
2515
|
+
}
|
|
2516
|
+
return { maxActuation, maxRapidTrigger, maxHaptics, buttons };
|
|
2517
|
+
}
|
|
2518
|
+
async readDpiCapabilities(featureIndex) {
|
|
2519
|
+
if (!featureIndex)
|
|
2520
|
+
return { separateAxes: false, liftOff: false };
|
|
2521
|
+
const reply = await this.request(featureIndex, 0x10, 0x00);
|
|
2522
|
+
const flags = reply[5] ?? 0;
|
|
2523
|
+
return { separateAxes: (flags & 0x01) !== 0, liftOff: (flags & 0x02) !== 0 };
|
|
2524
|
+
}
|
|
2525
|
+
async readRgbLighting(featureIndex) {
|
|
2526
|
+
if (this.rgbZone)
|
|
2527
|
+
return this.rgbLighting;
|
|
2528
|
+
const info = await this.request(featureIndex, 0x00, 0xff, 0xff, 0x00).catch(() => null);
|
|
2529
|
+
const zoneCount = Math.min(info?.[5] ?? 0, 8);
|
|
2530
|
+
if (zoneCount === 0)
|
|
2531
|
+
return null;
|
|
2532
|
+
// OpenMouse currently presents one lighting card. G502 X PLUS advertises
|
|
2533
|
+
// its light strip as one combined cluster, so use the first advertised
|
|
2534
|
+
// cluster and enumerate only effects the firmware says it accepts.
|
|
2535
|
+
const zoneReply = await this.request(featureIndex, 0x00, 0x00, 0xff, 0x00);
|
|
2536
|
+
const effectCount = Math.min(zoneReply[6] ?? 0, 32);
|
|
2537
|
+
const zone = {
|
|
2538
|
+
index: zoneReply[3] ?? 0,
|
|
2539
|
+
location: ((zoneReply[4] ?? 0) << 8) | (zoneReply[5] ?? 0),
|
|
2540
|
+
effects: [],
|
|
2541
|
+
};
|
|
2542
|
+
for (let effectIndex = 0; effectIndex < effectCount; effectIndex += 1) {
|
|
2543
|
+
const effect = await this.request(featureIndex, 0x00, zone.index, effectIndex, 0x00);
|
|
2544
|
+
zone.effects.push({
|
|
2545
|
+
index: effect[4] ?? effectIndex,
|
|
2546
|
+
id: ((effect[5] ?? 0) << 8) | (effect[6] ?? 0),
|
|
2547
|
+
period: ((effect[9] ?? 0) << 8) | (effect[10] ?? 0),
|
|
2548
|
+
});
|
|
2549
|
+
}
|
|
2550
|
+
this.rgbZone = zone;
|
|
2551
|
+
this.rgbLighting = logitechRgbLighting(zone);
|
|
2552
|
+
return this.rgbLighting;
|
|
2553
|
+
}
|
|
2554
|
+
async readPerLedLighting(featureIndex, deviceName) {
|
|
2555
|
+
// 0x8081 exposes a 256-bit zone bitmap across three pages, but deliberately
|
|
2556
|
+
// provides no color readback. Keep it scoped to the known mouse layout;
|
|
2557
|
+
// keyboards sharing this feature need a keyboard-shaped editor.
|
|
2558
|
+
if (!deviceName.toUpperCase().includes("G502 X"))
|
|
2559
|
+
return [];
|
|
2560
|
+
const bitmap = [];
|
|
2561
|
+
for (let page = 0; page < 3; page += 1) {
|
|
2562
|
+
const reply = await this.request(featureIndex, 0x00, 0x00, 0x00, page);
|
|
2563
|
+
bitmap.push(...reply.slice(5));
|
|
2564
|
+
}
|
|
2565
|
+
const ids = [];
|
|
2566
|
+
for (let id = 1; id < Math.min(255, bitmap.length * 8); id += 1) {
|
|
2567
|
+
if (((bitmap[id >> 3] ?? 0) & (1 << (id & 7))) !== 0)
|
|
2568
|
+
ids.push(id);
|
|
2569
|
+
}
|
|
2570
|
+
return ids.map((id) => ({
|
|
2571
|
+
zone: `LED ${id}`,
|
|
2572
|
+
group: "Lightstrip",
|
|
2573
|
+
hardwareZoneId: id,
|
|
2574
|
+
modes: ["Off", "Static"],
|
|
2575
|
+
mode: "Static",
|
|
2576
|
+
color: "#7c5cff",
|
|
2577
|
+
color2: null,
|
|
2578
|
+
colorModes: ["Static"],
|
|
2579
|
+
dualColorModes: [],
|
|
2580
|
+
reactiveModes: [],
|
|
2581
|
+
speeds: [],
|
|
2582
|
+
speed: null,
|
|
2583
|
+
writeOnly: true,
|
|
2584
|
+
}));
|
|
2585
|
+
}
|
|
2586
|
+
async readDpiConfiguration(featureIndex) {
|
|
2587
|
+
const reply = await this.request(featureIndex, 0x50);
|
|
2588
|
+
const x = ((reply[4] ?? 0) << 8) | (reply[5] ?? 0);
|
|
2589
|
+
const y = ((reply[8] ?? 0) << 8) | (reply[9] ?? 0);
|
|
2590
|
+
return { x, y, lod: reply[12] ?? 0 };
|
|
2591
|
+
}
|
|
2592
|
+
async readSupportedPollingRates(featureIndex) {
|
|
2593
|
+
if (!featureIndex)
|
|
2594
|
+
return [];
|
|
2595
|
+
const reply = await this.request(featureIndex, 0x10);
|
|
2596
|
+
const flags = ((reply[3] ?? 0) << 8) | (reply[4] ?? 0);
|
|
2597
|
+
return REPORT_RATE_HZ.filter((_rate, index) => (flags & (1 << index)) !== 0);
|
|
2598
|
+
}
|
|
2599
|
+
async readPollingRate(featureIndex) {
|
|
2600
|
+
if (!featureIndex) {
|
|
2601
|
+
throw new Error("This Logitech mouse does not expose extended report-rate controls.");
|
|
2602
|
+
}
|
|
2603
|
+
const reply = await this.request(featureIndex, 0x20);
|
|
2604
|
+
const rate = REPORT_RATE_HZ[reply[3] ?? -1];
|
|
2605
|
+
if (!rate) {
|
|
2606
|
+
throw new Error("The mouse returned an unknown report-rate value.");
|
|
2607
|
+
}
|
|
2608
|
+
return this.livePollingRateHz ?? rate;
|
|
2609
|
+
}
|
|
2610
|
+
waitForRateChange(rate) {
|
|
2611
|
+
return new Promise((resolve, reject) => {
|
|
2612
|
+
const timeout = window.setTimeout(() => {
|
|
2613
|
+
const index = this.rateChangeWaiters.findIndex((waiter) => waiter.reject === reject);
|
|
2614
|
+
if (index >= 0)
|
|
2615
|
+
this.rateChangeWaiters.splice(index, 1);
|
|
2616
|
+
reject(new Error("The mouse acknowledged the rate write but did not confirm the new active rate."));
|
|
2617
|
+
}, 6000);
|
|
2618
|
+
this.rateChangeWaiters.push({
|
|
2619
|
+
rate,
|
|
2620
|
+
resolve: () => {
|
|
2621
|
+
window.clearTimeout(timeout);
|
|
2622
|
+
resolve();
|
|
2623
|
+
},
|
|
2624
|
+
reject,
|
|
2625
|
+
});
|
|
2626
|
+
});
|
|
2627
|
+
}
|
|
2628
|
+
async readProfileState(featureIndex) {
|
|
2629
|
+
if (!featureIndex) {
|
|
2630
|
+
return { activeProfile: null, deviceMode: "Unknown" };
|
|
2631
|
+
}
|
|
2632
|
+
const mode = await this.request(featureIndex, 0x20);
|
|
2633
|
+
if (mode[3] !== 0x01) {
|
|
2634
|
+
return { activeProfile: null, deviceMode: mode[3] === 0x02 ? "Host" : "Unknown" };
|
|
2635
|
+
}
|
|
2636
|
+
const active = await this.request(featureIndex, 0x40);
|
|
2637
|
+
return {
|
|
2638
|
+
activeProfile: ((active[3] ?? 0) << 8) | (active[4] ?? 0),
|
|
2639
|
+
deviceMode: "Onboard",
|
|
2640
|
+
};
|
|
2641
|
+
}
|
|
2642
|
+
async readIdentity(featureIndex) {
|
|
2643
|
+
if (!featureIndex)
|
|
2644
|
+
return { unitId: null, modelId: null, transportIds: {} };
|
|
2645
|
+
const reply = await this.request(featureIndex, 0x00);
|
|
2646
|
+
const payload = reply.slice(3);
|
|
2647
|
+
if (payload.length < 13)
|
|
2648
|
+
return { unitId: null, modelId: null, transportIds: {} };
|
|
2649
|
+
const hex = (bytes) => [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("").toUpperCase();
|
|
2650
|
+
const unitId = hex(payload.slice(1, 5));
|
|
2651
|
+
const modelBytes = payload.slice(7, 13);
|
|
2652
|
+
const modelId = hex(modelBytes);
|
|
2653
|
+
const transportIds = {};
|
|
2654
|
+
let offset = 0;
|
|
2655
|
+
for (const [name, flag] of [["Bluetooth", 0x01], ["Bluetooth LE", 0x02], ["Wireless", 0x04], ["USB", 0x08]]) {
|
|
2656
|
+
if ((payload[6] & flag) !== 0 && offset + 2 <= modelBytes.length) {
|
|
2657
|
+
transportIds[name] = hex(modelBytes.slice(offset, offset + 2));
|
|
2658
|
+
offset += 2;
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
return { unitId: unitId === "00000000" ? null : unitId, modelId, transportIds };
|
|
2662
|
+
}
|
|
2663
|
+
async ensureHostControl() {
|
|
2664
|
+
// Direct-connect mice accept live 0x2201 DPI writes while still in onboard
|
|
2665
|
+
// mode. Switching them to host control is a persistent state change that
|
|
2666
|
+
// would leave the onboard profile bypassed after the tab closes, so leave
|
|
2667
|
+
// the mouse in whichever mode the user's own software left it in.
|
|
2668
|
+
if (this.isDirectConnect)
|
|
2669
|
+
return;
|
|
2670
|
+
const profiles = await this.getFeature(FEATURE.onboardProfiles);
|
|
2671
|
+
if (!profiles.index)
|
|
2672
|
+
return;
|
|
2673
|
+
const mode = await this.request(profiles.index, 0x20);
|
|
2674
|
+
if (mode[3] === 0x02)
|
|
2675
|
+
return;
|
|
2676
|
+
// Some mice refuse to hand control to software at all — a G102 answers this
|
|
2677
|
+
// with "request unavailable". Writing anyway is not the answer: the setting
|
|
2678
|
+
// would land in an onboard profile whose layout we have not decoded, so we
|
|
2679
|
+
// would be changing bytes we do not understand. Stop, and say what would
|
|
2680
|
+
// make it work.
|
|
2681
|
+
const refused = await this.request(profiles.index, 0x10, 0x02).then(() => false, () => true);
|
|
2682
|
+
const confirmed = refused ? null : await this.request(profiles.index, 0x20).catch(() => null);
|
|
2683
|
+
if (refused || confirmed?.[3] !== 0x02) {
|
|
2684
|
+
throw new OnboardOnlyError(this.profileFormatId);
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
async readFirmware(featureIndex) {
|
|
2688
|
+
if (!featureIndex) {
|
|
2689
|
+
return [];
|
|
2690
|
+
}
|
|
2691
|
+
const countReply = await this.request(featureIndex, 0x00);
|
|
2692
|
+
const count = countReply[3] ?? 0;
|
|
2693
|
+
const decoder = new TextDecoder();
|
|
2694
|
+
const firmware = [];
|
|
2695
|
+
for (let item = 0; item < count; item += 1) {
|
|
2696
|
+
const reply = await this.request(featureIndex, 0x10, item);
|
|
2697
|
+
const name = decoder.decode(reply.slice(4, 7)).replace(/\0/g, "");
|
|
2698
|
+
const major = (reply[7] ?? 0).toString(16).padStart(2, "0").toUpperCase();
|
|
2699
|
+
const minor = (reply[8] ?? 0).toString(16).padStart(2, "0").toUpperCase();
|
|
2700
|
+
firmware.push(`${name} ${major}.${minor}`);
|
|
2701
|
+
}
|
|
2702
|
+
return firmware;
|
|
2703
|
+
}
|
|
2704
|
+
async request(featureIndex, functionId, ...parameters) {
|
|
2705
|
+
return this.requestWithOptions(featureIndex, functionId, parameters);
|
|
2706
|
+
}
|
|
2707
|
+
async requestWithOptions(featureIndex, functionId, parameters, options = {}) {
|
|
2708
|
+
// Bolt feature traffic only answers on long reports, and Bluetooth has no
|
|
2709
|
+
// short report at all: its descriptor declares report 0x11 alone, so a
|
|
2710
|
+
// sendReport(0x10) is rejected outright. Lightspeed and wired mice keep the
|
|
2711
|
+
// short form for the three-parameter path they were verified with; longer
|
|
2712
|
+
// payloads still go through requestLong.
|
|
2713
|
+
if (this.isBoltReceiver || this.isBluetooth) {
|
|
2714
|
+
return this.requestLong(featureIndex, functionId, parameters, options.timeoutMs);
|
|
2715
|
+
}
|
|
2716
|
+
if (parameters.length > 3) {
|
|
2717
|
+
throw new Error("This WebHID client only sends short, read-only HID++ requests.");
|
|
2718
|
+
}
|
|
2719
|
+
const report = new Uint8Array([
|
|
2720
|
+
this.deviceIndex,
|
|
2721
|
+
featureIndex,
|
|
2722
|
+
withSoftwareId(functionId),
|
|
2723
|
+
parameters[0] ?? 0,
|
|
2724
|
+
parameters[1] ?? 0,
|
|
2725
|
+
parameters[2] ?? 0,
|
|
2726
|
+
]);
|
|
2727
|
+
const response = this.waitForResponse(featureIndex, functionId, options.timeoutMs);
|
|
2728
|
+
// Keep the timeout rejection observed even when sendReport itself fails
|
|
2729
|
+
// (for example, when a browser selected a protected mouse collection).
|
|
2730
|
+
// The original sendReport error is then shown by the control panel.
|
|
2731
|
+
void response.catch(() => undefined);
|
|
2732
|
+
await this.reportDevice.sendReport(SHORT_REPORT_ID, report);
|
|
2733
|
+
return await response;
|
|
2734
|
+
}
|
|
2735
|
+
async requestLong(featureIndex, functionId, parameters, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
2736
|
+
if (parameters.length > 16) {
|
|
2737
|
+
throw new Error("HID++ long requests support at most 16 parameter bytes.");
|
|
2738
|
+
}
|
|
2739
|
+
const report = new Uint8Array(19);
|
|
2740
|
+
report[0] = this.deviceIndex;
|
|
2741
|
+
report[1] = featureIndex;
|
|
2742
|
+
report[2] = withSoftwareId(functionId);
|
|
2743
|
+
report.set(parameters, 3);
|
|
2744
|
+
const response = this.waitForResponse(featureIndex, functionId, timeoutMs);
|
|
2745
|
+
void response.catch(() => undefined);
|
|
2746
|
+
await this.reportDevice.sendReport(LONG_REPORT_ID, report);
|
|
2747
|
+
return await response;
|
|
2748
|
+
}
|
|
2749
|
+
waitForResponse(featureIndex, functionId, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
2750
|
+
return new Promise((resolve, reject) => {
|
|
2751
|
+
const timeout = window.setTimeout(() => {
|
|
2752
|
+
const index = this.waiters.findIndex((waiter) => waiter.reject === reject);
|
|
2753
|
+
if (index >= 0) {
|
|
2754
|
+
this.waiters.splice(index, 1);
|
|
2755
|
+
}
|
|
2756
|
+
reject(new HidppTimeoutError(this.isDirectConnect
|
|
2757
|
+
? "The mouse did not answer. Close Logitech G HUB or Logitech Gaming Software, then try again."
|
|
2758
|
+
: this.isBoltReceiver || this.isBluetooth
|
|
2759
|
+
? "The mouse did not answer. Move it, close Logi Options+, then try again."
|
|
2760
|
+
: "The mouse did not answer. Move it or click a button, then try again."));
|
|
2761
|
+
}, timeoutMs);
|
|
2762
|
+
this.waiters.push({
|
|
2763
|
+
featureIndex,
|
|
2764
|
+
functionId,
|
|
2765
|
+
resolve: (report) => {
|
|
2766
|
+
window.clearTimeout(timeout);
|
|
2767
|
+
resolve(report);
|
|
2768
|
+
},
|
|
2769
|
+
reject,
|
|
2770
|
+
});
|
|
2771
|
+
});
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
//# sourceMappingURL=hidpp.js.map
|