|
|
hai 4 meses | |
|---|---|---|
| .. | ||
| README.md | hai 4 meses | |
| puck_sniffer.ino | hai 4 meses | |
| radio.cpp | hai 4 meses | |
| radio.h | hai 4 meses | |
A passive 2.4 GHz sniffer for the link between a real Valve SC2 puck and its controller. It never transmits, so it cannot disturb the pairing — it only listens, and streams every frame to a browser app in real time with Start Cap / End Cap.
Use it to capture the actual puck↔controller traffic for any function (shutdown, LED, battery, …), isolate the relevant frames, and export them.
arduino-cli compile -b adafruit:nrf52:feather52840 \
--build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb}" \
--upload -p <PORT> puck_sniffer
(Same manual-DFU/double-tap-reset dance as OpenPuck if the port won't auto-reset.) It enumerates as
28DE:534E "OpenPuck Sniffer".
WebUSB needs a secure context, so not a file:// path. Either:
docs/ is pushed to gh-pages), orcd docs && python3 -m http.server → open http://localhost:8000/sniffer.htmlClick Connect sniffer, pick OpenPuck Sniffer.
Why order matters: the link is Nordic Gazell — the on-air address is random per session, and a connected slot never beacons on ch2; only a reconnecting one does. So you can't lock onto an already-connected controller — you must catch its reconnect. The bond's ibex_uuid is stable across reconnects (only the address changes), so we anchor the lock on that uuid and force a reconnect.
E1s and pollutes
ch2. Leave only: the real Valve puck, the controller, and the sniffer board.pairtui (see ../pairtui/), note the
connected slot's ibex_uuid (e.g. EF7171B4) — it's the bond id, stable across sessions.docs/sniffer.html over localhost/https — not file://), Connect →
OpenPuck Sniffer. Confirm rx is climbing.EF7171B4), click Lock. Status → ACQUIRE (waiting on ch2 for only
that slot's E1; ignores other slots and the copycat).E1 (matching the uuid) with a fresh session
address → the sniffer matches the uuid, locks that session, and hunts its channels.C→P climbs. Wiggle sticks / trackpad to keep
traffic flowing.C→P climbing, do the thing in Steam (change brightness / LED, or turn off).Always: Lock the uuid → Start Cap → then power-cycle. The most common failure is the controller silently
auto-reconnecting before the lock is armed, so its E1 is never caught.
Direction: P→C (opcode 0xE_, puck→controller — LED/shutoff/brightness commands ride here) vs C→P (0xF_,
controller→puck — input + battery/telemetry).
The table has a filter bar:
0xF1 input replies and the bare 0xE3
polls), leaving only commands, feature responses, beacons, and anything unusual.P→C / C→P / all.f1, e3.≠ 49 (non-input), > 49, > 1. ≠ 49 is the key for finding feature responses / battery.9f, 2d).Filters re-render the visible table from a buffer, so you can change them after the fact.
Battery is not in the input stream — it's ID_GET_BATTERY_DATA, a feature GET Steam issues on demand
(when it shows the battery), answered by the controller in a longer C→P frame (len > 49) carrying a
type-04 TLV. To catch it: lock + Start Cap as above, set hide input/polls + dir C→P (or len ≠ 49),
then open the controller's page in Steam (Settings → Controller / the battery indicator) so Steam polls it.
Grab the non-49 C→P frame that appears.
C→P won't climb after the power-cycleop e1 rows during the reconnect: one payload should contain your uuid in
little-endian (EF7171B4 → … b4 71 71 ef …). If you only ever see a different uuid, the copycat or another
bonded slot is the only thing reconnecting — make sure the real controller is re-pairing to the real puck.Bond info (which controller/slot, its serial + uuids) is read over USB, not the air — see
../pairtui/. Useful for the uuid lock and labelling a capture; it does not give the on-air address (random per session).
After RX the radio buffer is [LENGTH][S1][payload…]; payload[0] is the opcode. The puck advertises its
per-session base/prefix/channel inside the E1 host frame on the shared ibex/ch2 rendezvous, so the sniffer
reads those, retunes to the session, and receives both directions (same ESB base — and it now listens on all
8 ESB pipes of that base, so a controller reply on a different prefix is still caught; the pipe column /
status last pipe shows which RXMATCH each frame hit). A QoS channel-hop is followed via the E1 keepalive.
When the session goes silent it sweeps clean candidate channels keeping the learned address (rather than
abandoning to ch2) and only falls back to a full discovery scan after two dry sweeps. Radio parameters are
OpenPuck's CRC-validated config (radio.cpp).
E2/E1 on ch2 and never the controller's 0xF_ repliesThat means it never camps on the session. Read a real E1 row in the table (a P→C, op e1 frame): its
payload bytes are e1 [proteus:4] [ibex:4] [CH] 00 00 00 [BASE:4] [PFX] — i.e. byte 9 = session channel,
bytes 13–16 = session base, byte 17 = prefix (the "E1 advertises" stat shows the sniffer's own read of these).
Cross-check that against where traffic actually is, then type the six values into pin session
(b0 b1 b2 b3 pfx ch, address bytes hex) and click Pin to force the sniffer onto that exact session — this
bypasses auto-acquire entirely, which is the reliable path if the real puck's E1 offsets differ from ours.
C0 DE [N] [t_us:4 LE] [ch] [flags] [rssi] [match] [N raw bytes] (flags bit0 = CRC ok; match = RXMATCH pipe; raw = [LEN][S1][payload])C1 DE [state] [curCh] [base:4] [prefix] [cap] [hb:2] [advCh] [advBase:4] [advPfx] [lastMatch] (19 B; adv* = session parsed from the last E1)01 start · 02 stop · 03 re-acquire · 04 <ch> pin channel · 05 <b0 b1 b2 b3 pfx ch> pin full session