소스 검색

Adding ability to Cold Boot a system by emulating a power button press from GPIO pins (#206)

* Add optional host power-switch trigger (Steam short-press, host-off gated)

New pwr_switch.{h,cpp} module (gated behind -DOPK_PWR_SWITCH=1, default off)
pulses a GPIO wired to the host motherboard's power-switch header when the
paired Steam Controller's STEAM button is short-pressed while the host is
off (USB not enumerated). Reuses the existing short-press gesture from
rf_link.cpp's wake-from-suspend path but as an independent, host-off-gated
detector, following the status_led.cpp module template and the
OPK_FACTORY_RESET opt-in-flag precedent.

Documents the feature in ARCHITECTURE.md/CODE_MAP.md and records the
planning session in docs/PWR_SWITCH_DEVLOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add make uf2 target, fix PWR_SWITCH_PIN default, add blink_test bring-up sketch

- Makefile: `make uf2` builds OpenPuck and converts the .hex to a drag-and-drop
  .uf2 in one step (gen_uf2.sh chained onto build), landing in build/openpuck/.
- pwr_switch.h: PWR_SWITCH_PIN default corrected from 5 to 29 (Arduino pin
  number for physical P0.17 / "017" on this SuperMini clone's silkscreen),
  confirmed against real hardware via blink_test.
- blink_test/: standalone bring-up sketch, isolated from the real firmware,
  to verify basic GPIO output on a given pin independent of pwr_switch.cpp's
  gating logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Adding Documentation for ColdBoot

* adding Coldboot documentation and images

* Reduced JPEG size

* ColdBoot: drop blink_test + images, point README at the wiki

Addresses review feedback on #206:
- remove blink_test/blink_test.ino
- remove the docs/ schematic, pinout, and build photos (now hosted on the wiki)
- condense the README section to the capability + build flag, linking to
  the "How to wire up an NRF52 board for cold boot" wiki page

Also drops the now-dangling blink_test reference from pwr_switch.h's
PWR_SWITCH_PIN comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ColdBoot: drop the dev-log doc

ARCHITECTURE.md ("Host power-switch trigger") and CODE_MAP.md already carry
the durable design notes for pwr_switch.{h,cpp}; the session log was
scaffolding and docs/ has no other devlogs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Jari <safijari@isu.edu>
katzmatt 2 달 전
부모
커밋
cd449fc130
8개의 변경된 파일과 193개의 추가작업 그리고 4개의 파일을 삭제
  1. 26 0
      ARCHITECTURE.md
  2. 17 3
      CODE_MAP.md
  3. 14 1
      Makefile
  4. 10 0
      OpenPuck/OpenPuck.ino
  5. 9 0
      OpenPuck/config.h
  6. 81 0
      OpenPuck/pwr_switch.cpp
  7. 29 0
      OpenPuck/pwr_switch.h
  8. 7 0
      README.md

+ 26 - 0
ARCHITECTURE.md

@@ -89,6 +89,7 @@ just points at the `OpenPuck` directory). Modules are layered low → high:
 | `serial_console.{h,cpp}` | The CDC single-letter debug command line. |
 | `wake_hid.{h,cpp}` | A boot-mouse HID interface added to the clean controller modes so the host honors USB remote-wakeup (see "Wake from sleep"). |
 | `status_led.{h,cpp}` | Wake-sent LED indicator: dark in all steady states; flashes 500 ms at each `remoteWakeup()`. Drives both the Feather user LED (P1.15) and the SuperMini clone's blue LED (P0.15); pins/polarity overridable. |
+| `pwr_switch.{h,cpp}` | Optional (`-DOPK_PWR_SWITCH=1`): pulses a GPIO wired to the HOST motherboard's power-switch header on a Steam-button short press while the host is off. See "Host power-switch trigger" below. |
 
 ## The controller abstraction
 
@@ -273,6 +274,31 @@ Windows keeps serving the stale cached descriptor — the change is invisible an
 fix short of the user manually uninstalling the device. So: **whenever you change a mode's USB descriptor, bump
 its `bcdDevice`.** The clean modes were bumped for the wake interface; each `begin()` documents its value.
 
+## Host power-switch trigger
+
+Optional feature (`pwr_switch.{h,cpp}`, compiled out unless built with `-DOPK_PWR_SWITCH=1`, see `config.h`):
+pulses a GPIO wired to the HOST motherboard's front-panel power-switch header, so a Steam Controller can power
+the PC on directly, with no USB host present to talk to yet.
+
+**Gating.** The gesture is the same **Steam-button short press** (down+up within 1 s) the wake-from-sleep path
+already recognizes — but the condition is the opposite: instead of firing while the host is suspended-but-
+attached, this fires only while the host is genuinely **off**: `USBDevice.mounted()` reading `false`
+continuously for `HOST_OFF_DEBOUNCE_MS` (~1.5 s), so a brief re-enumeration blip (e.g. this device's own
+`modeSwitchReboot()`, or a cable reseat) can't false-trigger. It's a deliberately independent detector — reading
+`g_in[]`/`g_connReplyMs[]` directly from its own `pwrSwitchTask()` rather than hooking into `rf_link.cpp` —
+mirroring how `haptics.cpp`'s `hapticOnReconnect` already re-derives the link-up signal independently instead of
+sharing state with `rf_link.cpp`'s own edge detector. A per-slot link-up guard (same 300 ms threshold as
+`anySlotLinkUp()`) keeps a mid-press link drop — which zeroes `g_in[s]` — from reading as a spurious release.
+Once fired, the pin is held for `PULSE_MS` (~300 ms, non-blocking, same timeout idiom as `status_led.cpp`) and a
+`RETRIGGER_COOLDOWN_MS` (~5 s) window guards against a rapid double-fire.
+
+**Assumptions** (both stated as hard preconditions, not detected/enforced): the USB port keeps standby power
+while the host is off, so this firmware — and its RF listen loop — never stops running; and the controller is
+already bonded (there's no pairing UI reachable from an unpowered host).
+
+**Hardware note:** the pin must drive an isolating stage (relay / optocoupler / transistor) wired across the
+motherboard's switch header — never a bare GPIO-to-header short. The firmware only knows how to toggle one pin.
+
 ## RF reverse-engineering tooling
 
 `rf_diag.cpp` holds everything used to *discover* the protocol — promiscuous capture, config sweeps, bit-exact

+ 17 - 3
CODE_MAP.md

@@ -15,7 +15,7 @@ Three contexts matter for every piece of state below:
 
 | Context | Priority / stack | What runs here |
 |---|---|---|
-| **loop task** | Arduino FreeRTOS "loop" task, low priority, ~4 KB stack | Everything called from `loop()`: `webusbPoll`, `g_active->task()`, `serialConsolePoll`, `rfDiagTask`, `rfLinkTask`, `hapticTask`, `ledTask`, `usbMountTask`. Also all of the RF radio code (it is driven synchronously from `rfLinkTask`/`rfDiagTask`). Also `setup()`. |
+| **loop task** | Arduino FreeRTOS "loop" task, low priority, ~4 KB stack | Everything called from `loop()`: `webusbPoll`, `g_active->task()`, `serialConsolePoll`, `rfDiagTask`, `rfLinkTask`, `hapticTask`, `ledTask`, `usbMountTask`, `pwrSwitchTask` (only under `OPK_PWR_SWITCH`). Also all of the RF radio code (it is driven synchronously from `rfLinkTask`/`rfDiagTask`). Also `setup()`. |
 | **usbd task** | TinyUSB "usbd" task, **high priority, ~800-byte stack** | All TinyUSB HID `set_report`/`get_report` callbacks, the custom XInput class-driver xfer callbacks, and (in the Adafruit core) HID `sendReport` queuing context. This is where host→device control transfers are decoded. **Stack is tiny — a `Serial.printf` here can overflow it (the suspected LOCKUP cause), which is why all per-report logging is `#if OPK_LOG` gated.** |
 | **ISR** | Hardware exception | `HardFault_Handler` (fault_diag.cpp). The RADIO is **polled, not interrupt-driven** — there is no radio ISR. |
 
@@ -56,8 +56,10 @@ re-add wake mouse + `g_active->mountSlots(k)` + WebUSB in locked instance order,
 1. Feeds the watchdog (`NRF_WDT->RR[0]`).
 2. If `g_dirty`, `saveBonds()` (flash write in loop context).
 3. Calls, in order: `webusbPoll`, `g_active->task`, `serialConsolePoll`, `rfDiagTask`,
-   `rfLinkTask`, `hapticTask`, `ledTask`, `usbMountTask`.
-   `#if OPK_LOG` wraps each in `micros()` timing for the WebUSB panel.
+   `rfLinkTask`, `hapticTask`, `ledTask`, `pwrSwitchTask` (only under `OPK_PWR_SWITCH`),
+   `usbMountTask`.
+   `#if OPK_LOG` wraps each in `micros()` timing for the WebUSB panel (`pwrSwitchTask`
+   is untimed, like `usbMountTask`/`usbTxPump`).
 
 ### State owned
 - `g_usbCfgDesc[512]` — config-descriptor build buffer (puck composite + WebUSB exceeds 256 B).
@@ -503,6 +505,18 @@ Reads `g_qamMap`/`g_abSwap`/`g_back[]`. Pure transforms, no buffers beyond calle
 Drives two GPIO pins (LED_BUILTIN + pin 24) together. `ledWakePulse()` lights them and
 stamps `g_pulseMs`; `ledTask()` clears after `PULSE_MS=500`. No buffers/delays/radio.
 
+### `pwr_switch.cpp` / `pwr_switch.h` (loop task; compiled in only under `OPK_PWR_SWITCH`)
+Drives one GPIO pin, wired externally to the HOST motherboard's power-switch header, to
+power the PC on. `pwrSwitchTask()`: (1) debounces `USBDevice.mounted()==false` for
+`HOST_OFF_DEBOUNCE_MS` into a `hostOff` flag; (2) per slot, independently re-derives the
+Steam-button short-press edge already detected in `rf_link.cpp` by reading `g_in[s].buttons`
++ `g_connReplyMs[s]` directly (same pattern `haptics.cpp`'s `hapticOnReconnect` uses against
+`rf_link.cpp`'s own link-up edge) — gated on that slot being link-up so a mid-press link drop
+(which zeroes `g_in[s]`) can't read as a release; (3) on a completed short press with
+`hostOff` true, outside a `RETRIGGER_COOLDOWN_MS` window, and no pulse already in flight,
+pulses the pin for `PULSE_MS` (non-blocking release, same idiom as `status_led.cpp`). No new
+cross-task shared state — every static here is loop-task-only.
+
 ### `fault_diag.cpp` / `fault_diag.h`
 - **`HardFault_Handler()` (ISR / exception context)** — a **strong override** of the
   weak default: sets `GPREGRET2 = 0xFA` then `NVIC_SystemReset()`. The only ISR/handler

+ 14 - 1
Makefile

@@ -51,6 +51,12 @@ USB_EXTRA_FLAGS = -DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=$(CFG_TUD_HID)
 # When BUILD_PATH is set, --clean + path flags are injected; omitted for fast incremental dev builds.
 _PATH_FLAGS = $(if $(BUILD_PATH),--clean --build-path $(BUILD_PATH) --output-dir $(OUTPUT_DIR))
 
+# `uf2` needs a KNOWN output location (unlike plain `build`, which is free to use arduino-cli's own internal
+# cache) so gen_uf2.sh can find the .hex afterward. Same convention CI uses. Override on the command line if
+# you want the .uf2 somewhere else: make uf2 UF2_OUTPUT_DIR=somewhere/else
+UF2_BUILD_PATH ?= build/cache/openpuck
+UF2_OUTPUT_DIR ?= build/openpuck
+
 # `flash`/`deploy` take the serial port as a REQUIRED POSITIONAL arg: `make flash /dev/cu.usbmodem1101`.
 # (No auto-detect -- uploading to a guessed serial port risks writing to the wrong device. List with
 # `arduino-cli board list`.) FLASH_PORT = whatever goal isn't one of our real targets; the catch-all rule at
@@ -66,7 +72,7 @@ UPLOAD = arduino-cli upload -b $(FQBN) -p "$(FLASH_PORT)" OpenPuck
 RP_USB_FLAGS = -DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_TASK_QUEUE_SZ=$(CFG_TUD_TASK_QUEUE_SZ) -DCFG_TUD_VENDOR_TX_BUFSIZE=$(CFG_TUD_VENDOR_TX_BUFSIZE) $(EXTRA_FLAGS)
 RP_UPLOAD = arduino-cli upload -b $(FQBN) -p "$(FLASH_PORT)" ReversePuckFirmware
 
-.PHONY: format format-check check build build-raytac package-raytac \
+.PHONY: format format-check check build build-raytac uf2 package-raytac \
 	flash-raytac deploy-raytac provision-raytac-softdevice build-recovery \
 	reversepuck reversepuck-flash reversepuck-deploy flash deploy
 
@@ -129,6 +135,13 @@ provision-raytac-softdevice:
 build-recovery:
 	$(MAKE) build BUILD_PATH=$(BUILD_PATH) OUTPUT_DIR=$(OUTPUT_DIR) EXTRA_FLAGS="$(EXTRA_FLAGS) -DOPK_FACTORY_RESET=1"
 
+## Build OpenPuck and convert the .hex into a drag-and-drop .uf2 (build/openpuck/OpenPuck.ino.uf2 by default).
+## Same overrides as `build` (CFG_TUD_HID / EXTRA_FLAGS / FQBN), e.g. a recovery UF2:
+##   make uf2 EXTRA_FLAGS="-DOPK_FACTORY_RESET=1"
+uf2:
+	$(MAKE) build BUILD_PATH=$(UF2_BUILD_PATH) OUTPUT_DIR=$(UF2_OUTPUT_DIR) EXTRA_FLAGS="$(EXTRA_FLAGS)"
+	./gen_uf2.sh $(UF2_OUTPUT_DIR)/OpenPuck.ino.hex $(UF2_OUTPUT_DIR)/OpenPuck.ino.uf2
+
 ## Compile the ReversePuck controller dongle firmware (28DE:1302) with its WebUSB vendor flags baked in.
 reversepuck:
 	arduino-cli compile -b $(FQBN) $(_PATH_FLAGS) --build-property "build.extra_flags=$(RP_USB_FLAGS)" ReversePuckFirmware

+ 10 - 0
OpenPuck/OpenPuck.ino

@@ -37,6 +37,7 @@ using namespace Adafruit_LittleFS_Namespace;
 #include "serial_console.h"
 #include "wake_hid.h"
 #include "status_led.h"
+#include "pwr_switch.h"
 #include "usb_mount.h"
 #include "identity.h"
 #include "fault_diag.h"
@@ -136,6 +137,9 @@ void setup()
 #endif
 	genSerial();
 	ledInit();
+#if OPK_PWR_SWITCH
+	pwrSwitchInit();
+#endif
 
 	// seed defaults so unbonded slots don't share the discovery address
 	for (int s = 0; s < NSLOT; s++)
@@ -386,6 +390,9 @@ void loop()
 	faultDiagSetStage(6);
 	ledTask();
 	acc[6] += (uint32_t)(micros() - t);
+#if OPK_PWR_SWITCH
+	pwrSwitchTask();
+#endif
 	faultDiagSetStage(7);
 	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
 	faultDiagSetStage(8);
@@ -426,6 +433,9 @@ void loop()
 	hapticTask();
 	faultDiagSetStage(6);
 	ledTask();
+#if OPK_PWR_SWITCH
+	pwrSwitchTask();
+#endif
 	faultDiagSetStage(7);
 	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
 	faultDiagSetStage(8);

+ 9 - 0
OpenPuck/config.h

@@ -21,6 +21,15 @@
 #define OPK_FACTORY_RESET 0
 #endif
 
+// Optional GPIO trigger wired to the HOST motherboard's front-panel power-switch header: fires a
+// momentary pulse when the paired Steam Controller's STEAM button is short-pressed WHILE the host
+// is off (USB not enumerated). 0 (default) = feature compiled out entirely -- no pin driven, no
+// behavior change for anyone without the extra wiring. -DOPK_PWR_SWITCH=1 to enable. See
+// pwr_switch.h for the pin/timing knobs.
+#ifndef OPK_PWR_SWITCH
+#define OPK_PWR_SWITCH 0
+#endif
+
 // ---- USB presentation modes (g_usbMode). RF poll/relay is identical across all; only USB enumeration +
 //      report mapping differ. ----
 #define MODE_STEAM 0 // Valve puck; auto-lizard when Steam closed

+ 81 - 0
OpenPuck/pwr_switch.cpp

@@ -0,0 +1,81 @@
+#include "pwr_switch.h"
+#include "bonds.h" // NSLOT, g_slot, g_connReplyMs
+#include "triton.h" // TB_STEAM, g_in
+#include <Arduino.h>
+#include <Adafruit_TinyUSB.h> // USBDevice.mounted()
+
+#define PWR_SWITCH_RELEASED ((PWR_SWITCH_ACTIVE) == HIGH ? LOW : HIGH)
+#define PULSE_MS 300u // motherboard power-header pulse width
+#define HOST_OFF_DEBOUNCE_MS \
+	1500u // USB must read "not mounted" this long before we trust it
+#define RETRIGGER_COOLDOWN_MS 5000u // minimum spacing between pulses
+
+static bool s_pulseActive = false;
+static unsigned long s_pulseStartMs = 0;
+static unsigned long s_hostOffSinceMs = 0;
+static unsigned long s_lastFireMs = 0;
+static bool s_everFired = false;
+
+// per-slot: with round-robin polling a shared static would get clobbered by other slots' reports
+// between press and release (same reasoning as rf_link.cpp's own Steam-short-press wake detector)
+static bool steamWasDown[NSLOT] = {};
+static unsigned long steamDownMs[NSLOT] = {};
+
+void pwrSwitchInit()
+{
+	pinMode(PWR_SWITCH_PIN, OUTPUT);
+	digitalWrite(PWR_SWITCH_PIN, PWR_SWITCH_RELEASED);
+}
+
+static void firePulse()
+{
+	digitalWrite(PWR_SWITCH_PIN, PWR_SWITCH_ACTIVE);
+	s_pulseActive = true;
+	s_pulseStartMs = millis();
+	s_lastFireMs = s_pulseStartMs;
+	s_everFired = true;
+}
+
+void pwrSwitchTask()
+{
+	// non-blocking release of an in-flight pulse
+	if (s_pulseActive && millis() - s_pulseStartMs >= PULSE_MS) {
+		digitalWrite(PWR_SWITCH_PIN, PWR_SWITCH_RELEASED);
+		s_pulseActive = false;
+	}
+
+	// Host-off debounce: USBDevice.mounted() must read false CONTINUOUSLY for HOST_OFF_DEBOUNCE_MS.
+	// Resets the instant it reads true, so a genuine boot/enumeration can never look like "off".
+	if (USBDevice.mounted()) {
+		s_hostOffSinceMs = 0;
+	} else if (s_hostOffSinceMs == 0) {
+		s_hostOffSinceMs = millis();
+	}
+	bool hostOff = s_hostOffSinceMs != 0 &&
+		       millis() - s_hostOffSinceMs >= HOST_OFF_DEBOUNCE_MS;
+
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_slot[s].used)
+			continue;
+
+		// A dropped link forces g_in[s] to zero (rf_link.cpp's own down-edge handling), which
+		// would otherwise look like a same-frame button release here -- only trust g_in while
+		// this slot is actually link-up (same 300ms threshold as anySlotLinkUp()).
+		if (millis() - g_connReplyMs[s] >= 300u) {
+			steamWasDown[s] = false;
+			continue;
+		}
+
+		bool steamNow = (g_in[s].buttons & TB_STEAM) != 0;
+		if (steamNow && !steamWasDown[s])
+			steamDownMs[s] = millis();
+		if (!steamNow && steamWasDown[s] &&
+		    millis() - steamDownMs[s] < 1000u && hostOff &&
+		    !s_pulseActive &&
+		    (!s_everFired ||
+		     millis() - s_lastFireMs >= RETRIGGER_COOLDOWN_MS)) {
+			firePulse();
+		}
+		steamWasDown[s] = steamNow;
+	}
+}

+ 29 - 0
OpenPuck/pwr_switch.h

@@ -0,0 +1,29 @@
+// pwr_switch.h -- GPIO trigger emulating a momentary press of the HOST motherboard's power switch.
+//
+// Optional feature (build with -DOPK_PWR_SWITCH=1, see config.h): fires ONE momentary pulse on
+// PWR_SWITCH_PIN when the paired Steam Controller's STEAM button gets a short press (down+up
+// within 1s -- the same gesture rf_link.cpp already recognizes for USB remote wakeup) WHILE the
+// host is OFF: USB has never enumerated / has stopped enumerating (USBDevice.mounted() == false),
+// sustained for HOST_OFF_DEBOUNCE_MS so a brief re-enumeration blip can't false-fire. Assumes the
+// USB port keeps standby power while the host is off (so this firmware keeps running and
+// listening) and the controller is already bonded -- there is no pairing UI here.
+//
+// Hardware note: PWR_SWITCH_PIN must drive an ISOLATING stage (relay / optocoupler / transistor)
+// wired across the motherboard's front-panel power-switch header -- never short the header
+// directly to the nRF's GPIO. This module only knows how to drive one pin; the isolation is on you.
+#pragma once
+
+#ifndef PWR_SWITCH_PIN
+// Arduino pin number for physical P0.17 ("017" on this SuperMini clone's silkscreen) under the
+// Adafruit Feather core this builds against (variants/feather_nrf52840_express/variant.cpp:
+// g_ADigitalPinMap[29] == 17) -- confirmed on real hardware with a standalone blink sketch. The
+// silkscreen number is NOT the Arduino pin number; don't assume identity on a different board.
+#define PWR_SWITCH_PIN 29
+#endif
+#ifndef PWR_SWITCH_ACTIVE
+#define PWR_SWITCH_ACTIVE \
+	HIGH // level that closes the switch; set LOW if your driver is active-low
+#endif
+
+void pwrSwitchInit(); // call once from setup(): pin to output, released (inactive) level
+void pwrSwitchTask(); // call every loop(): edge-detect the Steam short press, gate, pulse, release

+ 7 - 0
README.md

@@ -112,6 +112,13 @@ This is a tool to emulate a Steam Controller 2 with almost all of its inputs (ex
 
 [![ReversePuck Demo](https://img.youtube.com/vi/q_AvvpFn4A8/0.jpg)](https://www.youtube.com/watch?v=q_AvvpFn4A8)
 
+# ColdBoot Functionality
+ColdBoot lets a paired Steam Controller turn the PC on from a fully off state: a short press of the Steam button makes the puck pulse a GPIO pin that closes the motherboard's power-switch circuit, exactly like pressing the case power button. It works because the USB port keeps standby power on the board while the PC is off, so the puck stays awake and keeps listening. It only fires when the host is genuinely off (USB not enumerated), so it can't send a stray power press during normal use.
+
+This needs a little extra wiring (one resistor, one transistor, two wires to the front-panel header) and is off by default in the firmware -- build it with `make uf2 EXTRA_FLAGS="-DOPK_PWR_SWITCH=1"`.
+
+Bill of materials, schematic, and step-by-step wiring instructions: [How to wire up an NRF52 board for cold boot](https://github.com/safijari/openpuck/wiki/How-to-wire-up-an-NRF52-board-for-cold-boot).
+
 # Future work
 - Find a way to make Xinput mode and mouse work together on all platforms
 - Design the charging portion (and make it short proof)