@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.
Files changed (547) hide show
  1. package/CONTRIBUTING.md +167 -0
  2. package/README.md +89 -0
  3. package/dist/asus/index.d.ts +52 -0
  4. package/dist/asus/index.d.ts.map +1 -0
  5. package/dist/asus/index.js +248 -0
  6. package/dist/asus/index.js.map +1 -0
  7. package/dist/atk/index.d.ts +200 -0
  8. package/dist/atk/index.d.ts.map +1 -0
  9. package/dist/atk/index.js +480 -0
  10. package/dist/atk/index.js.map +1 -0
  11. package/dist/bitmouse/index.d.ts +284 -0
  12. package/dist/bitmouse/index.d.ts.map +1 -0
  13. package/dist/bitmouse/index.js +396 -0
  14. package/dist/bitmouse/index.js.map +1 -0
  15. package/dist/compx/codec.d.ts +31 -0
  16. package/dist/compx/codec.d.ts.map +1 -0
  17. package/dist/compx/codec.js +50 -0
  18. package/dist/compx/codec.js.map +1 -0
  19. package/dist/corsair/index.d.ts +147 -0
  20. package/dist/corsair/index.d.ts.map +1 -0
  21. package/dist/corsair/index.js +223 -0
  22. package/dist/corsair/index.js.map +1 -0
  23. package/dist/dareu/index.d.ts +136 -0
  24. package/dist/dareu/index.d.ts.map +1 -0
  25. package/dist/dareu/index.js +344 -0
  26. package/dist/dareu/index.js.map +1 -0
  27. package/dist/drivers/asus/hid.d.ts +29 -0
  28. package/dist/drivers/asus/hid.d.ts.map +1 -0
  29. package/dist/drivers/asus/hid.js +419 -0
  30. package/dist/drivers/asus/hid.js.map +1 -0
  31. package/dist/drivers/atk/bitmouse-hid.d.ts +80 -0
  32. package/dist/drivers/atk/bitmouse-hid.d.ts.map +1 -0
  33. package/dist/drivers/atk/bitmouse-hid.js +501 -0
  34. package/dist/drivers/atk/bitmouse-hid.js.map +1 -0
  35. package/dist/drivers/atk/hid.d.ts +163 -0
  36. package/dist/drivers/atk/hid.d.ts.map +1 -0
  37. package/dist/drivers/atk/hid.js +1368 -0
  38. package/dist/drivers/atk/hid.js.map +1 -0
  39. package/dist/drivers/atk/products.d.ts +13 -0
  40. package/dist/drivers/atk/products.d.ts.map +1 -0
  41. package/dist/drivers/atk/products.js +13 -0
  42. package/dist/drivers/atk/products.js.map +1 -0
  43. package/dist/drivers/attackshark/dpi.d.ts +41 -0
  44. package/dist/drivers/attackshark/dpi.d.ts.map +1 -0
  45. package/dist/drivers/attackshark/dpi.js +215 -0
  46. package/dist/drivers/attackshark/dpi.js.map +1 -0
  47. package/dist/drivers/attackshark/hid.d.ts +124 -0
  48. package/dist/drivers/attackshark/hid.d.ts.map +1 -0
  49. package/dist/drivers/attackshark/hid.js +728 -0
  50. package/dist/drivers/attackshark/hid.js.map +1 -0
  51. package/dist/drivers/corsair/hid.d.ts +70 -0
  52. package/dist/drivers/corsair/hid.d.ts.map +1 -0
  53. package/dist/drivers/corsair/hid.js +414 -0
  54. package/dist/drivers/corsair/hid.js.map +1 -0
  55. package/dist/drivers/dareu/hid.d.ts +59 -0
  56. package/dist/drivers/dareu/hid.d.ts.map +1 -0
  57. package/dist/drivers/dareu/hid.js +478 -0
  58. package/dist/drivers/dareu/hid.js.map +1 -0
  59. package/dist/drivers/endgame/egg-op1-hid.d.ts +107 -0
  60. package/dist/drivers/endgame/egg-op1-hid.d.ts.map +1 -0
  61. package/dist/drivers/endgame/egg-op1-hid.js +605 -0
  62. package/dist/drivers/endgame/egg-op1-hid.js.map +1 -0
  63. package/dist/drivers/endgame/egg-we-control.d.ts +42 -0
  64. package/dist/drivers/endgame/egg-we-control.d.ts.map +1 -0
  65. package/dist/drivers/endgame/egg-we-control.js +87 -0
  66. package/dist/drivers/endgame/egg-we-control.js.map +1 -0
  67. package/dist/drivers/endgame/egg-we-hid.d.ts +58 -0
  68. package/dist/drivers/endgame/egg-we-hid.d.ts.map +1 -0
  69. package/dist/drivers/endgame/egg-we-hid.js +514 -0
  70. package/dist/drivers/endgame/egg-we-hid.js.map +1 -0
  71. package/dist/drivers/fantech/hid.d.ts +106 -0
  72. package/dist/drivers/fantech/hid.d.ts.map +1 -0
  73. package/dist/drivers/fantech/hid.js +295 -0
  74. package/dist/drivers/fantech/hid.js.map +1 -0
  75. package/dist/drivers/finalmouse/hid.d.ts +31 -0
  76. package/dist/drivers/finalmouse/hid.d.ts.map +1 -0
  77. package/dist/drivers/finalmouse/hid.js +181 -0
  78. package/dist/drivers/finalmouse/hid.js.map +1 -0
  79. package/dist/drivers/gearhub/hid.d.ts +191 -0
  80. package/dist/drivers/gearhub/hid.d.ts.map +1 -0
  81. package/dist/drivers/gearhub/hid.js +542 -0
  82. package/dist/drivers/gearhub/hid.js.map +1 -0
  83. package/dist/drivers/glorious/classic-hid.d.ts +62 -0
  84. package/dist/drivers/glorious/classic-hid.d.ts.map +1 -0
  85. package/dist/drivers/glorious/classic-hid.js +326 -0
  86. package/dist/drivers/glorious/classic-hid.js.map +1 -0
  87. package/dist/drivers/glorious/hid.d.ts +39 -0
  88. package/dist/drivers/glorious/hid.d.ts.map +1 -0
  89. package/dist/drivers/glorious/hid.js +203 -0
  90. package/dist/drivers/glorious/hid.js.map +1 -0
  91. package/dist/drivers/gwolves/hid.d.ts +27 -0
  92. package/dist/drivers/gwolves/hid.d.ts.map +1 -0
  93. package/dist/drivers/gwolves/hid.js +225 -0
  94. package/dist/drivers/gwolves/hid.js.map +1 -0
  95. package/dist/drivers/gwolves/products.d.ts +17 -0
  96. package/dist/drivers/gwolves/products.d.ts.map +1 -0
  97. package/dist/drivers/gwolves/products.js +58 -0
  98. package/dist/drivers/gwolves/products.js.map +1 -0
  99. package/dist/drivers/hyperx/hid.d.ts +59 -0
  100. package/dist/drivers/hyperx/hid.d.ts.map +1 -0
  101. package/dist/drivers/hyperx/hid.js +229 -0
  102. package/dist/drivers/hyperx/hid.js.map +1 -0
  103. package/dist/drivers/incott/hid.d.ts +513 -0
  104. package/dist/drivers/incott/hid.d.ts.map +1 -0
  105. package/dist/drivers/incott/hid.js +1173 -0
  106. package/dist/drivers/incott/hid.js.map +1 -0
  107. package/dist/drivers/index.d.ts +5 -0
  108. package/dist/drivers/index.d.ts.map +1 -0
  109. package/dist/drivers/index.js +5 -0
  110. package/dist/drivers/index.js.map +1 -0
  111. package/dist/drivers/keychron/m6-hid.d.ts +81 -0
  112. package/dist/drivers/keychron/m6-hid.d.ts.map +1 -0
  113. package/dist/drivers/keychron/m6-hid.js +486 -0
  114. package/dist/drivers/keychron/m6-hid.js.map +1 -0
  115. package/dist/drivers/keychron/nape-hid.d.ts +60 -0
  116. package/dist/drivers/keychron/nape-hid.d.ts.map +1 -0
  117. package/dist/drivers/keychron/nape-hid.js +432 -0
  118. package/dist/drivers/keychron/nape-hid.js.map +1 -0
  119. package/dist/drivers/ksnake/hid.d.ts +74 -0
  120. package/dist/drivers/ksnake/hid.d.ts.map +1 -0
  121. package/dist/drivers/ksnake/hid.js +368 -0
  122. package/dist/drivers/ksnake/hid.js.map +1 -0
  123. package/dist/drivers/lamzu/hid.d.ts +58 -0
  124. package/dist/drivers/lamzu/hid.d.ts.map +1 -0
  125. package/dist/drivers/lamzu/hid.js +414 -0
  126. package/dist/drivers/lamzu/hid.js.map +1 -0
  127. package/dist/drivers/lamzu-atlantis/hid.d.ts +148 -0
  128. package/dist/drivers/lamzu-atlantis/hid.d.ts.map +1 -0
  129. package/dist/drivers/lamzu-atlantis/hid.js +587 -0
  130. package/dist/drivers/lamzu-atlantis/hid.js.map +1 -0
  131. package/dist/drivers/logitech/bolt.d.ts +41 -0
  132. package/dist/drivers/logitech/bolt.d.ts.map +1 -0
  133. package/dist/drivers/logitech/bolt.js +105 -0
  134. package/dist/drivers/logitech/bolt.js.map +1 -0
  135. package/dist/drivers/logitech/hidpp.d.ts +525 -0
  136. package/dist/drivers/logitech/hidpp.d.ts.map +1 -0
  137. package/dist/drivers/logitech/hidpp.js +2774 -0
  138. package/dist/drivers/logitech/hidpp.js.map +1 -0
  139. package/dist/drivers/logitech/mode-status.d.ts +53 -0
  140. package/dist/drivers/logitech/mode-status.d.ts.map +1 -0
  141. package/dist/drivers/logitech/mode-status.js +52 -0
  142. package/dist/drivers/logitech/mode-status.js.map +1 -0
  143. package/dist/drivers/logitech/onboard-profiles.d.ts +298 -0
  144. package/dist/drivers/logitech/onboard-profiles.d.ts.map +1 -0
  145. package/dist/drivers/logitech/onboard-profiles.js +965 -0
  146. package/dist/drivers/logitech/onboard-profiles.js.map +1 -0
  147. package/dist/drivers/logitech/rgb-effects.d.ts +20 -0
  148. package/dist/drivers/logitech/rgb-effects.d.ts.map +1 -0
  149. package/dist/drivers/logitech/rgb-effects.js +93 -0
  150. package/dist/drivers/logitech/rgb-effects.js.map +1 -0
  151. package/dist/drivers/mchose/a5-gen1-hid.d.ts +36 -0
  152. package/dist/drivers/mchose/a5-gen1-hid.d.ts.map +1 -0
  153. package/dist/drivers/mchose/a5-gen1-hid.js +219 -0
  154. package/dist/drivers/mchose/a5-gen1-hid.js.map +1 -0
  155. package/dist/drivers/mchose/dock-hid.d.ts +26 -0
  156. package/dist/drivers/mchose/dock-hid.d.ts.map +1 -0
  157. package/dist/drivers/mchose/dock-hid.js +176 -0
  158. package/dist/drivers/mchose/dock-hid.js.map +1 -0
  159. package/dist/drivers/mchose/hid.d.ts +118 -0
  160. package/dist/drivers/mchose/hid.d.ts.map +1 -0
  161. package/dist/drivers/mchose/hid.js +550 -0
  162. package/dist/drivers/mchose/hid.js.map +1 -0
  163. package/dist/drivers/mchose/v3-hid.d.ts +109 -0
  164. package/dist/drivers/mchose/v3-hid.d.ts.map +1 -0
  165. package/dist/drivers/mchose/v3-hid.js +572 -0
  166. package/dist/drivers/mchose/v3-hid.js.map +1 -0
  167. package/dist/drivers/microsoft/hid.d.ts +25 -0
  168. package/dist/drivers/microsoft/hid.d.ts.map +1 -0
  169. package/dist/drivers/microsoft/hid.js +247 -0
  170. package/dist/drivers/microsoft/hid.js.map +1 -0
  171. package/dist/drivers/moddo/hid.d.ts +31 -0
  172. package/dist/drivers/moddo/hid.d.ts.map +1 -0
  173. package/dist/drivers/moddo/hid.js +154 -0
  174. package/dist/drivers/moddo/hid.js.map +1 -0
  175. package/dist/drivers/mouse-types.d.ts +388 -0
  176. package/dist/drivers/mouse-types.d.ts.map +1 -0
  177. package/dist/drivers/mouse-types.js +2 -0
  178. package/dist/drivers/mouse-types.js.map +1 -0
  179. package/dist/drivers/ninjutso/hid.d.ts +62 -0
  180. package/dist/drivers/ninjutso/hid.d.ts.map +1 -0
  181. package/dist/drivers/ninjutso/hid.js +680 -0
  182. package/dist/drivers/ninjutso/hid.js.map +1 -0
  183. package/dist/drivers/orbital/hid.d.ts +24 -0
  184. package/dist/drivers/orbital/hid.d.ts.map +1 -0
  185. package/dist/drivers/orbital/hid.js +85 -0
  186. package/dist/drivers/orbital/hid.js.map +1 -0
  187. package/dist/drivers/orbital/host-protocol.d.ts +59 -0
  188. package/dist/drivers/orbital/host-protocol.d.ts.map +1 -0
  189. package/dist/drivers/orbital/host-protocol.js +884 -0
  190. package/dist/drivers/orbital/host-protocol.js.map +1 -0
  191. package/dist/drivers/pulsar/pulsar-hid.d.ts +63 -0
  192. package/dist/drivers/pulsar/pulsar-hid.d.ts.map +1 -0
  193. package/dist/drivers/pulsar/pulsar-hid.js +381 -0
  194. package/dist/drivers/pulsar/pulsar-hid.js.map +1 -0
  195. package/dist/drivers/pulsar/pulsar-pro-hid.d.ts +46 -0
  196. package/dist/drivers/pulsar/pulsar-pro-hid.d.ts.map +1 -0
  197. package/dist/drivers/pulsar/pulsar-pro-hid.js +343 -0
  198. package/dist/drivers/pulsar/pulsar-pro-hid.js.map +1 -0
  199. package/dist/drivers/pulsar/pulsar-xs1-hid.d.ts +40 -0
  200. package/dist/drivers/pulsar/pulsar-xs1-hid.d.ts.map +1 -0
  201. package/dist/drivers/pulsar/pulsar-xs1-hid.js +251 -0
  202. package/dist/drivers/pulsar/pulsar-xs1-hid.js.map +1 -0
  203. package/dist/drivers/rawm/hid.d.ts +29 -0
  204. package/dist/drivers/rawm/hid.d.ts.map +1 -0
  205. package/dist/drivers/rawm/hid.js +210 -0
  206. package/dist/drivers/rawm/hid.js.map +1 -0
  207. package/dist/drivers/razer/cobra-hid.d.ts +63 -0
  208. package/dist/drivers/razer/cobra-hid.d.ts.map +1 -0
  209. package/dist/drivers/razer/cobra-hid.js +282 -0
  210. package/dist/drivers/razer/cobra-hid.js.map +1 -0
  211. package/dist/drivers/razer/hid-open.d.ts +13 -0
  212. package/dist/drivers/razer/hid-open.d.ts.map +1 -0
  213. package/dist/drivers/razer/hid-open.js +29 -0
  214. package/dist/drivers/razer/hid-open.js.map +1 -0
  215. package/dist/drivers/razer/hid.d.ts +231 -0
  216. package/dist/drivers/razer/hid.d.ts.map +1 -0
  217. package/dist/drivers/razer/hid.js +772 -0
  218. package/dist/drivers/razer/hid.js.map +1 -0
  219. package/dist/drivers/razer/viper-hid.d.ts +33 -0
  220. package/dist/drivers/razer/viper-hid.d.ts.map +1 -0
  221. package/dist/drivers/razer/viper-hid.js +177 -0
  222. package/dist/drivers/razer/viper-hid.js.map +1 -0
  223. package/dist/drivers/razer/viper-mini-hid.d.ts +58 -0
  224. package/dist/drivers/razer/viper-mini-hid.d.ts.map +1 -0
  225. package/dist/drivers/razer/viper-mini-hid.js +273 -0
  226. package/dist/drivers/razer/viper-mini-hid.js.map +1 -0
  227. package/dist/drivers/razer/viper-v4-pro-hid.d.ts +26 -0
  228. package/dist/drivers/razer/viper-v4-pro-hid.d.ts.map +1 -0
  229. package/dist/drivers/razer/viper-v4-pro-hid.js +154 -0
  230. package/dist/drivers/razer/viper-v4-pro-hid.js.map +1 -0
  231. package/dist/drivers/registry.d.ts +70 -0
  232. package/dist/drivers/registry.d.ts.map +1 -0
  233. package/dist/drivers/registry.js +146 -0
  234. package/dist/drivers/registry.js.map +1 -0
  235. package/dist/drivers/steelseries/aerox3-hid.d.ts +53 -0
  236. package/dist/drivers/steelseries/aerox3-hid.d.ts.map +1 -0
  237. package/dist/drivers/steelseries/aerox3-hid.js +172 -0
  238. package/dist/drivers/steelseries/aerox3-hid.js.map +1 -0
  239. package/dist/drivers/steelseries/aerox5-hid.d.ts +54 -0
  240. package/dist/drivers/steelseries/aerox5-hid.d.ts.map +1 -0
  241. package/dist/drivers/steelseries/aerox5-hid.js +173 -0
  242. package/dist/drivers/steelseries/aerox5-hid.js.map +1 -0
  243. package/dist/drivers/steelseries/aerox5-wireless-hid.d.ts +69 -0
  244. package/dist/drivers/steelseries/aerox5-wireless-hid.d.ts.map +1 -0
  245. package/dist/drivers/steelseries/aerox5-wireless-hid.js +234 -0
  246. package/dist/drivers/steelseries/aerox5-wireless-hid.js.map +1 -0
  247. package/dist/drivers/steelseries/aerox9-wireless-hid.d.ts +70 -0
  248. package/dist/drivers/steelseries/aerox9-wireless-hid.d.ts.map +1 -0
  249. package/dist/drivers/steelseries/aerox9-wireless-hid.js +227 -0
  250. package/dist/drivers/steelseries/aerox9-wireless-hid.js.map +1 -0
  251. package/dist/drivers/steelseries/hid.d.ts +48 -0
  252. package/dist/drivers/steelseries/hid.d.ts.map +1 -0
  253. package/dist/drivers/steelseries/hid.js +157 -0
  254. package/dist/drivers/steelseries/hid.js.map +1 -0
  255. package/dist/drivers/steelseries/prime-mini-wireless-hid.d.ts +62 -0
  256. package/dist/drivers/steelseries/prime-mini-wireless-hid.d.ts.map +1 -0
  257. package/dist/drivers/steelseries/prime-mini-wireless-hid.js +215 -0
  258. package/dist/drivers/steelseries/prime-mini-wireless-hid.js.map +1 -0
  259. package/dist/drivers/steelseries/prime-plus-hid.d.ts +51 -0
  260. package/dist/drivers/steelseries/prime-plus-hid.d.ts.map +1 -0
  261. package/dist/drivers/steelseries/prime-plus-hid.js +150 -0
  262. package/dist/drivers/steelseries/prime-plus-hid.js.map +1 -0
  263. package/dist/drivers/steelseries/rival3-wireless-hid.d.ts +61 -0
  264. package/dist/drivers/steelseries/rival3-wireless-hid.d.ts.map +1 -0
  265. package/dist/drivers/steelseries/rival3-wireless-hid.js +185 -0
  266. package/dist/drivers/steelseries/rival3-wireless-hid.js.map +1 -0
  267. package/dist/drivers/steelseries/rival310-hid.d.ts +59 -0
  268. package/dist/drivers/steelseries/rival310-hid.d.ts.map +1 -0
  269. package/dist/drivers/steelseries/rival310-hid.js +182 -0
  270. package/dist/drivers/steelseries/rival310-hid.js.map +1 -0
  271. package/dist/drivers/steelseries/rival650-hid.d.ts +68 -0
  272. package/dist/drivers/steelseries/rival650-hid.d.ts.map +1 -0
  273. package/dist/drivers/steelseries/rival650-hid.js +209 -0
  274. package/dist/drivers/steelseries/rival650-hid.js.map +1 -0
  275. package/dist/drivers/steelseries/sensei-ten-hid.d.ts +62 -0
  276. package/dist/drivers/steelseries/sensei-ten-hid.d.ts.map +1 -0
  277. package/dist/drivers/steelseries/sensei-ten-hid.js +186 -0
  278. package/dist/drivers/steelseries/sensei-ten-hid.js.map +1 -0
  279. package/dist/drivers/teevolution/hid.d.ts +82 -0
  280. package/dist/drivers/teevolution/hid.d.ts.map +1 -0
  281. package/dist/drivers/teevolution/hid.js +607 -0
  282. package/dist/drivers/teevolution/hid.js.map +1 -0
  283. package/dist/drivers/vendors.d.ts +254 -0
  284. package/dist/drivers/vendors.d.ts.map +1 -0
  285. package/dist/drivers/vendors.js +587 -0
  286. package/dist/drivers/vendors.js.map +1 -0
  287. package/dist/drivers/vgn/hid.d.ts +33 -0
  288. package/dist/drivers/vgn/hid.d.ts.map +1 -0
  289. package/dist/drivers/vgn/hid.js +213 -0
  290. package/dist/drivers/vgn/hid.js.map +1 -0
  291. package/dist/drivers/wallhack/keyboard-hid.d.ts +28 -0
  292. package/dist/drivers/wallhack/keyboard-hid.d.ts.map +1 -0
  293. package/dist/drivers/wallhack/keyboard-hid.js +80 -0
  294. package/dist/drivers/wallhack/keyboard-hid.js.map +1 -0
  295. package/dist/drivers/wallhack/mouse-hid.d.ts +45 -0
  296. package/dist/drivers/wallhack/mouse-hid.d.ts.map +1 -0
  297. package/dist/drivers/wallhack/mouse-hid.js +288 -0
  298. package/dist/drivers/wallhack/mouse-hid.js.map +1 -0
  299. package/dist/drivers/webhid.d.ts +59 -0
  300. package/dist/drivers/webhid.d.ts.map +1 -0
  301. package/dist/drivers/webhid.js +2 -0
  302. package/dist/drivers/webhid.js.map +1 -0
  303. package/dist/drivers/wlmouse/hid.d.ts +81 -0
  304. package/dist/drivers/wlmouse/hid.d.ts.map +1 -0
  305. package/dist/drivers/wlmouse/hid.js +576 -0
  306. package/dist/drivers/wlmouse/hid.js.map +1 -0
  307. package/dist/drivers/wooting/hid.d.ts +86 -0
  308. package/dist/drivers/wooting/hid.d.ts.map +1 -0
  309. package/dist/drivers/wooting/hid.js +320 -0
  310. package/dist/drivers/wooting/hid.js.map +1 -0
  311. package/dist/drivers/zaunkoenig/hid.d.ts +31 -0
  312. package/dist/drivers/zaunkoenig/hid.d.ts.map +1 -0
  313. package/dist/drivers/zaunkoenig/hid.js +162 -0
  314. package/dist/drivers/zaunkoenig/hid.js.map +1 -0
  315. package/dist/endgame-gear/op1.d.ts +165 -0
  316. package/dist/endgame-gear/op1.d.ts.map +1 -0
  317. package/dist/endgame-gear/op1.js +384 -0
  318. package/dist/endgame-gear/op1.js.map +1 -0
  319. package/dist/endgame-gear/wireless.d.ts +93 -0
  320. package/dist/endgame-gear/wireless.d.ts.map +1 -0
  321. package/dist/endgame-gear/wireless.js +242 -0
  322. package/dist/endgame-gear/wireless.js.map +1 -0
  323. package/dist/fantech/index.d.ts +9 -0
  324. package/dist/fantech/index.d.ts.map +1 -0
  325. package/dist/fantech/index.js +11 -0
  326. package/dist/fantech/index.js.map +1 -0
  327. package/dist/finalmouse/index.d.ts +41 -0
  328. package/dist/finalmouse/index.d.ts.map +1 -0
  329. package/dist/finalmouse/index.js +106 -0
  330. package/dist/finalmouse/index.js.map +1 -0
  331. package/dist/gearhub/index.d.ts +210 -0
  332. package/dist/gearhub/index.d.ts.map +1 -0
  333. package/dist/gearhub/index.js +282 -0
  334. package/dist/gearhub/index.js.map +1 -0
  335. package/dist/glorious/index.d.ts +89 -0
  336. package/dist/glorious/index.d.ts.map +1 -0
  337. package/dist/glorious/index.js +236 -0
  338. package/dist/glorious/index.js.map +1 -0
  339. package/dist/glorious-classic/index.d.ts +103 -0
  340. package/dist/glorious-classic/index.d.ts.map +1 -0
  341. package/dist/glorious-classic/index.js +304 -0
  342. package/dist/glorious-classic/index.js.map +1 -0
  343. package/dist/gwolves/index.d.ts +82 -0
  344. package/dist/gwolves/index.d.ts.map +1 -0
  345. package/dist/gwolves/index.js +197 -0
  346. package/dist/gwolves/index.js.map +1 -0
  347. package/dist/hyperx/index.d.ts +181 -0
  348. package/dist/hyperx/index.d.ts.map +1 -0
  349. package/dist/hyperx/index.js +303 -0
  350. package/dist/hyperx/index.js.map +1 -0
  351. package/dist/incott/index.d.ts +1067 -0
  352. package/dist/incott/index.d.ts.map +1 -0
  353. package/dist/incott/index.js +1482 -0
  354. package/dist/incott/index.js.map +1 -0
  355. package/dist/index.d.ts +30 -0
  356. package/dist/index.d.ts.map +1 -0
  357. package/dist/index.js +30 -0
  358. package/dist/index.js.map +1 -0
  359. package/dist/keychron/index.d.ts +194 -0
  360. package/dist/keychron/index.d.ts.map +1 -0
  361. package/dist/keychron/index.js +341 -0
  362. package/dist/keychron/index.js.map +1 -0
  363. package/dist/ksnake/index.d.ts +157 -0
  364. package/dist/ksnake/index.d.ts.map +1 -0
  365. package/dist/ksnake/index.js +314 -0
  366. package/dist/ksnake/index.js.map +1 -0
  367. package/dist/lamzu/atlantis.d.ts +237 -0
  368. package/dist/lamzu/atlantis.d.ts.map +1 -0
  369. package/dist/lamzu/atlantis.js +263 -0
  370. package/dist/lamzu/atlantis.js.map +1 -0
  371. package/dist/lamzu/index.d.ts +60 -0
  372. package/dist/lamzu/index.d.ts.map +1 -0
  373. package/dist/lamzu/index.js +86 -0
  374. package/dist/lamzu/index.js.map +1 -0
  375. package/dist/logitech/controls.d.ts +138 -0
  376. package/dist/logitech/controls.d.ts.map +1 -0
  377. package/dist/logitech/controls.js +184 -0
  378. package/dist/logitech/controls.js.map +1 -0
  379. package/dist/logitech/friendly-name.d.ts +50 -0
  380. package/dist/logitech/friendly-name.d.ts.map +1 -0
  381. package/dist/logitech/friendly-name.js +74 -0
  382. package/dist/logitech/friendly-name.js.map +1 -0
  383. package/dist/logitech/haptics.d.ts +97 -0
  384. package/dist/logitech/haptics.d.ts.map +1 -0
  385. package/dist/logitech/haptics.js +116 -0
  386. package/dist/logitech/haptics.js.map +1 -0
  387. package/dist/logitech/hosts.d.ts +55 -0
  388. package/dist/logitech/hosts.d.ts.map +1 -0
  389. package/dist/logitech/hosts.js +80 -0
  390. package/dist/logitech/hosts.js.map +1 -0
  391. package/dist/logitech/index.d.ts +127 -0
  392. package/dist/logitech/index.d.ts.map +1 -0
  393. package/dist/logitech/index.js +233 -0
  394. package/dist/logitech/index.js.map +1 -0
  395. package/dist/logitech/wheel.d.ts +117 -0
  396. package/dist/logitech/wheel.d.ts.map +1 -0
  397. package/dist/logitech/wheel.js +133 -0
  398. package/dist/logitech/wheel.js.map +1 -0
  399. package/dist/mchose/a5-gen1.d.ts +38 -0
  400. package/dist/mchose/a5-gen1.d.ts.map +1 -0
  401. package/dist/mchose/a5-gen1.js +60 -0
  402. package/dist/mchose/a5-gen1.js.map +1 -0
  403. package/dist/mchose/buttons.d.ts +66 -0
  404. package/dist/mchose/buttons.d.ts.map +1 -0
  405. package/dist/mchose/buttons.js +184 -0
  406. package/dist/mchose/buttons.js.map +1 -0
  407. package/dist/mchose/dock.d.ts +77 -0
  408. package/dist/mchose/dock.d.ts.map +1 -0
  409. package/dist/mchose/dock.js +133 -0
  410. package/dist/mchose/dock.js.map +1 -0
  411. package/dist/mchose/index.d.ts +299 -0
  412. package/dist/mchose/index.d.ts.map +1 -0
  413. package/dist/mchose/index.js +429 -0
  414. package/dist/mchose/index.js.map +1 -0
  415. package/dist/mchose/v3-buttons.d.ts +64 -0
  416. package/dist/mchose/v3-buttons.d.ts.map +1 -0
  417. package/dist/mchose/v3-buttons.js +258 -0
  418. package/dist/mchose/v3-buttons.js.map +1 -0
  419. package/dist/mchose/v3.d.ts +380 -0
  420. package/dist/mchose/v3.d.ts.map +1 -0
  421. package/dist/mchose/v3.js +608 -0
  422. package/dist/mchose/v3.js.map +1 -0
  423. package/dist/microsoft/index.d.ts +19 -0
  424. package/dist/microsoft/index.d.ts.map +1 -0
  425. package/dist/microsoft/index.js +22 -0
  426. package/dist/microsoft/index.js.map +1 -0
  427. package/dist/moddo/index.d.ts +72 -0
  428. package/dist/moddo/index.d.ts.map +1 -0
  429. package/dist/moddo/index.js +147 -0
  430. package/dist/moddo/index.js.map +1 -0
  431. package/dist/ninjutso/index.d.ts +74 -0
  432. package/dist/ninjutso/index.d.ts.map +1 -0
  433. package/dist/ninjutso/index.js +133 -0
  434. package/dist/ninjutso/index.js.map +1 -0
  435. package/dist/orbital/index.d.ts +14 -0
  436. package/dist/orbital/index.d.ts.map +1 -0
  437. package/dist/orbital/index.js +19 -0
  438. package/dist/orbital/index.js.map +1 -0
  439. package/dist/pulsar/index.d.ts +81 -0
  440. package/dist/pulsar/index.d.ts.map +1 -0
  441. package/dist/pulsar/index.js +185 -0
  442. package/dist/pulsar/index.js.map +1 -0
  443. package/dist/rawm/index.d.ts +55 -0
  444. package/dist/rawm/index.d.ts.map +1 -0
  445. package/dist/rawm/index.js +229 -0
  446. package/dist/rawm/index.js.map +1 -0
  447. package/dist/razer/codec.d.ts +504 -0
  448. package/dist/razer/codec.d.ts.map +1 -0
  449. package/dist/razer/codec.js +648 -0
  450. package/dist/razer/codec.js.map +1 -0
  451. package/dist/razer/devices.d.ts +156 -0
  452. package/dist/razer/devices.d.ts.map +1 -0
  453. package/dist/razer/devices.js +540 -0
  454. package/dist/razer/devices.js.map +1 -0
  455. package/dist/razer/index.d.ts +2 -0
  456. package/dist/razer/index.d.ts.map +1 -0
  457. package/dist/razer/index.js +2 -0
  458. package/dist/razer/index.js.map +1 -0
  459. package/dist/razer/v4.d.ts +19 -0
  460. package/dist/razer/v4.d.ts.map +1 -0
  461. package/dist/razer/v4.js +51 -0
  462. package/dist/razer/v4.js.map +1 -0
  463. package/dist/steelseries/aerox3.d.ts +236 -0
  464. package/dist/steelseries/aerox3.d.ts.map +1 -0
  465. package/dist/steelseries/aerox3.js +311 -0
  466. package/dist/steelseries/aerox3.js.map +1 -0
  467. package/dist/steelseries/aerox5-wireless.d.ts +300 -0
  468. package/dist/steelseries/aerox5-wireless.d.ts.map +1 -0
  469. package/dist/steelseries/aerox5-wireless.js +389 -0
  470. package/dist/steelseries/aerox5-wireless.js.map +1 -0
  471. package/dist/steelseries/aerox5.d.ts +228 -0
  472. package/dist/steelseries/aerox5.d.ts.map +1 -0
  473. package/dist/steelseries/aerox5.js +294 -0
  474. package/dist/steelseries/aerox5.js.map +1 -0
  475. package/dist/steelseries/aerox9-wireless.d.ts +213 -0
  476. package/dist/steelseries/aerox9-wireless.d.ts.map +1 -0
  477. package/dist/steelseries/aerox9-wireless.js +305 -0
  478. package/dist/steelseries/aerox9-wireless.js.map +1 -0
  479. package/dist/steelseries/devices.d.ts +82 -0
  480. package/dist/steelseries/devices.d.ts.map +1 -0
  481. package/dist/steelseries/devices.js +310 -0
  482. package/dist/steelseries/devices.js.map +1 -0
  483. package/dist/steelseries/index.d.ts +13 -0
  484. package/dist/steelseries/index.d.ts.map +1 -0
  485. package/dist/steelseries/index.js +13 -0
  486. package/dist/steelseries/index.js.map +1 -0
  487. package/dist/steelseries/prime-mini-wireless.d.ts +253 -0
  488. package/dist/steelseries/prime-mini-wireless.d.ts.map +1 -0
  489. package/dist/steelseries/prime-mini-wireless.js +351 -0
  490. package/dist/steelseries/prime-mini-wireless.js.map +1 -0
  491. package/dist/steelseries/prime-plus.d.ts +179 -0
  492. package/dist/steelseries/prime-plus.d.ts.map +1 -0
  493. package/dist/steelseries/prime-plus.js +275 -0
  494. package/dist/steelseries/prime-plus.js.map +1 -0
  495. package/dist/steelseries/rival3-wireless.d.ts +222 -0
  496. package/dist/steelseries/rival3-wireless.d.ts.map +1 -0
  497. package/dist/steelseries/rival3-wireless.js +308 -0
  498. package/dist/steelseries/rival3-wireless.js.map +1 -0
  499. package/dist/steelseries/rival3.d.ts +99 -0
  500. package/dist/steelseries/rival3.d.ts.map +1 -0
  501. package/dist/steelseries/rival3.js +147 -0
  502. package/dist/steelseries/rival3.js.map +1 -0
  503. package/dist/steelseries/rival310.d.ts +230 -0
  504. package/dist/steelseries/rival310.d.ts.map +1 -0
  505. package/dist/steelseries/rival310.js +322 -0
  506. package/dist/steelseries/rival310.js.map +1 -0
  507. package/dist/steelseries/rival650.d.ts +215 -0
  508. package/dist/steelseries/rival650.d.ts.map +1 -0
  509. package/dist/steelseries/rival650.js +284 -0
  510. package/dist/steelseries/rival650.js.map +1 -0
  511. package/dist/steelseries/sensei-ten.d.ts +283 -0
  512. package/dist/steelseries/sensei-ten.d.ts.map +1 -0
  513. package/dist/steelseries/sensei-ten.js +389 -0
  514. package/dist/steelseries/sensei-ten.js.map +1 -0
  515. package/dist/teevolution/index.d.ts +211 -0
  516. package/dist/teevolution/index.d.ts.map +1 -0
  517. package/dist/teevolution/index.js +458 -0
  518. package/dist/teevolution/index.js.map +1 -0
  519. package/dist/valkyrie/index.d.ts +19 -0
  520. package/dist/valkyrie/index.d.ts.map +1 -0
  521. package/dist/valkyrie/index.js +43 -0
  522. package/dist/valkyrie/index.js.map +1 -0
  523. package/dist/valkyrie/settings.d.ts +28 -0
  524. package/dist/valkyrie/settings.d.ts.map +1 -0
  525. package/dist/valkyrie/settings.js +84 -0
  526. package/dist/valkyrie/settings.js.map +1 -0
  527. package/dist/vgn/index.d.ts +59 -0
  528. package/dist/vgn/index.d.ts.map +1 -0
  529. package/dist/vgn/index.js +165 -0
  530. package/dist/vgn/index.js.map +1 -0
  531. package/dist/wallhack/index.d.ts +165 -0
  532. package/dist/wallhack/index.d.ts.map +1 -0
  533. package/dist/wallhack/index.js +226 -0
  534. package/dist/wallhack/index.js.map +1 -0
  535. package/dist/wlmouse/index.d.ts +4 -0
  536. package/dist/wlmouse/index.d.ts.map +1 -0
  537. package/dist/wlmouse/index.js +7 -0
  538. package/dist/wlmouse/index.js.map +1 -0
  539. package/dist/wooting/index.d.ts +225 -0
  540. package/dist/wooting/index.d.ts.map +1 -0
  541. package/dist/wooting/index.js +296 -0
  542. package/dist/wooting/index.js.map +1 -0
  543. package/dist/zaunkoenig/index.d.ts +49 -0
  544. package/dist/zaunkoenig/index.d.ts.map +1 -0
  545. package/dist/zaunkoenig/index.js +127 -0
  546. package/dist/zaunkoenig/index.js.map +1 -0
  547. 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