Przeglądaj źródła

Shut off and LED brightness commands now work

Jariullah Safi 4 miesięcy temu
rodzic
commit
bf49c5f0f9

+ 3 - 0
.gitignore

@@ -2,3 +2,6 @@
 .idea
 *.log
 tmp/
+# pairtui helper binary (build from scmd.c: clang -framework IOKit -framework CoreFoundation -o scmd scmd.c)
+pairtui/scmd
+__pycache__/

+ 46 - 7
OpenPuck/haptics.cpp

@@ -2,15 +2,29 @@
 #include "bonds.h"
 #include "config.h"
 #include "rf_link.h"
+#include <Adafruit_TinyUSB.h>   // USBDevice.suspended() -> autonomous controller power-off on host sleep
 #include <Arduino.h>
 #include <string.h>
 
 uint8_t          g_relayOp  = 0xE3;   // E3 poll
-// relay sub-TLV type byte = SET (confirmed on hardware: E3 + [len][05][rid][payload] makes the controller act
-// on it -- haptics buzz + lizard-off lands). GET is 01.
-uint8_t          g_relaySub = 0x05;
+// relay sub-TLV TYPE byte. CONFIRMED from a real puck<->controller sniff: the puck wraps every host command as
+// E3 + [tlvlen][01][rid][innerlen][data] -- type 0x01 (same type as the GET poll's [01][45]; the rid selects
+// the action). The old 0x05 was wrong and, with the dropped innerlen byte, every relayed command was discarded.
+uint8_t          g_relaySub = 0x01;
 volatile uint8_t g_testHaptic = 0;
 volatile uint8_t g_hapticStop = 0;
+unsigned long    g_buzzFloodUntil = 0;   // 10Hz re-init flood active while millis() < this; armed boot/connect/panel
+
+void hapticArmBuzzFlood(){ g_buzzFloodUntil = millis() + HAPTIC_FLOOD_MS; }
+// Controller power-off. CONFIRMED from a real Windows USB capture of the Valve puck (shutoff.pcapng): Steam's
+// "turn off controller" is the single feature-0x01 command 0x9F with payload ASCII "off!" (6F 66 66 21). The
+// PROTEUS dongle forwards host feature reports to the controller verbatim as SET_FR (esb.json host_usb_bridge),
+// so the controller acts on report 0x9F directly -- we relay it the same way (E3 SET sub-TLV). The wire relay is
+// NO-ACK, so send a small burst: a single lost frame must not leave the controller on.
+void hapticSendShutdown(){
+  static const uint8_t OFF[4] = {0x6f,0x66,0x66,0x21};   // "off!"
+  for (uint8_t i=0; i<HAPTIC_SHUTDOWN_SHOTS; i++) relayEnqueue(0x9F, OFF, sizeof OFF);
+}
 unsigned long    g_hapticBlockUntil = 0;   // drop Steam haptics briefly during reconnect settle
 
 static unsigned long g_haptic82Ms = 0;     // millis of last 0x82 haptic OUTPUT relayed (Steam mode)
@@ -113,7 +127,7 @@ static bool haptic82PayloadOn(const uint8_t* p, uint16_t n){
   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
   return false;
 }
-static void hapticCancelPendingOn(){   // void queued 0x82-ON entries (stale Steam haptics across a reconnect)
+static void hapticCancelPendingOn(){   // void queued 0x82-ON + 0x9F-shutdown entries (stale across a reconnect)
   uint32_t pm = __get_PRIMASK(); __disable_irq();
   for(uint8_t i=g_rqTail; i!=g_rqHead; i=rqNext(i)){
     RelayMsg &m = g_rq[i];
@@ -147,15 +161,28 @@ void rfConnFlushRelay(uint8_t ch, uint8_t s1){
     RelayMsg &m = g_rq[g_rqTail];
     if(m.rid){                          // rid 0 = entry voided by hapticCancelPendingOn -> skip, take the next
       uint8_t rl = m.len; if(rl > RELAY_MAXP) rl = RELAY_MAXP;
-      uint8_t p[4+RELAY_MAXP]; p[0]=g_relayOp; p[1]=(uint8_t)(1+rl); p[2]=g_relaySub; p[3]=m.rid;
-      memcpy(p+4, m.data, rl);
+      // On-air sub-TLV format -- CONFIRMED from real puck<->controller sniffs (puck_sniffer). The TYPE byte and
+      // framing differ by report CLASS:
+      //   * actuators / haptics 0x80-0x86  ->  E3 [1+rl][05][rid][data]            (type 05, NO inner-len)
+      //         e.g. haptic on  = e3 04 05 82 01 01 f7
+      //   * config / settings / LED 0x87+  ->  E3 [2+rl][01][rid][innerlen][data]  (type 01, KEEPS the len byte)
+      //         e.g. brightness  = e3 05 01 87 03 2d 63 00 ,  LED = e3 12 01 c1 10 ...
+      // (Earlier OpenPuck sent type 05 + no inner-len for EVERYTHING: haptics worked but every 0x87+ command was
+      // discarded -- the controller read data[0] as the length. A later all-01 attempt fixed those but broke the
+      // 0x82 haptics. Splitting on 0x87 is what the real puck actually does.)
+      uint8_t p[5+RELAY_MAXP], plen;
+      if (m.rid >= 0x87){
+        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);
+      } else {
+        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);
+      }
       hapLogAdd(0xFE, m.rid, m.data, rl);   // log what we actually TX to the controller (slot 0xFE) for the buzz hunt
       g_rqTail = rqNext(g_rqTail);      // copied out -> release the slot before the TX
       // s1 carries a PID distinct from the GET poll that follows (caller cycles it), so the controller's ESB
       // dedup never mistakes the GET for a retransmit of this relay. 80us RX window: the relay is NO-ACK, the
       // controller sends nothing back, so don't burn the full ~1.2ms reply window (that was halving the poll
       // rate during haptics).
-      rfConnTx(ch,s1,p,(uint8_t)(4+rl),80);
+      rfConnTx(ch,s1,p,plen,80);
       return;                           // ONE relay per poll cycle (matches the real puck's pacing)
     }
     g_rqTail = rqNext(g_rqTail);
@@ -188,6 +215,7 @@ void hapticReinit(){
 }
 void hapticInit(){
   g_rqHead = g_rqTail = 0; g_haptic82On=false;
+  hapticArmBuzzFlood();   // flood the buzz-clear for the first HAPTIC_FLOOD_MS (re-armed on each connect below)
   g_hapticBlockUntil = millis() + HAPTIC_RECONNECT_BLOCK_MS;   // boot: block stale Steam 0x82 until link stable
   // NO fabricated stop burst. USB capture proves Steam only ever sends 0x82 [01 01 f7] pulses -- never a
   // zero-gain [01 01 00] "stop". Injecting our invented stop frame (which the real puck never sends) at
@@ -210,6 +238,7 @@ void hapticOnReconnect(){
   // so repeating it can't itself buzz.
   g_reinitAt = millis() + 200u;                               // first reset ~200ms after (re)connect
   g_reinitLeft = HAPTIC_REINIT_SHOTS;                          // then every HAPTIC_REINIT_GAP_MS across the window
+  hapticArmBuzzFlood();                                        // and hammer the buzz-clear at 10Hz for the next 30s
   uint8_t mk=2; hapLogAdd(0xFD, 0xEE, &mk, 1);                 // capture marker: RECONNECT detected (block+reinit armed)
 }
 void hapticTask(){
@@ -224,6 +253,16 @@ void hapticTask(){
     hapticReinit();
     g_reinitAt = (g_reinitLeft && --g_reinitLeft) ? (millis()+HAPTIC_REINIT_GAP_MS) : 0;
   }
+  // Buzz-clear FLOOD: while the armed window is open (boot/connect/panel -> g_buzzFloodUntil) and the link is up,
+  // replay the re-init every HAPTIC_FLOOD_GAP_MS (10Hz) to hammer a stubborn latch a single re-init won't shift.
+  // Auto-stops when the 30s window elapses; re-arm from the WebUSB panel. Gated on link-up (no churn while down).
+  static unsigned long floodMs=0;
+  if ((int32_t)(g_buzzFloodUntil - millis()) > 0 && up && millis()-floodMs >= HAPTIC_FLOOD_GAP_MS){ floodMs=millis(); hapticReinit(); }
+  // Controller power-off on host sleep: send the power-off command (0x9F "off!") the instant the USB bus
+  // suspends, like the real puck. wasSusp starts true so a boot-into-suspended state never false-fires.
+  static bool wasSusp=true; bool susp=USBDevice.suspended();
+  if (susp && !wasSusp) hapticSendShutdown();
+  wasSusp=susp;
   // Steam-mode: host went quiet -> mark the 0x82 stream inactive. Do NOT synthesize a stop: trackpad haptics
   // are one-shot pulses, so firing a 0x82-zero ~HAPTIC_QUIET_MS after a swipe ends is the extra end-of-movement
   // click the real puck doesn't make. Steam forwards its own stop for any sustained haptic.

+ 14 - 0
OpenPuck/haptics.h

@@ -39,6 +39,17 @@
 // during use (a buzz that starts seconds after connect and won't self-clear). Long enough not to fire between
 // rapid in-game haptics; short enough to clear a stuck buzz soon after the user pauses.
 #define HAPTIC_CLEAR_IDLE_MS 1200u
+// "Clear stuck buzz" FLOOD: replay the haptic re-init at this cadence (100ms = 10Hz) for a bounded window. A
+// single re-init doesn't always shift a stubborn latch; hammering it at 10Hz does. The re-init is settings-only
+// (no haptic play, no LED), so flooding it can't itself buzz or flicker. The flood is armed for HAPTIC_FLOOD_MS
+// (30s) at boot and again on every (re)connect -- so the first 30s after the controller connects (when the
+// connect-buzz engages) get hammered automatically -- then it stops. The WebUSB panel (op 0x07) re-arms the
+// same 30s window on demand, for a buzz that shows up later. Gated on link-up so it doesn't churn while down.
+#define HAPTIC_FLOOD_GAP_MS 100u
+#define HAPTIC_FLOOD_MS     30000u
+// Controller power-off: hapticSendShutdown() relays Steam's confirmed "turn off controller" command (feature-0x01
+// cmd 0x9F, payload "off!" -- captured from the real puck). Sent as a small burst because the RF relay is NO-ACK.
+#define HAPTIC_SHUTDOWN_SHOTS 3u
 
 // ---- relay queue (written by puck_hid.cpp, mode_xinput.cpp, serial_console.cpp; drained by rf_link.cpp) ----
 // Enqueue one host->controller report: rid = report/command id, payload = the bytes AFTER [cmd][len] (what
@@ -49,7 +60,10 @@ extern uint8_t          g_relayOp;   // relay frame opcode (E3 poll)
 extern uint8_t          g_relaySub;  // relay sub-TLV type byte = SET
 extern volatile uint8_t g_testHaptic;// 't<n>' injects n test haptics for the buzz hunt
 extern volatile uint8_t g_hapticStop;// pending haptic-STOP frames to relay (kill a latched whine)
+extern unsigned long    g_buzzFloodUntil; // re-init flood active while millis() < this (armed: boot/connect/panel)
 extern unsigned long    g_hapticBlockUntil;
+void hapticArmBuzzFlood();           // (re)arm the 10Hz buzz-clear flood for HAPTIC_FLOOD_MS
+void hapticSendShutdown();           // relay the controller power-off (0x9F "off!"), burst x3 (host-suspend / Steam 0x9F / test button)
 
 // ---- diagnostic capture (compiled in only when OPK_LOG): a ring of recent host->controller commands +
 //      link/TX markers, dumped over WebUSB. No-ops in a production build so call sites vanish. ----

+ 9 - 4
OpenPuck/puck_hid.cpp

@@ -108,11 +108,16 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type, uint8_t con
   uint8_t cmd = b[0], len = (n > 1) ? b[1] : 0;
   const uint8_t *pl = b + 2; uint16_t pln = (n >= 2) ? n - 2 : 0;
   if (cmd >= 0x80 && cmd <= 0x89) g_steamAliveMs = millis();   // any Steam settings/haptic/LED report (incl. the 0x87 lizard-off heartbeat) -> Steam present, forward gamepad, suppress auto-lizard
+  // Capture EVERY feature write (ANY report id, not just 0x01) into the diagnostic ring for the WebUSB capture
+  // view: haptics, LED SET_LED_COLOR, 0x87 settings, and the host power-off frame. The log shows cmd as "rid";
+  // bytes start [cmd][len]...  Only the report-0x01 path below relays to the controller; capture compiles out
+  // in a non-OPK_LOG build.
+  hapLogAdd((uint8_t)slot, cmd, b, n);
+  // Controller power-off: Steam's "turn off controller" is feature-0x01 frame 9F 04 6F 66 66 21 ("off!"),
+  // confirmed from a real puck capture. The feature-0x01 relay below forwards it once; hapticSendShutdown()
+  // bursts it for NO-ACK reliability (the single hook the test button + host-suspend also drive).
+  if (rid == 1 && cmd == 0x9F) hapticSendShutdown();
   if (rid == 1 && n >= 2) {   // report 0x01 = raw passthrough -> queue for RF relay to the controller
-    hapLogAdd((uint8_t)slot, cmd, b, n);   // capture EVERY relayed feature-1 command for the WebUSB capture view
-                                           // (haptics, LED SET_LED_COLOR, 0x87 settings, 0x9F power-off). The log
-                                           // shows cmd as "rid"; bytes start [cmd][len]...  Was gated to 0x80-0x89,
-                                           // which hid exactly the LED/power-off frames we now need to see.
     bool haptic82 = (cmd == 0x82 && len <= pln);
     bool muted = g_resumeMs && millis()-g_resumeMs < POST_RESUME_MUTE_MS;   // see the OUTPUT path: no haptics while Steam can't read 0x45 back
     bool relayOk = hapticRelaySlotOk(slot) && !(haptic82 && (lizardActive() || muted));  // never push haptics to the controller while presenting lizard (Steam isn't reading 0x45 -> would buzz-loop)

+ 12 - 0
OpenPuck/rf_link.cpp

@@ -40,6 +40,8 @@ uint16_t g_pollsps = 0;   // polls/s (GET+relay TXs) last second -- distinguishe
 uint16_t g_pollPeriodUs = 0;   // MEASURED avg us between GET-poll fires (vs intended g_pollUs) -- ground truth
 static uint32_t g_pollDtSum = 0; static uint16_t g_pollDtCnt = 0;
 volatile uint8_t g_linkRssi = 0;   // smoothed |dBm| of the controller's replies (0 = none yet)
+volatile uint8_t g_ctlrStatus[8] = {0};   // last F1 type-2 control/status TLV from the controller (battery/flags live here)
+volatile uint8_t g_ctlrStatusLen = 0;
 
 // ---- internal counters / timers ----
 static uint8_t  g_e3pid = 0;
@@ -197,6 +199,16 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t* payload, uint8_t plen, u
             if(g_active) g_active->onReport45(rep, fresh, tlen);
             lastRep=rep; lastTlen=tlen;
           }
+          else if((ttype==2 || ttype==4) && (size_t)(idx+2)+tlen<=sizeof(rfrx)){
+            // The controller's F1 reply also carries NON-input TLVs that OpenPuck used to discard: type 2 = the
+            // 4-byte control/status field (battery / charge / flags), type 4 = bulk. Battery is in here -- the
+            // puck forwards it to Steam in report 0x7B (OpenPuck currently hardcodes that -> always "full").
+            // Capture them (slot 0xFB) so we can identify the battery byte, and stash type-2 for wiring 0x7B.
+            const uint8_t* v=&rfrx[idx+2];
+            if(ttype==2){ uint8_t m=tlen>8?8:tlen; for(uint8_t i=0;i<m;i++) g_ctlrStatus[i]=v[i]; g_ctlrStatusLen=m; }
+            hapLogAdd(0xFB, ttype, v, tlen);
+            if(g_connVerbose && Serial.availableForWrite()>50){ Serial.printf("  F1.t%u n%u:",ttype,tlen); for(uint8_t i=0;i<tlen && i<16;i++)Serial.printf(" %02X",v[i]); Serial.println(); }
+          }
           idx+=tlen+2;
         }
         // mode-switch chord (back4 + face): A=always Steam; B/X/Y=configurable (g_chordBtn[]). Debounced.

+ 5 - 1
OpenPuck/rf_link.h

@@ -47,9 +47,13 @@ extern uint16_t g_pollPeriodUs; // MEASURED avg us between GET-poll fires (compa
 // reply during the poll. Stored as the dBm MAGNITUDE (35 = -35dBm); 0 = no sample yet. puck_hid reports it
 // to Steam in status report 0x7B byte 8 (signed dBm), after subtracting RSSI_DBM_OFFSET.
 extern volatile uint8_t g_linkRssi;
+// Last F1 type-2 control/status TLV the controller sent (battery/charge/flags). Logged (capture slot 0xFB) for
+// discovery; once the battery byte is identified it feeds report 0x7B (today hardcoded -> Steam shows full).
+extern volatile uint8_t g_ctlrStatus[8];
+extern volatile uint8_t g_ctlrStatusLen;
 // dB to subtract from our raw RSSI magnitude before reporting, to match the real puck's close-range -35dBm
 // (compensates the Pro Micro antenna vs Valve's front-end). Tune against one known-distance reading.
-#define RSSI_DBM_OFFSET 20
+#define RSSI_DBM_OFFSET 10
 
 // TX one connected packet [LEN][S1][payload] on channel ch, then RX the reply into rfrx; decodes 0xF1.
 // rxWinUs overrides the reply-wait window (0 = use g_rxWin). Pass a tiny value for NO-ACK relays that expect

+ 8 - 3
OpenPuck/webusb_config.cpp

@@ -70,8 +70,8 @@ void webusbPoll(){
     // process complete commands from the front of buf
     for(;;){
       if(n==0) break;
-      uint8_t op=buf[0]; uint8_t need = (op==0x02)?3 : (op==0x03||op==0x05)?2 : 1;   // 0x05 carries a value byte
-      if(op<0x01 || op>0x07){ // resync: drop one byte
+      uint8_t op=buf[0]; uint8_t need = (op==0x02)?3 : (op==0x03||op==0x05)?2 : (op==0x09)?4 : 1;   // 0x05 carries a value byte; 0x09 carries R,G,B
+      if(op<0x01 || op>0x09){ // resync: drop one byte
         memmove(buf,buf+1,--n); continue;
       }
       if(n<need) break;                      // wait for more bytes
@@ -80,7 +80,12 @@ void webusbPoll(){
       else if(op==0x05){ hapLogResetDrain(buf[1]!=0); }   // rewind drain: buf[1]=1 from boot (whole ring), 0 from now
       else if(op==0x06){ webusbDrainCapture(); }          // drain entries since the rewind (panel polls this)
 #endif
-      else if(op==0x07){ hapticReinit(); }                // clear a stuck/latched haptic buzz on the controller (functional)
+      else if(op==0x07){ hapticArmBuzzFlood(); hapticReinit(); }   // (re)arm the 30s 10Hz buzz-clear flood; fire one now for instant feedback
+      else if(op==0x08){ hapticSendShutdown(); }                   // TEST: trigger the controller power-off attempt (same path host-suspend uses)
+      else if(op==0x09){                                           // set LED color: feature-0x01 cmd 0xC1, 16-byte RGBW payload (from led-set capture)
+        uint8_t led[16]={buf[1],buf[2],buf[3],0xff, 0x03,0x09,0x05, 0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff};   // [R][G][B][W=ff][03 09 05][ff x9] -- captured frame, RGB substituted
+        relayEnqueue(0xC1, led, sizeof led);
+      }
       else if(op==0x02){
         uint8_t f=buf[1], v=buf[2];
         bool persist=true;   // every settable field persists (poll rate is no longer settable)

+ 30 - 10
docs/PROTOCOL.md

@@ -78,22 +78,42 @@ Returns:
 
 ### 3.3 Host-to-controller relay
 
-When Steam writes feature report `0x01`, OpenPuck forwards it over RF to the controller as an `E3` frame containing a SET sub-TLV:
+When Steam writes feature report `0x01`, OpenPuck forwards it over RF to the controller as an `E3` frame
+containing a sub-TLV. **CONFIRMED from a passive sniff of a real puck↔controller session** (`puck_sniffer`,
+`docs/sniffer.html`):
 
 ```text
-[E3][sub_len][0x05][report_id][payload...]
+[E3][tlvlen][0x01][report_id][innerlen][data...]
 ```
 
-`0x05` is the confirmed SET subtype. `0x01` is the GET subtype.
+- `tlvlen` counts the value after it = `report_id` + `innerlen` + `data` = `2 + len(data)`.
+- The type byte is **`0x01`** — the *same* type as the GET poll (`E3 02 01 45 <param>`); the `report_id`
+  selects the action. `innerlen` is the report's own declared length (the `[len]` byte of the feature payload).
+- `[report_id][innerlen][data]` is exactly the inner `[cmd][len][payload]` Steam wrote to feature `0x01`,
+  forwarded intact.
 
-This is how settings writes, haptics, and lizard-disable requests reach the controller.
+Confirmed examples from the sniff:
 
-The relay carries the command's **declared length** (the `[len]` byte of the feature payload), up to 60 bytes —
-the most one RF frame fits (`[E3][len][05][rid]` + payload ≤ MAXLEN 64). Multi-register `0x87` settings blocks
-(e.g. LED brightness) and calibration writes exceed 18 bytes, so any shorter cap silently corrupts them and the
-settings never land on the controller. Relays are staged in a small ring (not a single buffer): the USB SET
-callbacks run in ISR context and Steam sends settings/calibration as back-to-back bursts, so a single pending
-slot both drops reports and can be torn mid-flush. One queued relay is emitted per poll cycle.
+```text
+brightness   E3 05 01 87 03 2D <val> 00      (report 0x87, reg 0x2D)
+LED color    E3 12 01 C1 10 <R G B W> 03 09 05 FF*9   (+ E3 04 01 DC 02 01 02, E3 04 01 E2 02 01 20)
+haptic re-init E3 02 01 81 00 ,  E3 11 01 87 0F 30 …
+```
+
+This is how settings writes, haptics, LED, lizard-disable, and the controller power-off reach the controller.
+**Earlier OpenPuck builds used `[E3][len][0x05][rid][data]` — wrong type byte AND the `innerlen` was dropped, so
+the controller read the first data byte as the length and discarded every relayed command (LED / brightness /
+power-off all silently failed). Fixed to the form above.**
+
+**Controller power-off**: Steam's "turn off controller" is the feature-`0x01` command `9F 04 6F 66 66 21` —
+`cmd 0x9F`, `len 4`, payload `6F 66 66 21` = ASCII `"off!"`. Relayed (correctly framed) as
+`E3 06 01 9F 04 6F 66 66 21`. *(The exact RF frame still needs a clean sniff — it's a one-shot and the lossy
+sniffer missed it in the first capture; the framing above is the format every other command follows.)*
+
+The relay carries the command's declared length, up to 60 bytes — the most one RF frame fits
+(`[E3][tlvlen][01][rid][innerlen]` + payload ≤ MAXLEN 64). Relays are staged in a small ring (not a single
+buffer): the USB SET callbacks run in ISR context and Steam sends settings/calibration as back-to-back bursts,
+so a single pending slot both drops reports and can be torn mid-flush. One queued relay is emitted per poll cycle.
 
 ### 3.4 Connection presentation (input reports `0x79` / `0x7B`)
 

+ 11 - 2
docs/index.html

@@ -89,7 +89,13 @@
       </div>
       <p class="note logonly">Polls/s ≈ 250 expected; it's capped by Loop period (≈4000 µs needed). "Slowest stage" is the loop section eating the most time per iteration.</p>
       <div class="row" style="margin:10px 0 0"><button id="hapClear">Clear stuck buzz</button>
-        <span class="note" style="flex:1">If the controller's haptics get stuck/buzzing, this re-inits them (same as pressing Steam).</span>
+        <span class="note" style="flex:1">If the controller's haptics get stuck/buzzing, this floods the re-init (same as pressing Steam) at 10&nbsp;Hz for 30&nbsp;s. It also runs automatically for 30&nbsp;s on connect; click to re-arm if a buzz shows up later.</span>
+      </div>
+      <div class="row" style="margin:10px 0 0"><button id="ctlrOff">Turn off controller (test)</button>
+        <span class="note" style="flex:1">Fires the controller power-off (0x9F "off!", x3) — the same path host-suspend uses. Watch the capture / link status to see if the controller actually disconnects.</span>
+      </div>
+      <div class="row" style="margin:10px 0 0"><input type="color" id="ledColor" value="#ffffff"><button id="ledSet">Set LED color</button>
+        <span class="note" style="flex:1">Sends the controller LED-color command (0xC1 RGBW). Brightness is separate (Steam's 0x87 reg 2d).</span>
       </div>
     </div>
 
@@ -320,7 +326,10 @@ $("#connectBtn").onclick=connect;
 $("#capDl").onclick=downloadCap;
 $("#capStart").onclick=startCapture;
 $("#capStop").onclick=stopCapture;
-$("#hapClear").onclick=async()=>{ if(dev){ await send([0x07]); log("haptic re-init sent (clear stuck buzz)"); } };
+let buzzFloodTimer=null;
+$("#hapClear").onclick=async()=>{ if(dev){ await send([0x07]); $("#hapClear").classList.add("active"); clearTimeout(buzzFloodTimer); buzzFloodTimer=setTimeout(()=>$("#hapClear").classList.remove("active"),30000); log("buzz-clear flood armed — 10Hz re-init for 30s (click again to restart)"); } };
+$("#ctlrOff").onclick=async()=>{ if(dev){ await send([0x08]); log("controller power-off attempt sent — watch link status / capture"); } };
+$("#ledSet").onclick=async()=>{ if(dev){ const h=$("#ledColor").value; const r=parseInt(h.substr(1,2),16),g=parseInt(h.substr(3,2),16),b=parseInt(h.substr(5,2),16); await send([0x09,r,g,b]); log(`LED color set ${h} (r${r} g${g} b${b})`); } };
 $("#abSwap").onclick=()=>{ const on=$("#abSwap").classList.contains("active"); setField(4, on?0:1); };
 $("#persistMode").onclick=()=>{ const on=$("#persistMode").classList.contains("active"); setField(16, on?0:1); };
 for(const id of ["mDiv","mFric"]){