1	15: uint16_t oled_current_brightness = DEFAULT_OLED_BRIGHTNESS;
2	17:+// Doze state machine (ADR-0005). doze_active is the doze-latch: while set it
3	18:+// overrides the prox emission gate (so a doze-while-worn stays dark) and is
4	19:+// force-cleared on video loss (so the headset never stays latched without
5	20:+// video). The brightness fade runs in the stepper inside the video task loop.
6	22:+	DOZE_NONE = 0,	// fully awake, normal prox-gated operation
7	23:+	DOZE_SLEEPING,	// sweeping brightness down to the floor
8	24:+	DOZE_ASLEEP,	// at the floor, OLED display turned off (true black)
9	25:+	DOZE_WAKING		// OLED display back on, sweeping brightness up
10	28:+bool doze_active = false;					// the doze-latch
11	29:+static uint16_t doze_live_brightness = 0;	// brightness currently driven mid-sweep
12	32:+// latch but BEFORE the bringup has settled into DOZE_ASLEEP (it gates the wake
13	34:+// wake in that window used to disarm the latch -> panels came up lit with no
14	50:+// Per-iteration brightness increment for a ~DOZE_SWEEP_MS sweep between the
15	51:+// floor and 'target'. Recomputed at each transition so a user-raised brightness
16	54:+	uint16_t span = (target > DOZE_BRIGHTNESS_FLOOR) ? (target - DOZE_BRIGHTNESS_FLOOR) : 1u;
17	59:+// Map the linear sweep intent (DOZE_BRIGHTNESS_FLOOR..oled_current_brightness)
18	65:+	uint16_t full = oled_current_brightness;
19	66:+	if(full <= DOZE_BRIGHTNESS_FLOOR || linear <= DOZE_BRIGHTNESS_FLOOR)
20	67:+		return DOZE_BRIGHTNESS_FLOOR;
21	69:+	uint16_t span = full - DOZE_BRIGHTNESS_FLOOR;
22	70:+	float frac = (float)(linear - DOZE_BRIGHTNESS_FLOOR) / (float)span;
23	72:+	return (uint16_t)(DOZE_BRIGHTNESS_FLOOR + (uint16_t)(curved * (float)span + 0.5f));
24	75:+// Drive both panels to 'b', retrying the I2C write like the prox dimmers do.
25	76:+static void doze_write_brightness(uint16_t b) {
26	78:+	for(i = 0; i < NUM_BRIGHTNESS_RETRIES; i++) {
27	79:+		if(OLED_set_brightness(OLED_LEFT, b, pulse_emission) == pdPASS) break;
28	81:+	for(i = 0; i < NUM_BRIGHTNESS_RETRIES; i++) {
29	82:+		if(OLED_set_brightness(OLED_RIGHT, b, pulse_emission) == pdPASS) break;
30	93:+		// Doze sleep (ADR-0005): set the doze-latch and begin the fade to the
31	94:+		// floor. The video pipeline stays up (VXR locked, host still presenting);
32	96:+		// looks and sounds asleep. The fade and the display-off at the floor run
33	100:+				// Born-dozing (ADR-0005): arm the latch before video is up. The
34	108:+				// current live value when reversing a wake (no jump-to-bright flash).
35	110:+					doze_live_brightness = oled_current_brightness;
36	111:+				doze_set_step(oled_current_brightness);
37	113:+				// Doze owns the display now; stop the prox 5-minute display-off timer.
38	114:+				xTimerStop(prox_timeout_timer, 10);
39	115:+				prox_timeout_expired = false;
40	116:+				xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_OFF, eSetValueWithOverwrite);
41	117:+				xTaskNotify(get_led_task_handle(), LED_TASK_CODE_BREATHING, eSetBits);
42	125:+		// had reached true-black, then sweep brightness back up and re-couple the
43	126:+		// fan/LED to video. The latch clears when the sweep reaches full (below).
44	137:+				doze_set_step(oled_current_brightness);
45	139:+				xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_ON, eSetValueWithOverwrite);
46	140:+				xTaskNotify(get_led_task_handle(), LED_TASK_CODE_STATIC_COLOR, eSetBits);
47	143:+				// Latch armed for born-dozing but the bringup has not settled into
48	154: 		// Check if we can disable the OLED panels (video was enabled, now it isn't)
49	160:+				// Force-clear the doze-latch on video loss (ADR-0005 safety: the
50	161:+				// headset must never stay latched-dozing without video). Degrades
51

15: uint16_t oled_current_brightness = DEFAULT_OLED_BRIGHTNESS;
17:+// Doze state machine (ADR-0005). doze_active is the doze-latch: while set it
18:+// overrides the prox emission gate (so a doze-while-worn stays dark) and is
19:+// force-cleared on video loss (so the headset never stays latched without
20:+// video). The brightness fade runs in the stepper inside the video task loop.
22:+	DOZE_NONE = 0,	// fully awake, normal prox-gated operation
23:+	DOZE_SLEEPING,	// sweeping brightness down to the floor
24:+	DOZE_ASLEEP,	// at the floor, OLED display turned off (true black)
25:+	DOZE_WAKING		// OLED display back on, sweeping brightness up
28:+bool doze_active = false;					// the doze-latch
29:+static uint16_t doze_live_brightness = 0;	// brightness currently driven mid-sweep
32:+// latch but BEFORE the bringup has settled into DOZE_ASLEEP (it gates the wake
34:+// wake in that window used to disarm the latch -> panels came up lit with no
50:+// Per-iteration brightness increment for a ~DOZE_SWEEP_MS sweep between the
51:+// floor and 'target'. Recomputed at each transition so a user-raised brightness
54:+	uint16_t span = (target > DOZE_BRIGHTNESS_FLOOR) ? (target - DOZE_BRIGHTNESS_FLOOR) : 1u;
59:+// Map the linear sweep intent (DOZE_BRIGHTNESS_FLOOR..oled_current_brightness)
65:+	uint16_t full = oled_current_brightness;
66:+	if(full <= DOZE_BRIGHTNESS_FLOOR || linear <= DOZE_BRIGHTNESS_FLOOR)
67:+		return DOZE_BRIGHTNESS_FLOOR;
69:+	uint16_t span = full - DOZE_BRIGHTNESS_FLOOR;
70:+	float frac = (float)(linear - DOZE_BRIGHTNESS_FLOOR) / (float)span;
72:+	return (uint16_t)(DOZE_BRIGHTNESS_FLOOR + (uint16_t)(curved * (float)span + 0.5f));
75:+// Drive both panels to 'b', retrying the I2C write like the prox dimmers do.
76:+static void doze_write_brightness(uint16_t b) {
78:+	for(i = 0; i < NUM_BRIGHTNESS_RETRIES; i++) {
79:+		if(OLED_set_brightness(OLED_LEFT, b, pulse_emission) == pdPASS) break;
81:+	for(i = 0; i < NUM_BRIGHTNESS_RETRIES; i++) {
82:+		if(OLED_set_brightness(OLED_RIGHT, b, pulse_emission) == pdPASS) break;
93:+		// Doze sleep (ADR-0005): set the doze-latch and begin the fade to the
94:+		// floor. The video pipeline stays up (VXR locked, host still presenting);
96:+		// looks and sounds asleep. The fade and the display-off at the floor run
100:+				// Born-dozing (ADR-0005): arm the latch before video is up. The
108:+				// current live value when reversing a wake (no jump-to-bright flash).
110:+					doze_live_brightness = oled_current_brightness;
111:+				doze_set_step(oled_current_brightness);
113:+				// Doze owns the display now; stop the prox 5-minute display-off timer.
114:+				xTimerStop(prox_timeout_timer, 10);
115:+				prox_timeout_expired = false;
116:+				xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_OFF, eSetValueWithOverwrite);
117:+				xTaskNotify(get_led_task_handle(), LED_TASK_CODE_BREATHING, eSetBits);
125:+		// had reached true-black, then sweep brightness back up and re-couple the
126:+		// fan/LED to video. The latch clears when the sweep reaches full (below).
137:+				doze_set_step(oled_current_brightness);
139:+				xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_ON, eSetValueWithOverwrite);
140:+				xTaskNotify(get_led_task_handle(), LED_TASK_CODE_STATIC_COLOR, eSetBits);
143:+				// Latch armed for born-dozing but the bringup has not settled into
154: 		// Check if we can disable the OLED panels (video was enabled, now it isn't)
160:+				// Force-clear the doze-latch on video loss (ADR-0005 safety: the
161:+				// headset must never stay latched-dozing without video). Degrades


Prompt:
<task-notification>
<task-id>bk7utk19m</task-id>
<tool-use-id>toolu_018sqU6W4YxNZRWyC9r9bxXY</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-sauna\d3fb9be1-d61d-4f88-b77c-2a2a282b11fd\tasks\bk7utk19m.output</output-file>
<status>completed</status>
<summary>Background command "Scan doze video_proc for prox/brightness gating" completed (exit code 0)</summary>
</task-notification>


proceed with next steps. hmd power cycled.


<task-notification>
<task-id>bxbs9jrbp</task-id>
<tool-use-id>toolu_0163J89t8YzHwJ4WnVnW3bPL</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-sauna\d3fb9be1-d61d-4f88-b77c-2a2a282b11fd\tasks\bxbs9jrbp.output</output-file>
<status>completed</status>
<summary>Background command "Find doze cmd + suspend ordering in host" completed (exit code 0)</summary>
</task-notification>


This reconciles both hypotheses into one mechanism. Key firmware facts:

- `doze_active` latch **overrides the prox emission gate** (line 17-19) and stops the prox timeout timer (line 113-114).
- Latch only auto-clears on **video loss** (line 160) or a wake `'h'`.
- Symptom "brightness ~7" = `DOZE_BRIGHTNESS_FLOOR`; "prox dead" = latch override.

**Likely real mechanism:** sauna dozes panel (floor, prox gated) → SteamVR comes up and takes `0101` → wake `'h'` never lands (or seamless DP handoff means no video-loss to force-clear) → firmware stuck latched at floor → power-cycle. Both "firmware" and "host double-open" symptoms are the same wedge.

Critical unknown that decides the fix: **does the host wake the panel out of doze BEFORE suspending `0101` on SteamVR-up?** If not, that's a one-line host bug. Let me check the release/suspend path and how doze commands are issued.


Bash

cd ../sauna 2>/dev/null; cd C:/Users/decid/Documents/projects/sauna && grep -nE "suspend\(\)|resume\(\)|doze|wake|'H'|'h'|sendDoze|dozeEnter|dozeExit|hmdReleasedNow|reclaimWithFade" src/app/spatial_light.cpp | head -60

Find doze cmd + suspend ordering in host


Command running in background with ID: bxbs9jrbp. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-sauna\d3fb9be1-d61d-4f88-b77c-2a2a282b11fd\tasks\bxbs9jrbp.output. You will be notified when it completes. To check interim output, use Read on that file path.


Prompt:
<task-notification>
<task-id>bxbs9jrbp</task-id>
<tool-use-id>toolu_0163J89t8YzHwJ4WnVnW3bPL</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-sauna\d3fb9be1-d61d-4f88-b77c-2a2a282b11fd\tasks\bxbs9jrbp.output</output-file>
<status>completed</status>
<summary>Background command "Find doze cmd + suspend ordering in host" completed (exit code 0)</summary>
</task-notification>


1	214:  std::function<void()> sleepNow, wakeNow;
2	215:  // 0 = awake (offer sleep), 1 = SteamVR owns the headset (grayed),
3	216:  // 2 = asleep (offer wake — bypasses grace and prox gating).
4	237:      // Sleep/wake entry: asleep offers "Wake up" (works regardless of
5	256:        if (ss == 2 && ctx->wakeNow) ctx->wakeNow();
6	286:// SteamVR). Diagnostic instrument for the M5 doze VRAM question; cached adapter
7	409:  // Motion wake feature flag (default OFF — the don is the wake signal;
8	410:  // motion wake fires from desk bumps). Forced when no prox hardware.
9	411:  std::atomic<bool> wakeOnMotion{settings.wake_on_motion};
10	635:  // Born-dozing at launch (M5/ADR-0005): arm the firmware doze-latch and the
11	637:  // straight into doze-dark — no emission, fan, or LED flash as the headset
12	638:  // comes alive. The idle thread settles the prox and decides worn -> wake /
13	639:  // not worn -> stay dozed. No-op when the firmware can't doze.
14	640:  if (haveProx && prox.dozeCapable()) {
15	643:    printf("born dozing: doze-latch armed before video (no bringup flash)\n");
16	710:  std::atomic<bool> wakeNowReq{false};
17	712:  // SteamVR-exit reclaim-into-doze handoff (M5): when SteamVR exits while the
18	714:  // into doze (warm) instead of waiting for a don to cold-reacquire — then sets
19	715:  // this so the idle thread adopts the doze (parked/dozing bookkeeping) and a
20	716:  // later don is an instant ~95 ms doze-wake.
21	757:    const bool dozeCapable = haveProx && prox.dozeCapable();
22	759:    // when SteamVR takes over, kick off the firmware doze sweep on the LIVE
23	763:    // video -> the firmware clears its own doze-latch (panels already dark).
24	766:      if (dozeCapable) {
25	775:      // before stop(); reclaim resume()s them. Skip on timeout (never stop while
26	776:      // a frame might still reference them). Mirrors enterSleep's doze stop.
27	777:      for (int i = 0; i < 80 && !presenter.hmdReleasedNow() && !g_stop.load(); i++)
28	779:      if (presenter.hmdReleasedNow() && screensReady.load())
29	784:      // wedge the MCU app (telemetry dies, panels stick mid-doze → power-cycle).
30	785:      // Suspend before the compositor loads it. reclaim resume()s the MCU.
31	786:      if (haveProx) prox.suspend();
32	789:    // may still hold 0101 for a beat past compositor exit, so resume() lets the
33	791:    // commanding doze — else the wake 'h' is dropped on a still-closed handle
34	796:      prox.resume();
35	800:    // Reclaim with a fade-in (M5/ADR-0005): come back born-dozed — arm the
36	802:    // settles straight into doze-dark with no emission flash — then wake from
37	803:    // doze so the image sweeps up from black instead of snapping on.
38	804:    auto reclaimWithFade = [&] {
39	805:      if (dozeCapable) {
40	806:        prox.setDoze(true);           // latch before video (born-dozed reclaim)
41	813:           i < 200 && presenter.hmdReleasedNow() && !g_stop.load(); i++)
42	815:      // 'h' may land before the re-acquired link has settled into DOZE_ASLEEP;
43	816:      // the firmware (>= 0.4.3) remembers it (doze_wake_pending) and settles
44	817:      // into the wake fade — so no DSC/link wait is needed here.
45	819:      // capture sources to free VRAM for SteamVR — rebuild them now (resume() is
46	824:          if (s.src) s.src->resume();
47	826:      if (dozeCapable) {
48	828:        prox.setDoze(false);           // 'h' sweeps brightness up -> fade-in
49	831:    // Reclaim straight into doze (M5): SteamVR exited while NOT worn — re-
50	832:    // acquire the display born-dozed and LEAVE it dozed (warm) instead of
51	834:    // doze via vrDozedReq, so a later don is an instant ~95 ms doze-wake.
52	836:      prox.setDoze(true);           // arm latch before re-acquire (born-dozed)
53	840:           i < 200 && presenter.hmdReleasedNow() && !g_stop.load(); i++)
54	843:      // (dozed); the idle thread's wake() unpauses it cleanly on the don.
55	847:      vrDozedReq.store(true);  // idle thread adopts the doze bookkeeping
56	865:          trayDonReq.store(false);  // drop stale wake clicks (idle owns those)
57	882:              reclaimWithFade();
58	884:            } else if (dozeCapable) {
59	886:                     "doze (warm)]\n");
60	905:              reclaimWithFade();
61

214:  std::function<void()> sleepNow, wakeNow;
215:  // 0 = awake (offer sleep), 1 = SteamVR owns the headset (grayed),
216:  // 2 = asleep (offer wake — bypasses grace and prox gating).
237:      // Sleep/wake entry: asleep offers "Wake up" (works regardless of
256:        if (ss == 2 && ctx->wakeNow) ctx->wakeNow();
286:// SteamVR). Diagnostic instrument for the M5 doze VRAM question; cached adapter
409:  // Motion wake feature flag (default OFF — the don is the wake signal;
410:  // motion wake fires from desk bumps). Forced when no prox hardware.
411:  std::atomic<bool> wakeOnMotion{settings.wake_on_motion};
635:  // Born-dozing at launch (M5/ADR-0005): arm the firmware doze-latch and the
637:  // straight into doze-dark — no emission, fan, or LED flash as the headset
638:  // comes alive. The idle thread settles the prox and decides worn -> wake /
639:  // not worn -> stay dozed. No-op when the firmware can't doze.
640:  if (haveProx && prox.dozeCapable()) {
643:    printf("born dozing: doze-latch armed before video (no bringup flash)\n");
710:  std::atomic<bool> wakeNowReq{false};
712:  // SteamVR-exit reclaim-into-doze handoff (M5): when SteamVR exits while the
714:  // into doze (warm) instead of waiting for a don to cold-reacquire — then sets
715:  // this so the idle thread adopts the doze (parked/dozing bookkeeping) and a
716:  // later don is an instant ~95 ms doze-wake.
757:    const bool dozeCapable = haveProx && prox.dozeCapable();
759:    // when SteamVR takes over, kick off the firmware doze sweep on the LIVE
763:    // video -> the firmware clears its own doze-latch (panels already dark).
766:      if (dozeCapable) {
775:      // before stop(); reclaim resume()s them. Skip on timeout (never stop while
776:      // a frame might still reference them). Mirrors enterSleep's doze stop.
777:      for (int i = 0; i < 80 && !presenter.hmdReleasedNow() && !g_stop.load(); i++)
779:      if (presenter.hmdReleasedNow() && screensReady.load())
784:      // wedge the MCU app (telemetry dies, panels stick mid-doze → power-cycle).
785:      // Suspend before the compositor loads it. reclaim resume()s the MCU.
786:      if (haveProx) prox.suspend();
789:    // may still hold 0101 for a beat past compositor exit, so resume() lets the
791:    // commanding doze — else the wake 'h' is dropped on a still-closed handle
796:      prox.resume();
800:    // Reclaim with a fade-in (M5/ADR-0005): come back born-dozed — arm the
802:    // settles straight into doze-dark with no emission flash — then wake from
803:    // doze so the image sweeps up from black instead of snapping on.
804:    auto reclaimWithFade = [&] {
805:      if (dozeCapable) {
806:        prox.setDoze(true);           // latch before video (born-dozed reclaim)
813:           i < 200 && presenter.hmdReleasedNow() && !g_stop.load(); i++)
815:      // 'h' may land before the re-acquired link has settled into DOZE_ASLEEP;
816:      // the firmware (>= 0.4.3) remembers it (doze_wake_pending) and settles
817:      // into the wake fade — so no DSC/link wait is needed here.
819:      // capture sources to free VRAM for SteamVR — rebuild them now (resume() is
824:          if (s.src) s.src->resume();
826:      if (dozeCapable) {
828:        prox.setDoze(false);           // 'h' sweeps brightness up -> fade-in
831:    // Reclaim straight into doze (M5): SteamVR exited while NOT worn — re-
832:    // acquire the display born-dozed and LEAVE it dozed (warm) instead of
834:    // doze via vrDozedReq, so a later don is an instant ~95 ms doze-wake.
836:      prox.setDoze(true);           // arm latch before re-acquire (born-dozed)
840:           i < 200 && presenter.hmdReleasedNow() && !g_stop.load(); i++)
843:      // (dozed); the idle thread's wake() unpauses it cleanly on the don.
847:      vrDozedReq.store(true);  // idle thread adopts the doze bookkeeping
865:          trayDonReq.store(false);  // drop stale wake clicks (idle owns those)
882:              reclaimWithFade();
884:            } else if (dozeCapable) {
886:                     "doze (warm)]\n");
905:              reclaimWithFade();


752	    auto vrCompositorUp = [&] { return procRunning(L"vrcompositor.exe"); };
753	    auto pauseCapture = [&](bool p) {
754	      capPauseVr.store(p);
755	      applyCapturePause();
756	    };
757	    const bool dozeCapable = haveProx && prox.dozeCapable();
758	    // Release with a fade (M5/ADR-0005): instead of hard-cutting the display
759	    // when SteamVR takes over, kick off the firmware doze sweep on the LIVE
760	    // image and wait ~300 ms for it to reach true black, THEN relinquish. The
761	    // present loop keeps presenting real content through the sweep, so the
762	    // firmware brightness ramp dims the actual image to black. Releasing drops
763	    // video -> the firmware clears its own doze-latch (panels already dark).
764	    auto releaseWithFade = [&] {
765	      pauseCapture(true);
766	      if (dozeCapable) {
767	        prox.setDoze(true);
768	        std::this_thread::sleep_for(std::chrono::milliseconds(300));
769	      }
770	      presenter.requestHmdOwnership(false);
771	      // Free the capture GPU ring for the whole SteamVR session: sauna has let
772	      // the headset go entirely (no present, no desktop sampling), so the
773	      // hundreds-of-MB ring is dead weight the SteamVR game wants. Wait until the
774	      // present loop has actually released (draw no longer samples the textures)
775	      // before stop(); reclaim resume()s them. Skip on timeout (never stop while
776	      // a frame might still reference them). Mirrors enterSleep's doze stop.
777	      for (int i = 0; i < 80 && !presenter.hmdReleasedNow() && !g_stop.load(); i++)
778	        std::this_thread::sleep_for(std::chrono::milliseconds(25));
779	      if (presenter.hmdReleasedNow() && screensReady.load())
780	        for (auto& s : screens)
781	          if (s.src) s.src->stop();
782	      // Release the 0101 MCU interface too: SteamVR's BeyondProximity driver
783	      // opens the same interface when the compositor comes up, and two openers
784	      // wedge the MCU app (telemetry dies, panels stick mid-doze → power-cycle).
785	      // Suspend before the compositor loads it. reclaim resume()s the MCU.
786	      if (haveProx) prox.suspend();
787	    };
788	    // Bring the MCU back after the SteamVR session released it. BeyondProximity
789	    // may still hold 0101 for a beat past compositor exit, so resume() lets the
790	    // read thread reopen and we wait for telemetry to actually flow before
791	    // commanding doze — else the wake 'h' is dropped on a still-closed handle
792	    // and the panels stay dark (the stuck-dim symptom).
793	    auto resumeProx = [&] {
794	      if (!haveProx) return;
795	      const uint64_t r0 = prox.reports();
796	      prox.resume();
797	      for (int i = 0; i < 120 && prox.reports() == r0 && !g_stop.load(); i++)
798	        std::this_thread::sleep_for(std::chrono::milliseconds(50));
799	    };
800	    // Reclaim with a fade-in (M5/ADR-0005): come back born-dozed — arm the
801	    // latch and the host present-idle BEFORE re-acquiring, so the bringup
802	    // settles straight into doze-dark with no emission flash — then wake from
803	    // doze so the image sweeps up from black instead of snapping on.
804	    auto reclaimWithFade = [&] {
805	      if (dozeCapable) {
806	        prox.setDoze(true);           // latch before video (born-dozed reclaim)
807	        presenter.requestDoze(true);  // host present-idle through re-acquire
808	      }
809	      presenter.requestHmdOwnership(true);
810	      // Wait out the present loop's re-acquire (it retries while SteamVR is
811	      // still letting the display go); bounded so a stuck reclaim still moves.
812	      for (int i = 0;
813	           i < 200 && presenter.hmdReleasedNow() && !g_stop.load(); i++)
814	        std::this_thread::sleep_for(std::chrono::milliseconds(50));
815	      // 'h' may land before the re-acquired link has settled into DOZE_ASLEEP;
816	      // the firmware (>= 0.4.3) remembers it (doze_wake_pending) and settles
817	      // into the wake fade — so no DSC/link wait is needed here.
818	      // Content live again, ready for the fade-in. releaseWithFade stopped the
819	      // capture sources to free VRAM for SteamVR — rebuild them now (resume() is
820	      // a no-op if they were never stopped). Clear the VR pause flag too.
821	      capPauseVr.store(false);
822	      if (screensReady.load())
823	        for (auto& s : screens)
824	          if (s.src) s.src->resume();
825	      applyCapturePause();
826	      if (dozeCapable) {
827	        presenter.requestDoze(false);  // resume active present (still floor-dim)
828	        prox.setDoze(false);           // 'h' sweeps brightness up -> fade-in
829	      }
830	    };
831	    // Reclaim straight into doze (M5): SteamVR exited while NOT worn — re-
832	    // acquire the display born-dozed and LEAVE it dozed (warm) instead of
833	    // waiting for a don to cold-reacquire (3-5 s). The idle thread adopts the
834	    // doze via vrDozedReq, so a later don is an instant ~95 ms doze-wake.
835	    auto reclaimIntoDoze = [&] {
836	      prox.setDoze(true);           // arm latch before re-acquire (born-dozed)
837	      presenter.requestDoze(true);  // present comes up idled
838	      presenter.requestHmdOwnership(true);
839	      for (int i = 0;
840	           i < 200 && presenter.hmdReleasedNow() && !g_stop.load(); i++)
841	        std::this_thread::sleep_for(std::chrono::milliseconds(50));

    auto vrCompositorUp = [&] { return procRunning(L"vrcompositor.exe"); };
    auto pauseCapture = [&](bool p) {
      capPauseVr.store(p);
      applyCapturePause();
    };
    const bool dozeCapable = haveProx && prox.dozeCapable();
    // Release with a fade (M5/ADR-0005): instead of hard-cutting the display
    // when SteamVR takes over, kick off the firmware doze sweep on the LIVE
    // image and wait ~300 ms for it to reach true black, THEN relinquish. The
    // present loop keeps presenting real content through the sweep, so the
    // firmware brightness ramp dims the actual image to black. Releasing drops
    // video -> the firmware clears its own doze-latch (panels already dark).
    auto releaseWithFade = [&] {
      pauseCapture(true);
      if (dozeCapable) {
        prox.setDoze(true);
        std::this_thread::sleep_for(std::chrono::milliseconds(300));
      }
      presenter.requestHmdOwnership(false);
      // Free the capture GPU ring for the whole SteamVR session: sauna has let
      // the headset go entirely (no present, no desktop sampling), so the
      // hundreds-of-MB ring is dead weight the SteamVR game wants. Wait until the
      // present loop has actually released (draw no longer samples the textures)
      // before stop(); reclaim resume()s them. Skip on timeout (never stop while
      // a frame might still reference them). Mirrors enterSleep's doze stop.
      for (int i = 0; i < 80 && !presenter.hmdReleasedNow() && !g_stop.load(); i++)
        std::this_thread::sleep_for(std::chrono::milliseconds(25));
      if (presenter.hmdReleasedNow() && screensReady.load())
        for (auto& s : screens)
          if (s.src) s.src->stop();
      // Release the 0101 MCU interface too: SteamVR's BeyondProximity driver
      // opens the same interface when the compositor comes up, and two openers
      // wedge the MCU app (telemetry dies, panels stick mid-doze → power-cycle).
      // Suspend before the compositor loads it. reclaim resume()s the MCU.
      if (haveProx) prox.suspend();
    };
    // Bring the MCU back after the SteamVR session released it. BeyondProximity
    // may still hold 0101 for a beat past compositor exit, so resume() lets the
    // read thread reopen and we wait for telemetry to actually flow before
    // commanding doze — else the wake 'h' is dropped on a still-closed handle
    // and the panels stay dark (the stuck-dim symptom).
    auto resumeProx = [&] {
      if (!haveProx) return;
      const uint64_t r0 = prox.reports();
      prox.resume();
      for (int i = 0; i < 120 && prox.reports() == r0 && !g_stop.load(); i++)
        std::this_thread::sleep_for(std::chrono::milliseconds(50));
    };
    // Reclaim with a fade-in (M5/ADR-0005): come back born-dozed — arm the
    // latch and the host present-idle BEFORE re-acquiring, so the bringup
    // settles straight into doze-dark with no emission flash — then wake from
    // doze so the image sweeps up from black instead of snapping on.
    auto reclaimWithFade = [&] {
      if (dozeCapable) {
        prox.setDoze(true);           // latch before video (born-dozed reclaim)
        presenter.requestDoze(true);  // host present-idle through re-acquire
      }
      presenter.requestHmdOwnership(true);
      // Wait out the present loop's re-acquire (it retries while SteamVR is
      // still letting the display go); bounded so a stuck reclaim still moves.
      for (int i = 0;
           i < 200 && presenter.hmdReleasedNow() && !g_stop.load(); i++)
        std::this_thread::sleep_for(std::chrono::milliseconds(50));
      // 'h' may land before the re-acquired link has settled into DOZE_ASLEEP;
      // the firmware (>= 0.4.3) remembers it (doze_wake_pending) and settles
      // into the wake fade — so no DSC/link wait is needed here.
      // Content live again, ready for the fade-in. releaseWithFade stopped the
      // capture sources to free VRAM for SteamVR — rebuild them now (resume() is
      // a no-op if they were never stopped). Clear the VR pause flag too.
      capPauseVr.store(false);
      if (screensReady.load())
        for (auto& s : screens)
          if (s.src) s.src->resume();
      applyCapturePause();
      if (dozeCapable) {
        presenter.requestDoze(false);  // resume active present (still floor-dim)
        prox.setDoze(false);           // 'h' sweeps brightness up -> fade-in
      }
    };
    // Reclaim straight into doze (M5): SteamVR exited while NOT worn — re-
    // acquire the display born-dozed and LEAVE it dozed (warm) instead of
    // waiting for a don to cold-reacquire (3-5 s). The idle thread adopts the
    // doze via vrDozedReq, so a later don is an instant ~95 ms doze-wake.
    auto reclaimIntoDoze = [&] {
      prox.setDoze(true);           // arm latch before re-acquire (born-dozed)
      presenter.requestDoze(true);  // present comes up idled
      presenter.requestHmdOwnership(true);
      for (int i = 0;
           i < 200 && presenter.hmdReleasedNow() && !g_stop.load(); i++)
        std::this_thread::sleep_for(std::chrono::milliseconds(50));


<task-notification>
<task-id>bj28qw242</task-id>
<tool-use-id>toolu_01J78Lh8iVhXf22kEGjPM7op</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-sauna\d3fb9be1-d61d-4f88-b77c-2a2a282b11fd\tasks\bj28qw242.output</output-file>
<status>completed</status>
<summary>Background command "Read firmware video-loss doze-clear logic" completed (exit code 0)</summary>
</task-notification>


Found the likely bug. `releaseWithFade` (host, line 764-787) ordering:

1. `prox.setDoze(true)` → fw enters doze, sweeps to floor, **sets `doze_active` latch**
2. 300ms fade
3. `requestHmdOwnership(false)` → release video
4. `prox.suspend()` → release `0101`

The load-bearing assumption (line 762-763): *"Releasing drops video → the firmware clears its own doze-latch."* That only holds **if video actually drops**. When SteamVR takes the display seamlessly (re-acquires DP within the firmware's video-loss debounce), video never "loses" from the MCU's view → latch never clears → brightness stuck at floor (~7), prox gated → **wedge**. Exact reported symptom.

Need to confirm the firmware's video-loss clear condition to know if SteamVR handoff defeats it.


cd ../beyond_synaptics && git diff 78c279d HEAD -- src/video_proc.c | sed -n '145,230p'

Read firmware video-loss doze-clear logic


Command running in background with ID: bj28qw242. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-sauna\d3fb9be1-d61d-4f88-b77c-2a2a282b11fd\tasks\bj28qw242.output. You will be notified when it completes. To check interim output, use Read on that file path.


Prompt:
<task-notification>
<task-id>bj28qw242</task-id>
<tool-use-id>toolu_01J78Lh8iVhXf22kEGjPM7op</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-sauna\d3fb9be1-d61d-4f88-b77c-2a2a282b11fd\tasks\bj28qw242.output</output-file>
<status>completed</status>
<summary>Background command "Read firmware video-loss doze-clear logic" completed (exit code 0)</summary>
</task-notification>


1	+				// the wake; the born-doze settle below honors it and comes up
2	+				// straight into the fade-in instead of dark-then-wake.
3	+				doze_wake_pending = true;
4	+				send_code_to_hid_task(HID_REPORT_TASK_CODE_FOR_SUCCESS);
5	+			} else {
6	+				send_code_to_hid_task(HID_REPORT_TASK_CODE_FOR_ERROR);
7	+			}
8	+		}
9	+
10	 		// Check if we can disable the OLED panels (video was enabled, now it isn't)
11	 		if(video_enabled) {
12	 			if(!(main_left_display_reset_status()) || !(main_right_display_reset_status())) {
13	 				video_enabled = false;
14	 				dsc_enabled = false;
15	 				displays_on = false;
16	+				// Force-clear the doze-latch on video loss (ADR-0005 safety: the
17	+				// headset must never stay latched-dozing without video). Degrades
18	+				// to the normal dark "no video" state, not stuck dozed.
19	+				doze_active = false;
20	+				doze_phase = DOZE_NONE;
21	+				doze_wake_pending = false;
22	 				// Disable fan and led
23	 				xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_OFF, eSetValueWithOverwrite);
24	 				xTaskNotify(get_led_task_handle(), LED_TASK_CODE_BREATHING, eSetBits);
25	@@ -587,8 +717,17 @@ void task_video_proc(void *pvParameters) {
26	 						// Ramp fan speed up to final value
27	 						// Ramp speed is set to 1% per 10ms.
28	 						// Could take up to 1 sec if startup is set to 100%
29	-						xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_ON, eSetValueWithOverwrite);
30	-						xTaskNotify(get_led_task_handle(), LED_TASK_CODE_STATIC_COLOR, eSetBits);
31	+						// Born-dozing (ADR-0005): when the host armed the doze-latch
32	+						// before video, SKIP turning the fan/LED on at all. The
33	+						// doze_active settle below used to turn them ON here and revert
34	+						// them ~64 ms later, but the fan physically spins up and the LED
35	+						// flashes solid in that window — the visible boot blip. Gating
36	+						// here means the headset comes up doze-dark with zero fan/LED
37	+						// activity.
38	+						if(!doze_active) {
39	+							xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_ON, eSetValueWithOverwrite);
40	+							xTaskNotify(get_led_task_handle(), LED_TASK_CODE_STATIC_COLOR, eSetBits);
41	+						}
42
43	 						OLED_Startup(OLED_LEFT, vidsel);
44	 						OLED_Startup(OLED_RIGHT, vidsel);
45	@@ -646,8 +785,36 @@ void task_video_proc(void *pvParameters) {
46	 						OLED_dimming_post_init(OLED_LEFT);
47	 						OLED_dimming_post_init(OLED_RIGHT);
48
49	+						// Born-dozing (ADR-0005): if the host latched doze before video
50	+						// came up (launch / SteamVR-exit while not worn), settle straight
51	+						// into doze-dark instead of lighting. Brightness is already at the
52	+						// floor from the init above, so just cut to true black and idle the
53	+						// fan / breathe the LED (bringup set them to video-on above).
54	+						if(doze_active) {
55	+							doze_live_brightness = DOZE_BRIGHTNESS_FLOOR;
56	+							if(doze_wake_pending) {
57	+								// Host already requested wake (its 'h' raced ahead of this
58	+								// settle). Come up straight into the wake fade: displays are
59	+								// ON from the bringup above and brightness is at the floor, so
60	+								// just enter WAKING and let the stepper ramp up. Fan/LED to
61	+								// video (the bringup skipped them while doze_active was set).
62	+								doze_wake_pending = false;
63	+								displays_on = true;
64	+								doze_set_step(oled_current_brightness);
65	+								doze_phase = DOZE_WAKING;
66	+								xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_ON, eSetValueWithOverwrite);
67	+								xTaskNotify(get_led_task_handle(), LED_TASK_CODE_STATIC_COLOR, eSetBits);
68	+							} else {
69	+								OLED_display_on_off(OLED_LEFT, false);
70	+								OLED_display_on_off(OLED_RIGHT, false);
71	+								displays_on = false;
72	+								doze_phase = DOZE_ASLEEP;
73	+								xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_OFF, eSetValueWithOverwrite);
74	+								xTaskNotify(get_led_task_handle(), LED_TASK_CODE_BREATHING, eSetBits);
75	+							}
76	+						}
77	 						// If prox is detected, turn displays on
78	-						if(prox_enabled && prox_is_connected() && debug_prox_enabled) {
79	+						else if(prox_enabled && prox_is_connected() && debug_prox_enabled) {
80	 							if(proxDisplaysOn) {
81	 								displays_on = true;
82	 								video_displays_on_prox();
83	@@ -681,8 +848,41 @@ void task_video_proc(void *pvParameters) {
84	 		// 	dsc_enabled = false;
85	 		// }
86
87

+				// the wake; the born-doze settle below honors it and comes up
+				// straight into the fade-in instead of dark-then-wake.
+				doze_wake_pending = true;
+				send_code_to_hid_task(HID_REPORT_TASK_CODE_FOR_SUCCESS);
+			} else {
+				send_code_to_hid_task(HID_REPORT_TASK_CODE_FOR_ERROR);
+			}
+		}
+
 		// Check if we can disable the OLED panels (video was enabled, now it isn't)
 		if(video_enabled) {
 			if(!(main_left_display_reset_status()) || !(main_right_display_reset_status())) {
 				video_enabled = false;
 				dsc_enabled = false;
 				displays_on = false;
+				// Force-clear the doze-latch on video loss (ADR-0005 safety: the
+				// headset must never stay latched-dozing without video). Degrades
+				// to the normal dark "no video" state, not stuck dozed.
+				doze_active = false;
+				doze_phase = DOZE_NONE;
+				doze_wake_pending = false;
 				// Disable fan and led
 				xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_OFF, eSetValueWithOverwrite);
 				xTaskNotify(get_led_task_handle(), LED_TASK_CODE_BREATHING, eSetBits);
@@ -587,8 +717,17 @@ void task_video_proc(void *pvParameters) {
 						// Ramp fan speed up to final value
 						// Ramp speed is set to 1% per 10ms.
 						// Could take up to 1 sec if startup is set to 100%
-						xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_ON, eSetValueWithOverwrite);
-						xTaskNotify(get_led_task_handle(), LED_TASK_CODE_STATIC_COLOR, eSetBits);
+						// Born-dozing (ADR-0005): when the host armed the doze-latch
+						// before video, SKIP turning the fan/LED on at all. The
+						// doze_active settle below used to turn them ON here and revert
+						// them ~64 ms later, but the fan physically spins up and the LED
+						// flashes solid in that window — the visible boot blip. Gating
+						// here means the headset comes up doze-dark with zero fan/LED
+						// activity.
+						if(!doze_active) {
+							xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_ON, eSetValueWithOverwrite);
+							xTaskNotify(get_led_task_handle(), LED_TASK_CODE_STATIC_COLOR, eSetBits);
+						}

 						OLED_Startup(OLED_LEFT, vidsel);
 						OLED_Startup(OLED_RIGHT, vidsel);
@@ -646,8 +785,36 @@ void task_video_proc(void *pvParameters) {
 						OLED_dimming_post_init(OLED_LEFT);
 						OLED_dimming_post_init(OLED_RIGHT);

+						// Born-dozing (ADR-0005): if the host latched doze before video
+						// came up (launch / SteamVR-exit while not worn), settle straight
+						// into doze-dark instead of lighting. Brightness is already at the
+						// floor from the init above, so just cut to true black and idle the
+						// fan / breathe the LED (bringup set them to video-on above).
+						if(doze_active) {
+							doze_live_brightness = DOZE_BRIGHTNESS_FLOOR;
+							if(doze_wake_pending) {
+								// Host already requested wake (its 'h' raced ahead of this
+								// settle). Come up straight into the wake fade: displays are
+								// ON from the bringup above and brightness is at the floor, so
+								// just enter WAKING and let the stepper ramp up. Fan/LED to
+								// video (the bringup skipped them while doze_active was set).
+								doze_wake_pending = false;
+								displays_on = true;
+								doze_set_step(oled_current_brightness);
+								doze_phase = DOZE_WAKING;
+								xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_ON, eSetValueWithOverwrite);
+								xTaskNotify(get_led_task_handle(), LED_TASK_CODE_STATIC_COLOR, eSetBits);
+							} else {
+								OLED_display_on_off(OLED_LEFT, false);
+								OLED_display_on_off(OLED_RIGHT, false);
+								displays_on = false;
+								doze_phase = DOZE_ASLEEP;
+								xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_OFF, eSetValueWithOverwrite);
+								xTaskNotify(get_led_task_handle(), LED_TASK_CODE_BREATHING, eSetBits);
+							}
+						}
 						// If prox is detected, turn displays on
-						if(prox_enabled && prox_is_connected() && debug_prox_enabled) {
+						else if(prox_enabled && prox_is_connected() && debug_prox_enabled) {
 							if(proxDisplaysOn) {
 								displays_on = true;
 								video_displays_on_prox();
@@ -681,8 +848,41 @@ void task_video_proc(void *pvParameters) {
 		// 	dsc_enabled = false;
 		// }


Confirmed the mechanism. Firmware auto-clears the doze-latch **only on video loss** (`main_*_display_reset_status()` deasserts — DSC/DP link drop):

```c
if(!(main_left_display_reset_status()) || !(main_right_display_reset_status())) {
    video_enabled = false; ...
    doze_active = false;          // ← ONLY auto-clear path
    doze_phase = DOZE_NONE;
```

And `releaseWithFade` is **committed** code (not the uncommitted diff) — it sets the latch then leans on that video-loss clear. When SteamVR seamlessly re-acquires the DP link, the MCU never sees video drop → latch persists into the SteamVR session → doze stepper forces brightness to floor (~7) every loop, fighting the addon's brightness, prox gated. = right optic dim/black + dead prox + "wedge."

**Root cause is host-side, not firmware HID dispatch.** The wrap's firmware theory is wrong — the HID diff is purely additive. And the uncommitted `suspend/resume` attacks a *different* layer (0101 double-open), so it alone wouldn't cure the latch persisting.

Fix options diverge from the queued plan, and one path needs a flash (risk + the documented retry quirk). Your call on direction:


AskUserQuestion

Question: Fix direction for the doze-latch wedge?

Choices:
Stop releaseWithFade from handing SteamVR a latched-dozing panel (drop/guard the pre-release doze so latch is never left set). Rebuild sauna only, retest. No firmware flash. Fastest, lowest risk, falsifiable now.
releaseWithFade:
-  if(dozeCapable){ prox.setDoze(true); sleep(300); }
   requestHmdOwnership(false);
   ...
   prox.suspend();

// panel handed to SteamVR un-latched
// → no floor-fight, prox alive
Add firmware unlatch: any external (non-doze) brightness command from SteamVR's addon cancels doze_active. Robust regardless of host timing, but needs build+flash (retry quirk) on the just-power-cycled HMD.
usbhid_set_feature:
 if(report[0]==HID_CODE_FOR_BRIGHTNESS){
+   doze_active=false;       // external
+   doze_phase=DOZE_NONE;    // owner
+   doze_wake_pending=false; // cancels doze
 }
Ship the host fix first to unblock testing, then add the firmware unlatch as defense-in-depth in a 0.4.4 once host fix is confirmed.
Phase 1: host fix → rebuild → test
Phase 2 (if confirmed):
  firmware B3 unlatch → 0.4.4
  → build+flash → retest with addon

Question: Keep the visual fade-to-black when SteamVR takes over?

Choices:
Simplest host fix: no pre-release doze at all. SteamVR cuts to its own content immediately anyway, so the fade is barely perceptible during HMD handoff. Eliminates the latch risk entirely.
// hard release — SteamVR shows
// its own content instantly
requestHmdOwnership(false);
Preserve the smooth fade-to-black, but explicitly clear the latch right before/after release instead of relying on video-loss. More code, keeps the UX nicety.
prox.setDoze(true); sleep(300);
requestHmdOwnership(false);
+ // confirm video gone OR force
+ //  unlatch before suspend
+ prox.dozeCancel();


Your questions have been answered: "Fix direction for the doze-latch wedge?"=(no option selected) notes: i don't think your hypothesis is accurate. the latest tests where the HMD displays got stuck dim, then firmware wedged on steamvr exit, did NOT involve sauna at all. those were wholly SteamVR + BeyondEyetracking + BeyondSteamVR addons, no sauna. so the issue has something to do with how the sauna firmware changes interact with those addons. nothing uses the new doze feature directly except the sauna binary itself., "Keep the visual fade-to-black when SteamVR takes over?"="Keep fade, clear latch safely" selected preview:
prox.setDoze(true); sleep(300);
requestHmdOwnership(false);
+ // confirm video gone OR force
+ //  unlatch before suspend
+ prox.dozeCancel();. You can now continue with these answers in mind.

Question: Fix direction for the doze-latch wedge?

Choices:
Stop releaseWithFade from handing SteamVR a latched-dozing panel (drop/guard the pre-release doze so latch is never left set). Rebuild sauna only, retest. No firmware flash. Fastest, lowest risk, falsifiable now.
releaseWithFade:
-  if(dozeCapable){ prox.setDoze(true); sleep(300); }
   requestHmdOwnership(false);
   ...
   prox.suspend();

// panel handed to SteamVR un-latched
// → no floor-fight, prox alive
Add firmware unlatch: any external (non-doze) brightness command from SteamVR's addon cancels doze_active. Robust regardless of host timing, but needs build+flash (retry quirk) on the just-power-cycled HMD.
usbhid_set_feature:
 if(report[0]==HID_CODE_FOR_BRIGHTNESS){
+   doze_active=false;       // external
+   doze_phase=DOZE_NONE;    // owner
+   doze_wake_pending=false; // cancels doze
 }
Ship the host fix first to unblock testing, then add the firmware unlatch as defense-in-depth in a 0.4.4 once host fix is confirmed.
Phase 1: host fix → rebuild → test
Phase 2 (if confirmed):
  firmware B3 unlatch → 0.4.4
  → build+flash → retest with addon

Question: Keep the visual fade-to-black when SteamVR takes over?

Choices:
Simplest host fix: no pre-release doze at all. SteamVR cuts to its own content immediately anyway, so the fade is barely perceptible during HMD handoff. Eliminates the latch risk entirely.
// hard release — SteamVR shows
// its own content instantly
requestHmdOwnership(false);
Preserve the smooth fade-to-black, but explicitly clear the latch right before/after release instead of relying on video-loss. More code, keeps the UX nicety.
prox.setDoze(true); sleep(300);
requestHmdOwnership(false);
+ // confirm video gone OR force
+ //  unlatch before suspend
+ prox.dozeCancel();


Important correction — that kills my host hypothesis. Pure SteamVR + BeyondEyetracking + BeyondSteamVR, **no sauna, no doze command ever sent**, yet the doze firmware wedges the addons. So a doze firmware change altered a code path the **addons** exercise (prox / brightness / HID report task) even though `doze_active` never gets set.

Need the full `video_proc.c` diff (not my filtered grep) plus the `.h`, looking for anything on the always-run hot path: task priority/stack, timer behavior, HID report task, prox handling, loop timing.


Bash

cd ../beyond_synaptics && git diff 78c279d HEAD -- src/video_proc.h src/test_and_debug.c src/test_and_debug.h src/sw_ver.h

Full diff of header + debug + version


Command running in background with ID: bc6kab19q. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-sauna\d3fb9be1-d61d-4f88-b77c-2a2a282b11fd\tasks\bc6kab19q.output. You will be notified when it completes. To check interim output, use Read on that file path.


<task-notification>
<task-id>bc6kab19q</task-id>
<tool-use-id>toolu_01LtwV6AXaYeR9RuNUKkgjj8</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-sauna\d3fb9be1-d61d-4f88-b77c-2a2a282b11fd\tasks\bc6kab19q.output</output-file>
<status>completed</status>
<summary>Background command "Full diff of header + debug + version" completed (exit code 0)</summary>
</task-notification>


cd ../beyond_synaptics && git diff 78c279d HEAD -- src/video_proc.c > /tmp/vproc_full.diff && wc -l /tmp/vproc_full.diff && echo "===FIRST 120===" && sed -n '1,120p' /tmp/vproc_full.diff

Full video_proc.c diff head


Command running in background with ID: bgsw1alfj. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-sauna\d3fb9be1-d61d-4f88-b77c-2a2a282b11fd\tasks\bgsw1alfj.output. You will be notified when it completes. To check interim output, use Read on that file path.


Prompt:
<task-notification>
<task-id>bc6kab19q</task-id>
<tool-use-id>toolu_01LtwV6AXaYeR9RuNUKkgjj8</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-sauna\d3fb9be1-d61d-4f88-b77c-2a2a282b11fd\tasks\bc6kab19q.output</output-file>
<status>completed</status>
<summary>Background command "Full diff of header + debug + version" completed (exit code 0)</summary>
</task-notification>


<task-notification>
<task-id>bgsw1alfj</task-id>
<tool-use-id>toolu_01Ty9UFprNszZCCuYYpbkZVY</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-sauna\d3fb9be1-d61d-4f88-b77c-2a2a282b11fd\tasks\bgsw1alfj.output</output-file>
<status>completed</status>
<summary>Background command "Full video_proc.c diff head" completed (exit code 0)</summary>
</task-notification>


1	diff --git a/src/sw_ver.h b/src/sw_ver.h
2	index 9c1f86e..7878e62 100644
3	--- a/src/sw_ver.h
4	+++ b/src/sw_ver.h
5	@@ -3,8 +3,17 @@
6
7
8	 #define MAJOR_VERSION		0
9	-#define MINOR_VERSION		3
10	-#define PATCH_VERSION		19
11	+#define MINOR_VERSION		4
12	+#define PATCH_VERSION		3
13	+// 0.4.0: doze warm-shallow-sleep (ADR-0005) - display-sleep/wake HID pair
14	+// ('H'/'h'), doze-latch, fan kickstart. Host capability-gates doze on >= 0.4.0.
15	+// 0.4.1: gamma-eased doze brightness sweep (perceptually even fade/wake).
16	+// 0.4.2: born-doze bringup skips fan/LED-on (gated on !doze_active) - kills the
17	+// brief boot fan-spin/LED-flash when launching not-worn straight into doze.
18	+// 0.4.3: born-doze wake race fix - an 'h' arriving before the bringup settles
19	+// into DOZE_ASLEEP is remembered (doze_wake_pending) and the settle comes up
20	+// straight into the wake fade, instead of disarming (lit, no fade-in). Fixes
21	+// donned-startup + first-don-after-undonned having no brightness sweep.
22
23	 #define macrostr(s) #s
24	 #define VERSION_STRING_BUILDER(major, minor, patch)		macrostr(major) "." macrostr(minor) "." macrostr(patch)
25	diff --git a/src/test_and_debug.c b/src/test_and_debug.c
26	index 108a91e..dbbc3f9 100644
27	--- a/src/test_and_debug.c
28	+++ b/src/test_and_debug.c
29	@@ -329,6 +329,16 @@ static void task_fan(void* pvParameters) {
30	 		notify_cmd = 0;
31	 		notify_arg = 0;
32
33	+		// Fan kickstart: a stationary fan won't reliably begin spinning below a
34	+		// minimum PWM (low-end stiction), so the slow 1%/10ms ramp from a dead
35	+		// stop can stall. Jump straight to the minimum stable duty first so a
36	+		// doze-wake re-spins cleanly, then ramp. (Distinct from the fan-sticktion
37	+		// 100%-blast hack, which targets debris-jammed fans.)
38	+		if((current_fan_speed == 0) && (next_fan_speed > 0)) {
39	+			current_fan_speed = (next_fan_speed < FAN_MIN_START) ? next_fan_speed : FAN_MIN_START;
40	+			set_fan_pwm(current_fan_speed);
41	+		}
42	+
43	 		// Fan speed ramp up...
44	 		while((notify_val == 0) && (current_fan_speed < next_fan_speed)) {
45	 			set_fan_pwm(++current_fan_speed);
46	diff --git a/src/test_and_debug.h b/src/test_and_debug.h
47	index 291d9af..8a566a6 100644
48	--- a/src/test_and_debug.h
49	+++ b/src/test_and_debug.h
50	@@ -27,6 +27,9 @@
51	 #define LED_TASK_CODE_BLINKING		(0x08)
52
53	 #define FAN_UPDATE_DELAY		(10) // Fan updates PWM at 100Hz
54	+#define FAN_MIN_START			(35) // Min PWM a stationary fan reliably starts at (low-end
55	+									     // stiction). Jump here before ramping from a dead stop.
56	+									     // Just under HMDUtility fan_speed_min; tune by ear.
57	 #define FAN_TASK_CODE_NEW_SPEED		(0x100) // Lowest byte is the new speed
58	 #define FAN_TASK_CODE_STOP			(0x200)
59	 #define FAN_TASK_CODE_SPEED_IMM		(0x300) // Change immediately to this speed. May enter debug state
60	diff --git a/src/video_proc.h b/src/video_proc.h
61	index 10f9ca7..611e963 100644
62	--- a/src/video_proc.h
63	+++ b/src/video_proc.h
64	@@ -21,17 +21,36 @@
65	 #define VIDEOPROC_TRACK_UART_ACTIVITY				(1u << 10u)
66	 #define VIDEOPROC_VXR_POWER_DOWN					(1u << 11u)
67	 #define VIDEOPROC_VXR_POWER_UP						(1u << 12u)
68	+#define VIDEOPROC_DOZE_SLEEP						(1u << 13u) // enter doze (warm shallow sleep)
69	+#define VIDEOPROC_DOZE_WAKE							(1u << 14u) // wake from doze
70	 #define VIDEOPROC_STACK_OVERFLOW					(1u << 20u)
71
72	 #define VIDEOPROC_NOTIFY_TASKBITS		(VIDEOPROC_NOTIFY_VXR_RESET | VIDEOPROC_NOTIFY_DEBUG_PROX_DISABLED \
73	 										| VIDEOPROC_COLORBAR | VIDEOPROC_DISP_POWER_DISABLE | VIDEOPROC_DISP_POWER_ENABLE \
74	 										| VIDEOPROC_NEW_FAN_SPEED | VIDEOPROC_EYETRACKING_ERROR | VIDEOPROC_EYETRACKING_ERROR_CLEAR \
75	-										| VIDEOPROC_TRACK_UART_ACTIVITY | VIDEOPROC_VXR_POWER_DOWN | VIDEOPROC_VXR_POWER_UP )
76	+										| VIDEOPROC_TRACK_UART_ACTIVITY | VIDEOPROC_VXR_POWER_DOWN | VIDEOPROC_VXR_POWER_UP \
77	+										| VIDEOPROC_DOZE_SLEEP | VIDEOPROC_DOZE_WAKE )
78
79	-#define DEFAULT_OLED_BRIGHTNESS			(0x010Au) // 266d, or about 26% (max is 0x03FF = 1023d)
80	+#define DEFAULT_OLED_BRIGHTNESS			(0x010Au) // 266d, or about 26% (max is 0x03FF = 1023d)
81
82	 #define NUM_BRIGHTNESS_RETRIES			(3u) // Number of times to retry setting brightness if it failed
83
84	+// Doze (warm shallow sleep, ADR-0005): on doze-sleep the brightness is swept to
85	+// the floor over ~DOZE_SWEEP_MS, then the OLED display is turned off (true
86	+// black); doze-wake reverses it. The sweep advances one step per video-task
87	+// iteration (VIDEO_TASK_DELAY) so it is naturally target-seeking: a wake that
88	+// arrives mid-fade just flips the phase and the next iteration ramps back up.
89	+#define DOZE_BRIGHTNESS_FLOOR			(0u)		// register 0 = lowest emission
90	+#define DOZE_SWEEP_MS					(300u)		// target fade duration
91	+#define DOZE_SWEEP_STEPS				(DOZE_SWEEP_MS / VIDEO_TASK_DELAY) // ~15 @ 20ms
92	+// The OLED is perceptually gamma'd: a register-linear sweep reads as a fast
93	+// jump then a slow crawl (camera-confirmed, docs/photon-latency-bench.md). The
94	+// sweep intent stays linear (so the target-seeking reversal logic is simple),
95	+// but the register actually written is gamma-mapped so perceived brightness
96	+// ramps evenly. >1 spends more of the sweep at low register (where the eye is
97	+// most sensitive). Camera luminance fit ~2.9; default a touch gentler. By ear.
98	+#define DOZE_SWEEP_GAMMA				(2.2f)
99	+
100	 // Definitions for the display status
101	 #define VIDSTATUS_DISPLAYS_ON						(1u << 0u)
102	 #define VIDSTATUS_PROX_ON							(1u << 1u)
103	@@ -62,6 +81,7 @@ typedef enum FATP_Mode{
104
105	 TaskHandle_t video_proc_task_handle(void);
106	 bool video_is_enabled(void);
107	+bool video_is_dozing(void);
108	 bool displays_are_on(void);
109	 void start_video_task(void);
110	 void task_video_proc(void *pvParameters);
111

diff --git a/src/sw_ver.h b/src/sw_ver.h
index 9c1f86e..7878e62 100644
--- a/src/sw_ver.h
+++ b/src/sw_ver.h
@@ -3,8 +3,17 @@

 #define MAJOR_VERSION		0
-#define MINOR_VERSION		3
-#define PATCH_VERSION		19
+#define MINOR_VERSION		4
+#define PATCH_VERSION		3
+// 0.4.0: doze warm-shallow-sleep (ADR-0005) - display-sleep/wake HID pair
+// ('H'/'h'), doze-latch, fan kickstart. Host capability-gates doze on >= 0.4.0.
+// 0.4.1: gamma-eased doze brightness sweep (perceptually even fade/wake).
+// 0.4.2: born-doze bringup skips fan/LED-on (gated on !doze_active) - kills the
+// brief boot fan-spin/LED-flash when launching not-worn straight into doze.
+// 0.4.3: born-doze wake race fix - an 'h' arriving before the bringup settles
+// into DOZE_ASLEEP is remembered (doze_wake_pending) and the settle comes up
+// straight into the wake fade, instead of disarming (lit, no fade-in). Fixes
+// donned-startup + first-don-after-undonned having no brightness sweep.

 #define macrostr(s) #s
 #define VERSION_STRING_BUILDER(major, minor, patch)		macrostr(major) "." macrostr(minor) "." macrostr(patch)
diff --git a/src/test_and_debug.c b/src/test_and_debug.c
index 108a91e..dbbc3f9 100644
--- a/src/test_and_debug.c
+++ b/src/test_and_debug.c
@@ -329,6 +329,16 @@ static void task_fan(void* pvParameters) {
 		notify_cmd = 0;
 		notify_arg = 0;

+		// Fan kickstart: a stationary fan won't reliably begin spinning below a
+		// minimum PWM (low-end stiction), so the slow 1%/10ms ramp from a dead
+		// stop can stall. Jump straight to the minimum stable duty first so a
+		// doze-wake re-spins cleanly, then ramp. (Distinct from the fan-sticktion
+		// 100%-blast hack, which targets debris-jammed fans.)
+		if((current_fan_speed == 0) && (next_fan_speed > 0)) {
+			current_fan_speed = (next_fan_speed < FAN_MIN_START) ? next_fan_speed : FAN_MIN_START;
+			set_fan_pwm(current_fan_speed);
+		}
+
 		// Fan speed ramp up...
 		while((notify_val == 0) && (current_fan_speed < next_fan_speed)) {
 			set_fan_pwm(++current_fan_speed);
diff --git a/src/test_and_debug.h b/src/test_and_debug.h
index 291d9af..8a566a6 100644
--- a/src/test_and_debug.h
+++ b/src/test_and_debug.h
@@ -27,6 +27,9 @@
 #define LED_TASK_CODE_BLINKING		(0x08)

 #define FAN_UPDATE_DELAY		(10) // Fan updates PWM at 100Hz
+#define FAN_MIN_START			(35) // Min PWM a stationary fan reliably starts at (low-end
+									     // stiction). Jump here before ramping from a dead stop.
+									     // Just under HMDUtility fan_speed_min; tune by ear.
 #define FAN_TASK_CODE_NEW_SPEED		(0x100) // Lowest byte is the new speed
 #define FAN_TASK_CODE_STOP			(0x200)
 #define FAN_TASK_CODE_SPEED_IMM		(0x300) // Change immediately to this speed. May enter debug state
diff --git a/src/video_proc.h b/src/video_proc.h
index 10f9ca7..611e963 100644
--- a/src/video_proc.h
+++ b/src/video_proc.h
@@ -21,17 +21,36 @@
 #define VIDEOPROC_TRACK_UART_ACTIVITY				(1u << 10u)
 #define VIDEOPROC_VXR_POWER_DOWN					(1u << 11u)
 #define VIDEOPROC_VXR_POWER_UP						(1u << 12u)
+#define VIDEOPROC_DOZE_SLEEP						(1u << 13u) // enter doze (warm shallow sleep)
+#define VIDEOPROC_DOZE_WAKE							(1u << 14u) // wake from doze
 #define VIDEOPROC_STACK_OVERFLOW					(1u << 20u)

 #define VIDEOPROC_NOTIFY_TASKBITS		(VIDEOPROC_NOTIFY_VXR_RESET | VIDEOPROC_NOTIFY_DEBUG_PROX_DISABLED \
 										| VIDEOPROC_COLORBAR | VIDEOPROC_DISP_POWER_DISABLE | VIDEOPROC_DISP_POWER_ENABLE \
 										| VIDEOPROC_NEW_FAN_SPEED | VIDEOPROC_EYETRACKING_ERROR | VIDEOPROC_EYETRACKING_ERROR_CLEAR \
-										| VIDEOPROC_TRACK_UART_ACTIVITY | VIDEOPROC_VXR_POWER_DOWN | VIDEOPROC_VXR_POWER_UP )
+										| VIDEOPROC_TRACK_UART_ACTIVITY | VIDEOPROC_VXR_POWER_DOWN | VIDEOPROC_VXR_POWER_UP \
+										| VIDEOPROC_DOZE_SLEEP | VIDEOPROC_DOZE_WAKE )

-#define DEFAULT_OLED_BRIGHTNESS			(0x010Au) // 266d, or about 26% (max is 0x03FF = 1023d)
+#define DEFAULT_OLED_BRIGHTNESS			(0x010Au) // 266d, or about 26% (max is 0x03FF = 1023d)

 #define NUM_BRIGHTNESS_RETRIES			(3u) // Number of times to retry setting brightness if it failed

+// Doze (warm shallow sleep, ADR-0005): on doze-sleep the brightness is swept to
+// the floor over ~DOZE_SWEEP_MS, then the OLED display is turned off (true
+// black); doze-wake reverses it. The sweep advances one step per video-task
+// iteration (VIDEO_TASK_DELAY) so it is naturally target-seeking: a wake that
+// arrives mid-fade just flips the phase and the next iteration ramps back up.
+#define DOZE_BRIGHTNESS_FLOOR			(0u)		// register 0 = lowest emission
+#define DOZE_SWEEP_MS					(300u)		// target fade duration
+#define DOZE_SWEEP_STEPS				(DOZE_SWEEP_MS / VIDEO_TASK_DELAY) // ~15 @ 20ms
+// The OLED is perceptually gamma'd: a register-linear sweep reads as a fast
+// jump then a slow crawl (camera-confirmed, docs/photon-latency-bench.md). The
+// sweep intent stays linear (so the target-seeking reversal logic is simple),
+// but the register actually written is gamma-mapped so perceived brightness
+// ramps evenly. >1 spends more of the sweep at low register (where the eye is
+// most sensitive). Camera luminance fit ~2.9; default a touch gentler. By ear.
+#define DOZE_SWEEP_GAMMA				(2.2f)
+
 // Definitions for the display status
 #define VIDSTATUS_DISPLAYS_ON						(1u << 0u)
 #define VIDSTATUS_PROX_ON							(1u << 1u)
@@ -62,6 +81,7 @@ typedef enum FATP_Mode{

 TaskHandle_t video_proc_task_handle(void);
 bool video_is_enabled(void);
+bool video_is_dozing(void);
 bool displays_are_on(void);
 void start_video_task(void);
 void task_video_proc(void *pvParameters);


Prompt:
<task-notification>
<task-id>bgsw1alfj</task-id>
<tool-use-id>toolu_01Ty9UFprNszZCCuYYpbkZVY</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-sauna\d3fb9be1-d61d-4f88-b77c-2a2a282b11fd\tasks\bgsw1alfj.output</output-file>
<status>completed</status>
<summary>Background command "Full video_proc.c diff head" completed (exit code 0)</summary>
</task-notification>


Non-doze-gated change #1: **fan kickstart** in `task_fan` (runs on any 0→N fan change, addon-driven too) — but it's just a PWM jump, unlikely to wedge HID/prox. Need the `video_proc.c` task-loop body — the notify processing each iteration is where addon paths live.


1	269 /tmp/vproc_full.diff
2	===FIRST 120===
3	diff --git a/src/video_proc.c b/src/video_proc.c
4	index 4e2022a..1f68d40 100644
5	--- a/src/video_proc.c
6	+++ b/src/video_proc.c
7	@@ -7,6 +7,7 @@
8	  */
9
10	 #include <asf.h>
11	+#include <math.h>
12	 #include <stdbool.h>
13	 #include <string.h>
14	 #include "oled_control.h"
15	@@ -42,6 +43,28 @@ Video_Format_T vid_fmt;
16
17	 uint16_t oled_current_brightness = DEFAULT_OLED_BRIGHTNESS;
18
19	+// Doze state machine (ADR-0005). doze_active is the doze-latch: while set it
20	+// overrides the prox emission gate (so a doze-while-worn stays dark) and is
21	+// force-cleared on video loss (so the headset never stays latched without
22	+// video). The brightness fade runs in the stepper inside the video task loop.
23	+typedef enum {
24	+	DOZE_NONE = 0,	// fully awake, normal prox-gated operation
25	+	DOZE_SLEEPING,	// sweeping brightness down to the floor
26	+	DOZE_ASLEEP,	// at the floor, OLED display turned off (true black)
27	+	DOZE_WAKING		// OLED display back on, sweeping brightness up
28	+} Doze_Phase_T;
29	+static Doze_Phase_T doze_phase = DOZE_NONE;
30	+bool doze_active = false;					// the doze-latch
31	+static uint16_t doze_live_brightness = 0;	// brightness currently driven mid-sweep
32	+static uint16_t doze_step = 1;				// per-iteration sweep increment
33	+// Born-doze wake race: the host may send 'h' (display-wake) AFTER arming the
34	+// latch but BEFORE the bringup has settled into DOZE_ASLEEP (it gates the wake
35	+// on DSC/link-up, which the firmware reaches earlier than the doze settle). A
36	+// wake in that window used to disarm the latch -> panels came up lit with no
37	+// fade. Instead we remember it here and the bringup settles straight into the
38	+// wake fade, so the host need not time 'h' precisely.
39	+static bool doze_wake_pending = false;
40	+
41	 bool VXR_Powered_Down = true;
42	 bool VXR_Startup_Succeeded = false;
43	 TimerHandle_t vxr_power_down_timer;
44	@@ -191,6 +214,46 @@ bool video_is_enabled(void) {
45	 	return video_enabled;
46	 }
47
48	+bool video_is_dozing(void) {
49	+	return doze_active;
50	+}
51	+
52	+// Per-iteration brightness increment for a ~DOZE_SWEEP_MS sweep between the
53	+// floor and 'target'. Recomputed at each transition so a user-raised brightness
54	+// still fades in roughly the same wall-clock time.
55	+static void doze_set_step(uint16_t target) {
56	+	uint16_t span = (target > DOZE_BRIGHTNESS_FLOOR) ? (target - DOZE_BRIGHTNESS_FLOOR) : 1u;
57	+	doze_step = span / DOZE_SWEEP_STEPS;
58	+	if(doze_step == 0) doze_step = 1u;
59	+}
60	+
61	+// Map the linear sweep intent (DOZE_BRIGHTNESS_FLOOR..oled_current_brightness)
62	+// to a perceptually eased OLED register via a gamma curve, so the visible ramp
63	+// is even rather than a fast jump + slow crawl. The stepper keeps advancing the
64	+// LINEAR intent (target-seeking reversal stays trivial); only the written value
65	+// is curved here.
66	+static uint16_t doze_eased(uint16_t linear) {
67	+	uint16_t full = oled_current_brightness;
68	+	if(full <= DOZE_BRIGHTNESS_FLOOR || linear <= DOZE_BRIGHTNESS_FLOOR)
69	+		return DOZE_BRIGHTNESS_FLOOR;
70	+	if(linear >= full) return full;
71	+	uint16_t span = full - DOZE_BRIGHTNESS_FLOOR;
72	+	float frac = (float)(linear - DOZE_BRIGHTNESS_FLOOR) / (float)span;
73	+	float curved = powf(frac, DOZE_SWEEP_GAMMA);
74	+	return (uint16_t)(DOZE_BRIGHTNESS_FLOOR + (uint16_t)(curved * (float)span + 0.5f));
75	+}
76	+
77	+// Drive both panels to 'b', retrying the I2C write like the prox dimmers do.
78	+static void doze_write_brightness(uint16_t b) {
79	+	uint_fast8_t i;
80	+	for(i = 0; i < NUM_BRIGHTNESS_RETRIES; i++) {
81	+		if(OLED_set_brightness(OLED_LEFT, b, pulse_emission) == pdPASS) break;
82	+	}
83	+	for(i = 0; i < NUM_BRIGHTNESS_RETRIES; i++) {
84	+		if(OLED_set_brightness(OLED_RIGHT, b, pulse_emission) == pdPASS) break;
85	+	}
86	+}
87	+
88	 void start_video_task(void) {
89	 	if(xTaskCreate(task_video_proc, "video_proc", TASK_VIDEO_PROC_STACK_SIZE, NULL,
90	 	TASK_VIDEO_PROC_PRIORITY, &xVideoProcTaskHandle) != pdPASS) {
91	@@ -530,12 +593,79 @@ void task_video_proc(void *pvParameters) {
92	 			}
93	 		}
94
95	+		// Doze sleep (ADR-0005): set the doze-latch and begin the fade to the
96	+		// floor. The video pipeline stays up (VXR locked, host still presenting);
97	+		// fan -> Idle and LED -> breathing decouple from video so the headset
98	+		// looks and sounds asleep. The fade and the display-off at the floor run
99	+		// in the stepper below. Only valid while video is up; reject otherwise.
100	+		if((notificationValue & VIDEOPROC_DOZE_SLEEP) != 0) {
101	+			if(!video_enabled) {
102	+				// Born-dozing (ADR-0005): arm the latch before video is up. The
103	+				// bringup path settles straight into doze-dark when video arrives.
104	+				// Nothing to sweep yet, so leave the phase at NONE.
105	+				doze_active = true;
106	+				send_code_to_hid_task(HID_REPORT_TASK_CODE_FOR_SUCCESS);
107	+			} else if(doze_phase == DOZE_NONE || doze_phase == DOZE_WAKING) {
108	+				doze_active = true;
109	+				// Start the fade from where we are: full when fully awake, or the
110	+				// current live value when reversing a wake (no jump-to-bright flash).
111	+				if(doze_phase == DOZE_NONE)
112	+					doze_live_brightness = oled_current_brightness;
113	+				doze_set_step(oled_current_brightness);
114	+				doze_phase = DOZE_SLEEPING;
115	+				// Doze owns the display now; stop the prox 5-minute display-off timer.
116	+				xTimerStop(prox_timeout_timer, 10);
117	+				prox_timeout_expired = false;
118	+				xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_OFF, eSetValueWithOverwrite);
119	+				xTaskNotify(get_led_task_handle(), LED_TASK_CODE_BREATHING, eSetBits);
120	+				send_code_to_hid_task(HID_REPORT_TASK_CODE_FOR_SUCCESS);
121	+			} else {
122	+				send_code_to_hid_task(HID_REPORT_TASK_CODE_FOR_ERROR); // already asleep/sleeping
123

269 /tmp/vproc_full.diff
===FIRST 120===
diff --git a/src/video_proc.c b/src/video_proc.c
index 4e2022a..1f68d40 100644
--- a/src/video_proc.c
+++ b/src/video_proc.c
@@ -7,6 +7,7 @@
  */

 #include <asf.h>
+#include <math.h>
 #include <stdbool.h>
 #include <string.h>
 #include "oled_control.h"
@@ -42,6 +43,28 @@ Video_Format_T vid_fmt;

 uint16_t oled_current_brightness = DEFAULT_OLED_BRIGHTNESS;

+// Doze state machine (ADR-0005). doze_active is the doze-latch: while set it
+// overrides the prox emission gate (so a doze-while-worn stays dark) and is
+// force-cleared on video loss (so the headset never stays latched without
+// video). The brightness fade runs in the stepper inside the video task loop.
+typedef enum {
+	DOZE_NONE = 0,	// fully awake, normal prox-gated operation
+	DOZE_SLEEPING,	// sweeping brightness down to the floor
+	DOZE_ASLEEP,	// at the floor, OLED display turned off (true black)
+	DOZE_WAKING		// OLED display back on, sweeping brightness up
+} Doze_Phase_T;
+static Doze_Phase_T doze_phase = DOZE_NONE;
+bool doze_active = false;					// the doze-latch
+static uint16_t doze_live_brightness = 0;	// brightness currently driven mid-sweep
+static uint16_t doze_step = 1;				// per-iteration sweep increment
+// Born-doze wake race: the host may send 'h' (display-wake) AFTER arming the
+// latch but BEFORE the bringup has settled into DOZE_ASLEEP (it gates the wake
+// on DSC/link-up, which the firmware reaches earlier than the doze settle). A
+// wake in that window used to disarm the latch -> panels came up lit with no
+// fade. Instead we remember it here and the bringup settles straight into the
+// wake fade, so the host need not time 'h' precisely.
+static bool doze_wake_pending = false;
+
 bool VXR_Powered_Down = true;
 bool VXR_Startup_Succeeded = false;
 TimerHandle_t vxr_power_down_timer;
@@ -191,6 +214,46 @@ bool video_is_enabled(void) {
 	return video_enabled;
 }

+bool video_is_dozing(void) {
+	return doze_active;
+}
+
+// Per-iteration brightness increment for a ~DOZE_SWEEP_MS sweep between the
+// floor and 'target'. Recomputed at each transition so a user-raised brightness
+// still fades in roughly the same wall-clock time.
+static void doze_set_step(uint16_t target) {
+	uint16_t span = (target > DOZE_BRIGHTNESS_FLOOR) ? (target - DOZE_BRIGHTNESS_FLOOR) : 1u;
+	doze_step = span / DOZE_SWEEP_STEPS;
+	if(doze_step == 0) doze_step = 1u;
+}
+
+// Map the linear sweep intent (DOZE_BRIGHTNESS_FLOOR..oled_current_brightness)
+// to a perceptually eased OLED register via a gamma curve, so the visible ramp
+// is even rather than a fast jump + slow crawl. The stepper keeps advancing the
+// LINEAR intent (target-seeking reversal stays trivial); only the written value
+// is curved here.
+static uint16_t doze_eased(uint16_t linear) {
+	uint16_t full = oled_current_brightness;
+	if(full <= DOZE_BRIGHTNESS_FLOOR || linear <= DOZE_BRIGHTNESS_FLOOR)
+		return DOZE_BRIGHTNESS_FLOOR;
+	if(linear >= full) return full;
+	uint16_t span = full - DOZE_BRIGHTNESS_FLOOR;
+	float frac = (float)(linear - DOZE_BRIGHTNESS_FLOOR) / (float)span;
+	float curved = powf(frac, DOZE_SWEEP_GAMMA);
+	return (uint16_t)(DOZE_BRIGHTNESS_FLOOR + (uint16_t)(curved * (float)span + 0.5f));
+}
+
+// Drive both panels to 'b', retrying the I2C write like the prox dimmers do.
+static void doze_write_brightness(uint16_t b) {
+	uint_fast8_t i;
+	for(i = 0; i < NUM_BRIGHTNESS_RETRIES; i++) {
+		if(OLED_set_brightness(OLED_LEFT, b, pulse_emission) == pdPASS) break;
+	}
+	for(i = 0; i < NUM_BRIGHTNESS_RETRIES; i++) {
+		if(OLED_set_brightness(OLED_RIGHT, b, pulse_emission) == pdPASS) break;
+	}
+}
+
 void start_video_task(void) {
 	if(xTaskCreate(task_video_proc, "video_proc", TASK_VIDEO_PROC_STACK_SIZE, NULL,
 	TASK_VIDEO_PROC_PRIORITY, &xVideoProcTaskHandle) != pdPASS) {
@@ -530,12 +593,79 @@ void task_video_proc(void *pvParameters) {
 			}
 		}

+		// Doze sleep (ADR-0005): set the doze-latch and begin the fade to the
+		// floor. The video pipeline stays up (VXR locked, host still presenting);
+		// fan -> Idle and LED -> breathing decouple from video so the headset
+		// looks and sounds asleep. The fade and the display-off at the floor run
+		// in the stepper below. Only valid while video is up; reject otherwise.
+		if((notificationValue & VIDEOPROC_DOZE_SLEEP) != 0) {
+			if(!video_enabled) {
+				// Born-dozing (ADR-0005): arm the latch before video is up. The
+				// bringup path settles straight into doze-dark when video arrives.
+				// Nothing to sweep yet, so leave the phase at NONE.
+				doze_active = true;
+				send_code_to_hid_task(HID_REPORT_TASK_CODE_FOR_SUCCESS);
+			} else if(doze_phase == DOZE_NONE || doze_phase == DOZE_WAKING) {
+				doze_active = true;
+				// Start the fade from where we are: full when fully awake, or the
+				// current live value when reversing a wake (no jump-to-bright flash).
+				if(doze_phase == DOZE_NONE)
+					doze_live_brightness = oled_current_brightness;
+				doze_set_step(oled_current_brightness);
+				doze_phase = DOZE_SLEEPING;
+				// Doze owns the display now; stop the prox 5-minute display-off timer.
+				xTimerStop(prox_timeout_timer, 10);
+				prox_timeout_expired = false;
+				xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_OFF, eSetValueWithOverwrite);
+				xTaskNotify(get_led_task_handle(), LED_TASK_CODE_BREATHING, eSetBits);
+				send_code_to_hid_task(HID_REPORT_TASK_CODE_FOR_SUCCESS);
+			} else {
+				send_code_to_hid_task(HID_REPORT_TASK_CODE_FOR_ERROR); // already asleep/sleeping


Rest of video_proc.c diff


Command running in background with ID: buhl97vwb. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-sauna\d3fb9be1-d61d-4f88-b77c-2a2a282b11fd\tasks\buhl97vwb.output. You will be notified when it completes. To check interim output, use Read on that file path.


<task-notification>
<task-id>buhl97vwb</task-id>
<tool-use-id>toolu_015Vz886hdzvsXxfbpxyPk5r</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-sauna\d3fb9be1-d61d-4f88-b77c-2a2a282b11fd\tasks\buhl97vwb.output</output-file>
<status>completed</status>
<summary>Background command "Rest of video_proc.c diff" completed (exit code 0)</summary>
</task-notification>


1	+				send_code_to_hid_task(HID_REPORT_TASK_CODE_FOR_ERROR); // already asleep/sleeping
2	+			}
3	+		}
4	+
5	+		// Doze wake: reverse the choreography. Re-enable the OLED display if it
6	+		// had reached true-black, then sweep brightness back up and re-couple the
7	+		// fan/LED to video. The latch clears when the sweep reaches full (below).
8	+		if((notificationValue & VIDEOPROC_DOZE_WAKE) != 0) {
9	+			if(doze_phase == DOZE_ASLEEP || doze_phase == DOZE_SLEEPING) {
10	+				if(doze_phase == DOZE_ASLEEP) {
11	+					OLED_display_on_off(OLED_LEFT, true);
12	+					OLED_display_on_off(OLED_RIGHT, true);
13	+					vTaskDelay(OLED_POST_INIT_DELAY);
14	+					OLED_dimming_post_init(OLED_LEFT);
15	+					OLED_dimming_post_init(OLED_RIGHT);
16	+				}
17	+				displays_on = true;
18	+				doze_set_step(oled_current_brightness);
19	+				doze_phase = DOZE_WAKING;
20	+				xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_ON, eSetValueWithOverwrite);
21	+				xTaskNotify(get_led_task_handle(), LED_TASK_CODE_STATIC_COLOR, eSetBits);
22	+				send_code_to_hid_task(HID_REPORT_TASK_CODE_FOR_SUCCESS);
23	+			} else if(doze_active && doze_phase == DOZE_NONE) {
24	+				// Latch armed for born-dozing but the bringup has not settled into
25	+				// DOZE_ASLEEP yet (host's wake raced ahead of the settle). Remember
26	+				// the wake; the born-doze settle below honors it and comes up
27	+				// straight into the fade-in instead of dark-then-wake.
28	+				doze_wake_pending = true;
29	+				send_code_to_hid_task(HID_REPORT_TASK_CODE_FOR_SUCCESS);
30	+			} else {
31	+				send_code_to_hid_task(HID_REPORT_TASK_CODE_FOR_ERROR);
32	+			}
33	+		}
34	+
35	 		// Check if we can disable the OLED panels (video was enabled, now it isn't)
36	 		if(video_enabled) {
37	 			if(!(main_left_display_reset_status()) || !(main_right_display_reset_status())) {
38	 				video_enabled = false;
39	 				dsc_enabled = false;
40	 				displays_on = false;
41	+				// Force-clear the doze-latch on video loss (ADR-0005 safety: the
42	+				// headset must never stay latched-dozing without video). Degrades
43	+				// to the normal dark "no video" state, not stuck dozed.
44	+				doze_active = false;
45	+				doze_phase = DOZE_NONE;
46	+				doze_wake_pending = false;
47	 				// Disable fan and led
48	 				xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_OFF, eSetValueWithOverwrite);
49	 				xTaskNotify(get_led_task_handle(), LED_TASK_CODE_BREATHING, eSetBits);
50	@@ -587,8 +717,17 @@ void task_video_proc(void *pvParameters) {
51	 						// Ramp fan speed up to final value
52	 						// Ramp speed is set to 1% per 10ms.
53	 						// Could take up to 1 sec if startup is set to 100%
54	-						xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_ON, eSetValueWithOverwrite);
55	-						xTaskNotify(get_led_task_handle(), LED_TASK_CODE_STATIC_COLOR, eSetBits);
56	+						// Born-dozing (ADR-0005): when the host armed the doze-latch
57	+						// before video, SKIP turning the fan/LED on at all. The
58	+						// doze_active settle below used to turn them ON here and revert
59	+						// them ~64 ms later, but the fan physically spins up and the LED
60	+						// flashes solid in that window — the visible boot blip. Gating
61	+						// here means the headset comes up doze-dark with zero fan/LED
62	+						// activity.
63	+						if(!doze_active) {
64	+							xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_ON, eSetValueWithOverwrite);
65	+							xTaskNotify(get_led_task_handle(), LED_TASK_CODE_STATIC_COLOR, eSetBits);
66	+						}
67
68	 						OLED_Startup(OLED_LEFT, vidsel);
69	 						OLED_Startup(OLED_RIGHT, vidsel);
70	@@ -646,8 +785,36 @@ void task_video_proc(void *pvParameters) {
71	 						OLED_dimming_post_init(OLED_LEFT);
72	 						OLED_dimming_post_init(OLED_RIGHT);
73
74	+						// Born-dozing (ADR-0005): if the host latched doze before video
75	+						// came up (launch / SteamVR-exit while not worn), settle straight
76	+						// into doze-dark instead of lighting. Brightness is already at the
77	+						// floor from the init above, so just cut to true black and idle the
78	+						// fan / breathe the LED (bringup set them to video-on above).
79	+						if(doze_active) {
80	+							doze_live_brightness = DOZE_BRIGHTNESS_FLOOR;
81	+							if(doze_wake_pending) {
82	+								// Host already requested wake (its 'h' raced ahead of this
83	+								// settle). Come up straight into the wake fade: displays are
84	+								// ON from the bringup above and brightness is at the floor, so
85	+								// just enter WAKING and let the stepper ramp up. Fan/LED to
86	+								// video (the bringup skipped them while doze_active was set).
87	+								doze_wake_pending = false;
88	+								displays_on = true;
89	+								doze_set_step(oled_current_brightness);
90	+								doze_phase = DOZE_WAKING;
91	+								xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_ON, eSetValueWithOverwrite);
92	+								xTaskNotify(get_led_task_handle(), LED_TASK_CODE_STATIC_COLOR, eSetBits);
93	+							} else {
94	+								OLED_display_on_off(OLED_LEFT, false);
95	+								OLED_display_on_off(OLED_RIGHT, false);
96	+								displays_on = false;
97	+								doze_phase = DOZE_ASLEEP;
98	+								xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_OFF, eSetValueWithOverwrite);
99	+								xTaskNotify(get_led_task_handle(), LED_TASK_CODE_BREATHING, eSetBits);
100	+							}
101	+						}
102	 						// If prox is detected, turn displays on
103	-						if(prox_enabled && prox_is_connected() && debug_prox_enabled) {
104	+						else if(prox_enabled && prox_is_connected() && debug_prox_enabled) {
105	 							if(proxDisplaysOn) {
106	 								displays_on = true;
107	 								video_displays_on_prox();
108	@@ -681,8 +848,41 @@ void task_video_proc(void *pvParameters) {
109	 		// 	dsc_enabled = false;
110	 		// }
111
112	+		// Doze brightness stepper (ADR-0005): advance one step per loop iteration
113	+		// toward the current phase's target. Because it re-reads doze_phase each
114	+		// pass it is naturally target-seeking - a doze-wake mid-fade flips the
115	+		// phase and the next iteration ramps the other way from wherever it was.
116	+		if(doze_phase == DOZE_SLEEPING) {
117	+			if(doze_live_brightness > (DOZE_BRIGHTNESS_FLOOR + doze_step))
118	+				doze_live_brightness -= doze_step;
119	+			else
120	+				doze_live_brightness = DOZE_BRIGHTNESS_FLOOR;
121	+			doze_write_brightness(doze_eased(doze_live_brightness));
122	+			displays_on = false;
123	+			if(doze_live_brightness == DOZE_BRIGHTNESS_FLOOR) {
124	+				// Reached the floor: cut to true black and settle asleep.
125	+				OLED_display_on_off(OLED_LEFT, false);
126	+				OLED_display_on_off(OLED_RIGHT, false);
127	+				doze_phase = DOZE_ASLEEP;
128	+			}
129	+		} else if(doze_phase == DOZE_WAKING) {
130	+			if((uint32_t)doze_live_brightness + doze_step < oled_current_brightness)
131	+				doze_live_brightness += doze_step;
132	+			else
133	+				doze_live_brightness = oled_current_brightness;
134	+			doze_write_brightness(doze_eased(doze_live_brightness));
135	+			if(doze_live_brightness == oled_current_brightness) {
136	+				// Back to full brightness: clear the latch, prox resumes ownership.
137	+				doze_phase = DOZE_NONE;
138	+				doze_active = false;
139	+			}
140	+		}
141	+
142	 		// Check proximity sensor - need to turn on or off displays?
143	-		if(prox_enabled && debug_prox_enabled) {
144	+		// Suppressed while the doze-latch is set: doze owns the display, and a
145	+		// don out of doze is the host sending doze-wake (host = single wake
146	+		// authority, ADR-0005), not the firmware lighting up on its own prox read.
147	+		if(prox_enabled && debug_prox_enabled && !doze_active) {
148	 			if(proxDisplaysOn) {
149	 				if(false == prox_hmd_on_person()) {
150	 					// distance sensor below threshold, now turn off the displays
151

+				send_code_to_hid_task(HID_REPORT_TASK_CODE_FOR_ERROR); // already asleep/sleeping
+			}
+		}
+
+		// Doze wake: reverse the choreography. Re-enable the OLED display if it
+		// had reached true-black, then sweep brightness back up and re-couple the
+		// fan/LED to video. The latch clears when the sweep reaches full (below).
+		if((notificationValue & VIDEOPROC_DOZE_WAKE) != 0) {
+			if(doze_phase == DOZE_ASLEEP || doze_phase == DOZE_SLEEPING) {
+				if(doze_phase == DOZE_ASLEEP) {
+					OLED_display_on_off(OLED_LEFT, true);
+					OLED_display_on_off(OLED_RIGHT, true);
+					vTaskDelay(OLED_POST_INIT_DELAY);
+					OLED_dimming_post_init(OLED_LEFT);
+					OLED_dimming_post_init(OLED_RIGHT);
+				}
+				displays_on = true;
+				doze_set_step(oled_current_brightness);
+				doze_phase = DOZE_WAKING;
+				xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_ON, eSetValueWithOverwrite);
+				xTaskNotify(get_led_task_handle(), LED_TASK_CODE_STATIC_COLOR, eSetBits);
+				send_code_to_hid_task(HID_REPORT_TASK_CODE_FOR_SUCCESS);
+			} else if(doze_active && doze_phase == DOZE_NONE) {
+				// Latch armed for born-dozing but the bringup has not settled into
+				// DOZE_ASLEEP yet (host's wake raced ahead of the settle). Remember
+				// the wake; the born-doze settle below honors it and comes up
+				// straight into the fade-in instead of dark-then-wake.
+				doze_wake_pending = true;
+				send_code_to_hid_task(HID_REPORT_TASK_CODE_FOR_SUCCESS);
+			} else {
+				send_code_to_hid_task(HID_REPORT_TASK_CODE_FOR_ERROR);
+			}
+		}
+
 		// Check if we can disable the OLED panels (video was enabled, now it isn't)
 		if(video_enabled) {
 			if(!(main_left_display_reset_status()) || !(main_right_display_reset_status())) {
 				video_enabled = false;
 				dsc_enabled = false;
 				displays_on = false;
+				// Force-clear the doze-latch on video loss (ADR-0005 safety: the
+				// headset must never stay latched-dozing without video). Degrades
+				// to the normal dark "no video" state, not stuck dozed.
+				doze_active = false;
+				doze_phase = DOZE_NONE;
+				doze_wake_pending = false;
 				// Disable fan and led
 				xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_OFF, eSetValueWithOverwrite);
 				xTaskNotify(get_led_task_handle(), LED_TASK_CODE_BREATHING, eSetBits);
@@ -587,8 +717,17 @@ void task_video_proc(void *pvParameters) {
 						// Ramp fan speed up to final value
 						// Ramp speed is set to 1% per 10ms.
 						// Could take up to 1 sec if startup is set to 100%
-						xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_ON, eSetValueWithOverwrite);
-						xTaskNotify(get_led_task_handle(), LED_TASK_CODE_STATIC_COLOR, eSetBits);
+						// Born-dozing (ADR-0005): when the host armed the doze-latch
+						// before video, SKIP turning the fan/LED on at all. The
+						// doze_active settle below used to turn them ON here and revert
+						// them ~64 ms later, but the fan physically spins up and the LED
+						// flashes solid in that window — the visible boot blip. Gating
+						// here means the headset comes up doze-dark with zero fan/LED
+						// activity.
+						if(!doze_active) {
+							xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_ON, eSetValueWithOverwrite);
+							xTaskNotify(get_led_task_handle(), LED_TASK_CODE_STATIC_COLOR, eSetBits);
+						}

 						OLED_Startup(OLED_LEFT, vidsel);
 						OLED_Startup(OLED_RIGHT, vidsel);
@@ -646,8 +785,36 @@ void task_video_proc(void *pvParameters) {
 						OLED_dimming_post_init(OLED_LEFT);
 						OLED_dimming_post_init(OLED_RIGHT);

+						// Born-dozing (ADR-0005): if the host latched doze before video
+						// came up (launch / SteamVR-exit while not worn), settle straight
+						// into doze-dark instead of lighting. Brightness is already at the
+						// floor from the init above, so just cut to true black and idle the
+						// fan / breathe the LED (bringup set them to video-on above).
+						if(doze_active) {
+							doze_live_brightness = DOZE_BRIGHTNESS_FLOOR;
+							if(doze_wake_pending) {
+								// Host already requested wake (its 'h' raced ahead of this
+								// settle). Come up straight into the wake fade: displays are
+								// ON from the bringup above and brightness is at the floor, so
+								// just enter WAKING and let the stepper ramp up. Fan/LED to
+								// video (the bringup skipped them while doze_active was set).
+								doze_wake_pending = false;
+								displays_on = true;
+								doze_set_step(oled_current_brightness);
+								doze_phase = DOZE_WAKING;
+								xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_ON, eSetValueWithOverwrite);
+								xTaskNotify(get_led_task_handle(), LED_TASK_CODE_STATIC_COLOR, eSetBits);
+							} else {
+								OLED_display_on_off(OLED_LEFT, false);
+								OLED_display_on_off(OLED_RIGHT, false);
+								displays_on = false;
+								doze_phase = DOZE_ASLEEP;
+								xTaskNotify(get_fan_task_handle(), FAN_TASK_CODE_VIDEO_OFF, eSetValueWithOverwrite);
+								xTaskNotify(get_led_task_handle(), LED_TASK_CODE_BREATHING, eSetBits);
+							}
+						}
 						// If prox is detected, turn displays on
-						if(prox_enabled && prox_is_connected() && debug_prox_enabled) {
+						else if(prox_enabled && prox_is_connected() && debug_prox_enabled) {
 							if(proxDisplaysOn) {
 								displays_on = true;
 								video_displays_on_prox();
@@ -681,8 +848,41 @@ void task_video_proc(void *pvParameters) {
 		// 	dsc_enabled = false;
 		// }

+		// Doze brightness stepper (ADR-0005): advance one step per loop iteration
+		// toward the current phase's target. Because it re-reads doze_phase each
+		// pass it is naturally target-seeking - a doze-wake mid-fade flips the
+		// phase and the next iteration ramps the other way from wherever it was.
+		if(doze_phase == DOZE_SLEEPING) {
+			if(doze_live_brightness > (DOZE_BRIGHTNESS_FLOOR + doze_step))
+				doze_live_brightness -= doze_step;
+			else
+				doze_live_brightness = DOZE_BRIGHTNESS_FLOOR;
+			doze_write_brightness(doze_eased(doze_live_brightness));
+			displays_on = false;
+			if(doze_live_brightness == DOZE_BRIGHTNESS_FLOOR) {
+				// Reached the floor: cut to true black and settle asleep.
+				OLED_display_on_off(OLED_LEFT, false);
+				OLED_display_on_off(OLED_RIGHT, false);
+				doze_phase = DOZE_ASLEEP;
+			}
+		} else if(doze_phase == DOZE_WAKING) {
+			if((uint32_t)doze_live_brightness + doze_step < oled_current_brightness)
+				doze_live_brightness += doze_step;
+			else
+				doze_live_brightness = oled_current_brightness;
+			doze_write_brightness(doze_eased(doze_live_brightness));
+			if(doze_live_brightness == oled_current_brightness) {
+				// Back to full brightness: clear the latch, prox resumes ownership.
+				doze_phase = DOZE_NONE;
+				doze_active = false;
+			}
+		}
+
 		// Check proximity sensor - need to turn on or off displays?
-		if(prox_enabled && debug_prox_enabled) {
+		// Suppressed while the doze-latch is set: doze owns the display, and a
+		// don out of doze is the host sending doze-wake (host = single wake
+		// authority, ADR-0005), not the firmware lighting up on its own prox read.
+		if(prox_enabled && debug_prox_enabled && !doze_active) {
 			if(proxDisplaysOn) {
 				if(false == prox_hmd_on_person()) {
 					// distance sensor below threshold, now turn off the displays


Prompt:
<task-notification>
<task-id>buhl97vwb</task-id>
<tool-use-id>toolu_015Vz886hdzvsXxfbpxyPk5r</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-sauna\d3fb9be1-d61d-4f88-b77c-2a2a282b11fd\tasks\buhl97vwb.output</output-file>
<status>completed</status>
<summary>Background command "Rest of video_proc.c diff" completed (exit code 0)</summary>
</task-notification>


Now I have the complete doze diff. Decisive analysis: **when doze is never invoked** (`doze_active` stays false), every doze branch in `video_proc.c` is inert or falls through to byte-identical original behavior. The video task logic is functionally unchanged on the addon-only path.

So the wedge can't come from the doze *state machine*. That leaves one doze change that fires **without anyone sending a doze command** — the new HID dispatch codes. The addon talks to `0101` via feature reports. Doze claimed two new first-byte codes: **`'H'` = 0x48** (sleep) and **`'h'` = 0x68** (wake). If BeyondEyetracking/BeyondSteamVR already send feature reports whose `report[0]` is 0x48 or 0x68 for their *own* purposes, then on 0.4.x the firmware now **hijacks them as doze sleep/wake** → spurious dim-to-floor + latch + prox-gate → wedge. Pre-0.4.0 those bytes were no-ops. Fits every symptom (no sauna needed, stuck dim, prox dead, wedge on exit, 0.4.x-only).

Let me verify the collision — full HID code table + how set_feature dispatches.


cd ../beyond_synaptics && echo "===ALL HID_CODE_FOR defines===" && grep -nE "#define +HID_CODE_FOR" src/usbhid_interface.h && echo "" && echo "===0x48 / 0x68 / H / h references anywhere in src===" && grep -rnE "0x48|0x68|'H'|'h'" src/ | grep -iv "0x480|0x481|0x68[0-9a-f]" | head -40

HID code table + collision scan


Command running in background with ID: bjdo4tjwa. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-sauna\d3fb9be1-d61d-4f88-b77c-2a2a282b11fd\tasks\bjdo4tjwa.output. You will be notified when it completes. To check interim output, use Read on that file path.


<task-notification>
<task-id>bjdo4tjwa</task-id>
<tool-use-id>toolu_01H2bbcpscYT4shBqxgTZTcA</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-sauna\d3fb9be1-d61d-4f88-b77c-2a2a282b11fd\tasks\bjdo4tjwa.output</output-file>
<status>completed</status>
<summary>Background command "HID code table + collision scan" completed (exit code 0)</summary>
</task-notification>