haptics.cpp 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. #include "haptics.h"
  2. #include "bonds.h"
  3. #include "config.h"
  4. #include "rf_link.h"
  5. #include <Adafruit_TinyUSB.h> // USBDevice.suspended() -> autonomous controller power-off on host sleep
  6. #include <Arduino.h>
  7. #include <string.h>
  8. uint8_t g_relayOp = 0xE3; // E3 poll
  9. // relay sub-TLV TYPE byte. Vestigial now: rfConnFlushRelay derives the on-air type from the report id (see the
  10. // CONFIRMED split there -- 0x05 for actuators/haptics <0x87, 0x01 for config/settings/LED >=0x87). Still exposed
  11. // over WebUSB (relaySub field) for the buzz-hunt diagnostics, but the flush path no longer reads it.
  12. uint8_t g_relaySub = 0x05;
  13. volatile uint8_t g_testHaptic = 0;
  14. volatile uint8_t g_hapticStop = 0;
  15. unsigned long g_hapticBlockUntil = 0; // drop Steam haptics briefly during reconnect settle
  16. // Controller power-off. CONFIRMED from a real Windows USB capture of the Valve puck (shutoff.pcapng): Steam's
  17. // "turn off controller" is the single feature-0x01 command 0x9F with payload ASCII "off!" (6F 66 66 21). The
  18. // dongle forwards host feature reports to the controller verbatim, so the controller acts on report 0x9F
  19. // directly -- we relay it the same way (E3 SET sub-TLV). The wire relay is NO-ACK, so send a small burst: a
  20. // single lost frame must not leave the controller on. Driven by the Steam 0x9F passthrough (puck_hid), the
  21. // host-suspend edge (hapticTask), and the WebUSB test button (op 0x08).
  22. void hapticSendShutdown(){
  23. static const uint8_t OFF[4] = {0x6f,0x66,0x66,0x21}; // "off!"
  24. for (uint8_t i=0; i<HAPTIC_SHUTDOWN_SHOTS; i++) relayEnqueue(0x9F, OFF, sizeof OFF);
  25. }
  26. static unsigned long g_haptic82Ms = 0; // millis of last 0x82 haptic OUTPUT relayed (Steam mode)
  27. static bool g_haptic82On = false; // a non-zero 0x82 haptic is currently active (awaiting host stop)
  28. static unsigned long g_rumble80Ms = 0; // millis of last translated host rumble (0x80)
  29. static bool g_rumble80On = false; // Steam/Triton rumble is latched on until an explicit zero report
  30. static unsigned long g_reinitAt = 0; // when to fire the next post-reconnect haptic re-init (0 = none scheduled)
  31. static uint8_t g_reinitLeft = 0; // how many re-init shots remain in this connect window
  32. static bool g_hapClearArmed=false;// haptic activity happened -> arm a clear once it goes idle (catches a
  33. // latch that engaged during/after use, even seconds after connect)
  34. // ---- relay ring: multi-producer (USB ISR + loop-context console/xinput), single consumer (poll flush) ----
  35. // Producers serialize through a brief PRIMASK critical section (copy is <=62 bytes); the consumer only ever
  36. // touches the tail entry, which no producer writes while the ring isn't full -- so a flush can't be torn.
  37. struct RelayMsg { uint8_t rid, len; uint8_t data[RELAY_MAXP]; };
  38. #define RELAY_QLEN 32 // deep enough to hold a full Steam settings/LED transaction burst without loss
  39. static RelayMsg g_rq[RELAY_QLEN];
  40. static volatile uint8_t g_rqHead = 0, g_rqTail = 0; // head=next write, tail=next read; empty when equal
  41. static inline uint8_t rqNext(uint8_t i){ return (uint8_t)((i+1)%RELAY_QLEN); }
  42. bool relayPending(){ return g_rqHead != g_rqTail; }
  43. bool relayEnqueue(uint8_t rid, const uint8_t* payload, uint8_t plen){
  44. if (plen > RELAY_MAXP) plen = RELAY_MAXP; // 60B is the RF frame ceiling; longer can't be relayed
  45. uint32_t pm = __get_PRIMASK(); __disable_irq();
  46. uint8_t h = g_rqHead, nx = rqNext(h);
  47. // Full -> evict the OLDEST entry, never the newest. Steam sends commands as bursts where the meaningful
  48. // frame comes LAST (a settings transaction ends with its commit; a haptic stream ends with its stop). The
  49. // old drop-newest policy threw away exactly those, so LED commits and haptic stops were the casualties --
  50. // brightness never applied and a glide's final stop never reached the controller (stuck buzz). Dropping the
  51. // oldest instead guarantees the most recent command always lands.
  52. if (nx == g_rqTail) g_rqTail = rqNext(g_rqTail);
  53. g_rq[h].rid = rid; g_rq[h].len = plen;
  54. if (plen) memcpy(g_rq[h].data, payload, plen);
  55. g_rqHead = nx;
  56. // Any haptic relay (Steam OR Xbox rumble OR test) arms the idle-clear and refreshes its timer -- so the
  57. // during-use buzz gets cleared in EVERY USB mode, not just Steam. The re-init's own 0x81/0x87 don't match.
  58. if (rid==0x82 || rid==0x80){ g_haptic82Ms = millis(); g_hapClearArmed = true; }
  59. __set_PRIMASK(pm); return true;
  60. }
  61. #if OPK_LOG
  62. // diagnostic capture: a ring of the last OUTPUT reports Steam sends (rid/slot/bytes/ms), dumped with 'H'.
  63. // Reproduce the whine, press Steam to stop it, then 'H' to see the ON stream + the exact OFF frame Steam sends.
  64. struct HapLog { uint32_t ms; uint8_t slot, rid, n, b[16]; }; // 16 payload bytes: capture full 0x87 settings frames
  65. // Big always-on ring: log EVERYTHING from boot (Steam writes, our TX-to-controller, link edges) so a rare
  66. // reconnect-buzz can be caught after the fact -- the trigger happens moments after the puck boots, while this
  67. // RAM is fresh, and we dump it once the panel reconnects. 4096 * 20B ~= 80KB (we have plenty of free RAM).
  68. #define HAPLOG_N 4096
  69. static HapLog g_hapLog[HAPLOG_N];
  70. static uint16_t g_hapHead = 0;
  71. static uint16_t g_hapTail = 0; // live/dump drain cursor (loop-context reader; chases g_hapHead)
  72. void hapLogAdd(uint8_t slot, uint8_t rid, const uint8_t* b, uint16_t n){
  73. // Written from the USB SET ISR (handleSet) AND loop context (relay flush / link edges) -> guard g_hapHead.
  74. // Special slot markers for the diagnostic capture: 0xFE = a frame WE transmitted to the controller (TX
  75. // relay); 0xFD = a link state edge (b[0]=1 up, 0 down). Real Steam writes use the interface index (0..3).
  76. uint32_t pm = __get_PRIMASK(); __disable_irq();
  77. HapLog &e = g_hapLog[g_hapHead]; e.ms = millis(); e.slot = slot; e.rid = rid; e.n = (uint8_t)(n>255?255:n);
  78. for(int i=0;i<16;i++) e.b[i] = (i<(int)n) ? b[i] : 0;
  79. g_hapHead = (uint8_t)((g_hapHead+1) % (sizeof g_hapLog / sizeof g_hapLog[0]));
  80. __set_PRIMASK(pm);
  81. }
  82. void hapticDumpLog(){ // dump the captured history (oldest->newest) over CDC for the haptic-whine hunt
  83. const uint16_t N=HAPLOG_N; uint32_t now=millis();
  84. Serial.printf("# --- capture history (now=%lu, connSlot=%d) ---\n",(unsigned long)now,g_connSlot);
  85. for(uint16_t i=0;i<N;i++){ HapLog &e=g_hapLog[(uint16_t)((g_hapHead+i)%N)]; if(!e.ms && !e.rid) continue;
  86. Serial.printf("# -%lums if%u rid=%02X n=%u:",(unsigned long)(now-e.ms),e.slot,e.rid,e.n);
  87. for(uint8_t j=0;j<16 && j<e.n;j++) Serial.printf(" %02X",e.b[j]); Serial.println(); }
  88. Serial.println("# --- end ---");
  89. }
  90. // ---- drain cursor: stream entries to the WebUSB panel. resetDrain(false)=from "now" (live only);
  91. // resetDrain(true)=from the OLDEST entry (dump the whole ring from boot). pull skips empty slots. ----
  92. void hapLogResetDrain(bool fromBoot){
  93. g_hapTail = fromBoot ? (uint16_t)((g_hapHead+1)%HAPLOG_N) // oldest slot (the next-to-overwrite holds it)
  94. : g_hapHead; // "now"
  95. }
  96. bool hapLogPull(uint32_t* logMs, uint8_t* slot, uint8_t* rid, uint8_t* n, uint8_t bytes16[16]){
  97. while(g_hapTail != g_hapHead){
  98. HapLog &e=g_hapLog[g_hapTail];
  99. g_hapTail=(uint16_t)((g_hapTail+1)%HAPLOG_N);
  100. if(!e.ms && !e.rid) continue; // skip empty slot (ring not yet full)
  101. // Return the ABSOLUTE log time (millis since boot), not "age now". The panel drains in batches 100ms
  102. // apart, so an age computed here would jump between batches; the panel computes age vs the newest entry.
  103. *logMs=e.ms; *slot=e.slot; *rid=e.rid; *n=(e.n>16)?16:e.n; memcpy(bytes16,e.b,16);
  104. return true;
  105. }
  106. return false;
  107. }
  108. #endif // OPK_LOG
  109. bool hapticLinkUp(){
  110. return g_connSlot>=0 && (millis()-g_connReplyMs) < 300;
  111. }
  112. bool haptic82Blocked(){
  113. return !hapticLinkUp() || (g_hapticBlockUntil && (int32_t)(millis()-g_hapticBlockUntil) < 0);
  114. }
  115. bool hapticRelaySlotOk(int slot){
  116. return g_connSlot>=0 && slot==g_connSlot;
  117. }
  118. static bool haptic82PayloadOn(const uint8_t* p, uint16_t n){
  119. if(n<3) return false;
  120. for(uint16_t i=2;i<n;i++) if(p[i]) return true; // observed form is [01 01 gain], but treat any trailing non-zero as active
  121. return false;
  122. }
  123. static void hapticCancelPendingOn(){ // void queued ON entries (stale Steam haptics / translated rumble across a reconnect)
  124. uint32_t pm = __get_PRIMASK(); __disable_irq();
  125. for(uint8_t i=g_rqTail; i!=g_rqHead; i=rqNext(i)){
  126. RelayMsg &m = g_rq[i];
  127. if(m.rid==0x82){ bool on=false; for(uint8_t j=2;j<m.len;j++) if(m.data[j]){on=true;break;} if(on) m.rid=0; }
  128. if(m.rid==0x80){ bool on=false; for(uint8_t j=0;j<m.len;j++) if(m.data[j]){on=true;break;} if(on) m.rid=0; }
  129. }
  130. __set_PRIMASK(pm);
  131. }
  132. void haptic82HostReport(const uint8_t* p, uint16_t n){
  133. if(n<3) return;
  134. g_haptic82Ms = millis();
  135. g_hapClearArmed = true; // any haptic activity arms a clear when it next goes idle (kills a latch from this use)
  136. // Track on/off only. Do NOT synthesize a stop burst when Steam's own stop arrives: that stop is already
  137. // forwarded verbatim, so adding 0x82-zero frames on top just makes the controller see the stop several times
  138. // over. Each 0x82 is a discrete pad click, so the extra frames are exactly the spurious end-of-movement
  139. // "click"/buzz that the real puck never produces. (Connect-time clearing still runs in hapticTask().)
  140. g_haptic82On = haptic82PayloadOn(p,n);
  141. }
  142. bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq){
  143. if(g_rumbleScale!=100){ // user rumble-strength scale (percent; 200 = double). Clamp to 16-bit.
  144. uint32_t l=(uint32_t)lowFreq*g_rumbleScale/100, h=(uint32_t)highFreq*g_rumbleScale/100;
  145. lowFreq=(l>0xFFFF)?0xFFFF:(uint16_t)l; highFreq=(h>0xFFFF)?0xFFFF:(uint16_t)h;
  146. }
  147. bool on = lowFreq || highFreq;
  148. if(on && haptic82Blocked()) return false; // same settle gate as native Steam haptics
  149. if(!on && !hapticLinkUp()) return false;
  150. // SDL's current Steam/Triton structs define output report 0x80 as:
  151. // type, uint16 intensity, {uint16 speed, int8 gain} left/right.
  152. // We map conventional gamepad low/high-frequency motors to left/right speeds and use max as intensity.
  153. uint16_t intensity = lowFreq > highFreq ? lowFreq : highFreq;
  154. uint8_t p[9];
  155. p[0] = on ? 0x04 : 0x00; // haptic_type_t::HAPTIC_TYPE_RUMBLE; 0 is the off/zero report
  156. p[1] = (uint8_t)(intensity & 0xFF); p[2] = (uint8_t)(intensity >> 8);
  157. p[3] = (uint8_t)(lowFreq & 0xFF); p[4] = (uint8_t)(lowFreq >> 8); p[5] = 0;
  158. p[6] = (uint8_t)(highFreq & 0xFF); p[7] = (uint8_t)(highFreq >> 8); p[8] = 0;
  159. if(!relayEnqueue(0x80, p, sizeof p)) return false;
  160. g_rumble80Ms = millis();
  161. g_rumble80On = on;
  162. return true;
  163. }
  164. // Queue a pending test-haptic / stop relay (runs inside the poll cadence -- never at raw loop rate).
  165. void rfConnQueueHapticRelay(){
  166. if(relayPending()) return; // host relays first; injectables wait for an idle cycle
  167. static const uint8_t HAP_ON[3] = {0x01,0x01,0xF7};
  168. static const uint8_t HAP_OFF[3] = {0x01,0x01,0x00};
  169. if(g_testHaptic){
  170. if(relayEnqueue(0x82, HAP_ON, 3)) g_testHaptic--;
  171. } else if(g_hapticStop && !g_xbox){
  172. if(relayEnqueue(0x82, HAP_OFF, 3)) g_hapticStop--;
  173. }
  174. }
  175. void rfConnFlushRelay(uint8_t ch, uint8_t s1){
  176. while(g_rqTail != g_rqHead){
  177. RelayMsg &m = g_rq[g_rqTail];
  178. if(m.rid){ // rid 0 = entry voided by hapticCancelPendingOn -> skip, take the next
  179. uint8_t rl = m.len; if(rl > RELAY_MAXP) rl = RELAY_MAXP;
  180. // On-air sub-TLV framing. CONFIRMED from real puck<->controller sniffs (puck_sniffer): a command LANDS on
  181. // the controller only with the type-01 + inner-len form E3 [2+rl][01][rid][innerlen][data]; the legacy
  182. // form E3 [1+rl][05][rid][data] (no inner-len) is what the long-working build shipped, and the controller
  183. // DISCARDS any 0x87+ command sent that way (it reads data[0] as the length).
  184. //
  185. // We WHITELIST the landing form to EXACTLY the two commands that need it -- and nothing else:
  186. // * LED brightness -- report 0x87 whose first register byte is 0x2D
  187. // * controller power-off -- report 0x9F ("off!")
  188. // Everything else keeps the legacy form. This is the crux: Steam ALSO sends 0x87 passthrough writes during
  189. // normal play -- the haptic-config block (reg 0x30 = IMU/subsystem enable, 0x34/0x35 = haptic amplitude). On
  190. // the legacy framing the controller discards them, which is WHY the gyro keeps streaming (a landed 0x30
  191. // FREEZES the IMU) and the haptics stay quiet (landed 0x34/0x35 = the connect buzz). Landing ALL 0x87 (the
  192. // naive "split on >=0x87") regresses BOTH: stuck gyro + buzz. So keep this list tight.
  193. bool land01 = (m.rid==0x9F) || (m.rid==0x87 && rl>=1 && m.data[0]==0x2D);
  194. uint8_t p[5+RELAY_MAXP], plen;
  195. if (land01){
  196. p[0]=g_relayOp; p[1]=(uint8_t)(2+rl); p[2]=0x01; p[3]=m.rid; p[4]=rl; memcpy(p+5, m.data, rl); plen=(uint8_t)(5+rl);
  197. } else {
  198. p[0]=g_relayOp; p[1]=(uint8_t)(1+rl); p[2]=0x05; p[3]=m.rid; memcpy(p+4, m.data, rl); plen=(uint8_t)(4+rl);
  199. }
  200. hapLogAdd(0xFE, m.rid, m.data, rl); // log what we actually TX to the controller (slot 0xFE) for the buzz hunt
  201. g_rqTail = rqNext(g_rqTail); // copied out -> release the slot before the TX
  202. // s1 carries a PID distinct from the GET poll that follows (caller cycles it), so the controller's ESB
  203. // dedup never mistakes the GET for a retransmit of this relay. 80us RX window: the relay is NO-ACK, the
  204. // controller sends nothing back, so don't burn the full ~1.2ms reply window (that was halving the poll
  205. // rate during haptics).
  206. rfConnTx(ch,s1,p,plen,80);
  207. return; // ONE relay per poll cycle (matches the real puck's pacing)
  208. }
  209. g_rqTail = rqNext(g_rqTail);
  210. }
  211. }
  212. // Haptic-subsystem RE-INIT: the exact sequence Steam sends (captured on hardware) when it (re)takes control,
  213. // and which clears a stuck haptic script on the controller -- a 0x81 reset action plus 0x87 writes to the
  214. // haptic registers (30/07/08/31/52, 18/2e/34/35). We replay it to recover from the latched-buzz the
  215. // controller falls into across a reconnect. Brightness (0x87 reg 2d) is deliberately OMITTED so we don't
  216. // stomp the LED. Enqueued onto the normal relay (drains in the poll cadence).
  217. //
  218. // NOTE: the 0x87 frames below go out on the LEGACY (type-05) framing -- the relay whitelist only lands 0x87
  219. // when its first register is brightness 0x2D -- so the controller DISCARDS them, exactly as on the long-working
  220. // build. They are kept verbatim so this path matches that build byte-for-byte; the effective re-init is the
  221. // three 0x81 frames, which is what cleared the buzz there. (Landing the 0x30 here would freeze the gyro.)
  222. void hapticReinit(){
  223. static const uint8_t H30[]={0x30,0x00,0x00,0x07,0x07,0x00,0x08,0x07,0x00,0x31,0x02,0x00,0x52,0x03,0x00};
  224. static const uint8_t H18[]={0x18,0x00,0x00,0x2e,0x00,0x00,0x34,0xff,0xff,0x35,0xff,0xff,0x34,0xff,0xff};
  225. static const uint8_t H35[]={0x35,0xff,0xff,0x2e,0x00,0x00};
  226. static const uint8_t T81A[]={0x00,0x00,0x00,0x00,0x00,0x00,0x00};
  227. static const uint8_t T81B[]={0x01,0x00,0x00,0x00,0x00,0x00,0x00};
  228. relayEnqueue(0x81, nullptr, 0); // reset action (FUN_0001f554) -- Steam sends this first
  229. relayEnqueue(0x87, H30, sizeof H30);
  230. relayEnqueue(0x87, H18, sizeof H18); // haptic config (enabled/amplifier/gain): the part that clears a latch
  231. relayEnqueue(0x87, H35, sizeof H35);
  232. relayEnqueue(0x81, T81A, sizeof T81A);
  233. relayEnqueue(0x81, T81B, sizeof T81B);
  234. }
  235. void hapticInit(){
  236. g_rqHead = g_rqTail = 0; g_haptic82On=false; g_rumble80On=false;
  237. g_hapticBlockUntil = millis() + HAPTIC_RECONNECT_BLOCK_MS; // boot: block stale Steam 0x82 until link stable
  238. // NO fabricated stop burst. USB capture proves Steam only ever sends 0x82 [01 01 f7] pulses -- never a
  239. // zero-gain [01 01 00] "stop". Injecting our invented stop frame (which the real puck never sends) at
  240. // boot/connect is unfaithful and, given the controller's "haptic script already active -> ignore" behavior,
  241. // a likelier cause of the connect-time buzz than a cure (the buzz happened WITH this burst in place). Be a
  242. // pure pass-through of Steam's haptics, like the real puck.
  243. g_hapticStop = 0;
  244. }
  245. // Arm the post-(re)connect haptic block + schedule the clearing re-init. Called from rf_link the moment a
  246. // controller reply arrives after a gap (the reliable reconnect signal), and as a backup on hapticTask's
  247. // link-up edge. Idempotent -- safe to call repeatedly.
  248. void hapticOnReconnect(){
  249. g_hapticBlockUntil = millis() + HAPTIC_RECONNECT_BLOCK_MS; // no haptics relayed for the next 3s
  250. g_haptic82On = false; g_rumble80On = false;
  251. hapticCancelPendingOn(); // drop any haptic ON queued before the link came up
  252. // Be PROACTIVE: start re-initing the haptic engine right after the link is up and repeat across the whole
  253. // settle window, instead of waiting for the block to end. The brief connect buzz engages early (during the
  254. // block, controller-internal), so a single late shot misses it -- frequent early resets cut it short or
  255. // preempt it. The re-init is settings only (no haptic play), and Steam haptics are blocked the whole time,
  256. // so repeating it can't itself buzz.
  257. g_reinitAt = millis() + 200u; // first reset ~200ms after (re)connect
  258. g_reinitLeft = HAPTIC_REINIT_SHOTS; // then every HAPTIC_REINIT_GAP_MS across the window
  259. uint8_t mk=2; hapLogAdd(0xFD, 0xEE, &mk, 1); // capture marker: RECONNECT detected (block+reinit armed)
  260. }
  261. void hapticTask(){
  262. static bool wasHapticLinkUp=false;
  263. bool up=hapticLinkUp();
  264. // Link-edge markers are diagnostic only now -- the block/re-init is armed reliably from rf_link
  265. // (hapticOnReconnect) on the first reply after a gap, which fires even when this 300ms edge doesn't.
  266. if(up && !wasHapticLinkUp){ uint8_t mk=1; hapLogAdd(0xFD,0xEE,&mk,1); hapticOnReconnect(); }
  267. if(!up && wasHapticLinkUp){ uint8_t mk=0; hapLogAdd(0xFD,0xEE,&mk,1); }
  268. wasHapticLinkUp=up;
  269. if(g_reinitAt && up && (int32_t)(millis()-g_reinitAt) >= 0){ // proactive haptic re-init across the connect window
  270. hapticReinit();
  271. g_reinitAt = (g_reinitLeft && --g_reinitLeft) ? (millis()+HAPTIC_REINIT_GAP_MS) : 0;
  272. }
  273. // Controller power-off on host SLEEP: send the power-off command (0x9F "off!") the instant the USB bus
  274. // suspends, like the real puck. BUT only when USB power (VBUS) is still present -- i.e. a genuine host sleep,
  275. // NOT a cable unplug. Pulling the dongle ALSO trips the suspend edge (in the brief window it runs on residual
  276. // power), and we must NOT kill the controller then; it should only power off on a shutdown command or a real
  277. // host sleep. VBUSDETECT is 1 while the cable still delivers 5V, 0 once unplugged. wasSusp starts true so a
  278. // boot-into-suspended state never false-fires.
  279. static bool wasSusp=true; bool susp=USBDevice.suspended();
  280. bool vbus = (NRF_POWER->USBREGSTATUS & POWER_USBREGSTATUS_VBUSDETECT_Msk) != 0;
  281. if (susp && !wasSusp && vbus) hapticSendShutdown();
  282. wasSusp=susp;
  283. // Steam-mode: host went quiet -> mark the 0x82 stream inactive. Do NOT synthesize a stop: trackpad haptics
  284. // are one-shot pulses, so firing a 0x82-zero ~HAPTIC_QUIET_MS after a swipe ends is the extra end-of-movement
  285. // click the real puck doesn't make. Steam forwards its own stop for any sustained haptic.
  286. if (!g_xbox && g_haptic82On && millis()-g_haptic82Ms > HAPTIC_QUIET_MS) g_haptic82On=false;
  287. if (g_rumble80On && millis()-g_rumble80Ms > 2500u) hapticSteamRumble(0, 0);
  288. // Haptic activity has gone idle for a while -> fire one re-init to clear any latch it left behind (the buzz
  289. // that engages during/after use and won't self-clear, incl. after a mode switch). Fires only after a quiet
  290. // gap, so it never interrupts active haptics; the brightness-less re-init is silent (settings, no play, no
  291. // LED). Runs in ALL modes (the controller-side latch is mode-independent).
  292. if (g_hapClearArmed && (millis()-g_haptic82Ms) > HAPTIC_CLEAR_IDLE_MS){ g_hapClearArmed=false; hapticReinit(); }
  293. }