GameScreen: add FLAG_KEEP_SCREEN_ON while a game is active. Keeps the system out of the deeper sleep states that aggravate OTG selective-suspend on Samsung, where the 8BitDo adapter otherwise drops every 60-120 seconds. Cleared on dispose so other screens are unaffected. docs: drop a design sketch for a DIY Joy-Con bridge built on an original ESP32 or Raspberry Pi Pico W. Covers the two architectures (fully wireless via BT Classic host + BLE peripheral, or wired hybrid via BT Classic host + USB-CDC serial), the Joy-Con init subcommand requirement, a proposed wire protocol for the serial path, and the changes we'd make app-side to consume it. Also captures the dongle rabbit hole we went through so future-us doesn't repeat the same experiments. Notes that Bluepad32 already implements BT Classic HID host with first-class Joy-Con support on original ESP32 and Pico W, which shrinks the firmware side considerably. Also warns explicitly against ESP32-S3 for this project — despite being the obvious "newer, better" ESP32, it is BLE-only and cannot pair with Joy-Cons (BR/EDR-only).
357 lines
17 KiB
Markdown
357 lines
17 KiB
Markdown
# Joy-Con ESP32 Bridge — Design Notes
|
||
|
||
Design sketch for replacing the 8BitDo USB Wireless Adapter 2 with a DIY
|
||
ESP32-based Joy-Con bridge. Not yet implemented — this doc captures the
|
||
plan so it's ready to pick up later.
|
||
|
||
## Why
|
||
|
||
The current "working" path uses an 8BitDo USB Wireless Adapter 2 in PS-Classic
|
||
mode, plugged into the Samsung Fold 6 via an OTG cable. It works, but has three
|
||
annoying problems:
|
||
|
||
1. **It wedges periodically.** The adapter stops sending HID reports every
|
||
60–120 seconds on this phone. Unplug + replug clears it. Likely root cause
|
||
is Samsung's aggressive USB selective-suspend / OTG power management
|
||
kicking the idle device off the bus. See the "dongle rabbit hole" notes
|
||
below for everything we tried.
|
||
2. **PS-Classic mode is fragile.** We picked it because it's the only mode
|
||
where Samsung's kernel `usbhid` actually binds the device as a real
|
||
gamepad. Direct Joy-Con Bluetooth, XInput mode, DInput mode, and macOS/DS4
|
||
mode all fail for various reasons specific to Samsung's kernel + this
|
||
adapter's firmware.
|
||
3. **It's a cable dangling off a foldable phone.** Not great ergonomically.
|
||
|
||
Doing our own Joy-Con-to-gamepad translation on an ESP32 lets us control both
|
||
ends of the conversation: we pair the Joy-Con to hardware we own, we do the
|
||
init handshake correctly, and we present the result to the phone as either a
|
||
standard BLE HID gamepad (no cable) or a USB-CDC serial stream (cable, but we
|
||
control the firmware so the selective-suspend problem goes away).
|
||
|
||
## Why the Joy-Con is non-trivial
|
||
|
||
Joy-Cons are Bluetooth **Classic** HID devices. They are NOT BLE. This means
|
||
any bridge MCU must have BT Classic on its radio.
|
||
|
||
Once paired, they default to input report mode `0x3F`, which only emits a
|
||
small subset of buttons as a dumb HID "joystick". To get the full
|
||
button-mask + analog sticks + IMU + battery, you have to send a sequence of
|
||
subcommands:
|
||
|
||
1. Read SPI flash calibration at `0x6020` (stick factory calibration) and
|
||
`0x8010` (stick user calibration)
|
||
2. `set_player_lights` (subcommand `0x30`)
|
||
3. `enable_imu` (subcommand `0x40`) if you want gyro/accel
|
||
4. **`set_input_report_mode` to `0x30`** (subcommand `0x03`) — this is the
|
||
important one; switches to the 12-byte input report that contains
|
||
everything you actually want
|
||
|
||
Once the Joy-Con is in `0x30` mode, every input report is a fixed layout:
|
||
|
||
```
|
||
byte purpose
|
||
0 0x30 (report id)
|
||
1 timer (rolling counter)
|
||
2 battery + connection info
|
||
3 buttons right (Y, X, B, A, SR, SL, R, ZR)
|
||
4 buttons shared (-, +, R-stick, L-stick, home, capture)
|
||
5 buttons left (down, up, right, left, SR, SL, L, ZL)
|
||
6-8 left stick (12-bit X + 12-bit Y, packed)
|
||
9-11 right stick (same)
|
||
12 vibrator ack
|
||
13+ IMU samples (3 frames x 12 bytes) if IMU enabled
|
||
```
|
||
|
||
The full protocol is documented here:
|
||
- https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering
|
||
- https://github.com/DanielOgorchock/joycond (Linux userspace init daemon)
|
||
- Linux mainline `drivers/hid/hid-nintendo.c` (kernel driver that does the
|
||
same init sequence in C)
|
||
|
||
Either joycond or hid-nintendo is a fine starting point for porting to C on
|
||
ESP32.
|
||
|
||
## Hardware
|
||
|
||
Joy-Cons use **Bluetooth Classic (BR/EDR)**, not BLE. This is the single
|
||
most important constraint on the hardware. Most modern MCU boards that
|
||
have "Bluetooth" actually only have BLE, which won't work.
|
||
|
||
Only these chips have BT Classic:
|
||
|
||
| Chip | BT Classic | BLE | Native USB | Fits? |
|
||
|----------------------------|:----------:|:---:|:----------:|:-----:|
|
||
| **ESP32** (D0WDQ6 / WROOM-32 / WROVER-32) | ✅ | ✅ | ❌ | ✅ **best default** |
|
||
| **Raspberry Pi Pico W** (CYW43439) | ✅ | ✅ | ✅ | ✅ also works |
|
||
| ESP32-S3 | ❌ | ✅ | ✅ | ❌ BLE only (common mistake) |
|
||
| ESP32-S2 | ❌ | ❌ | ✅ | ❌ no BT at all |
|
||
| ESP32-C3 | ❌ | ✅ | ❌ | ❌ BLE only |
|
||
| ESP32-C6 | ❌ | ✅ | ❌ | ❌ BLE + 802.15.4 only |
|
||
| ESP32-H2 | ❌ | ✅ | ❌ | ❌ BLE + 802.15.4 only |
|
||
|
||
**⚠️ Do not buy ESP32-S3 for this project** even though it looks like the
|
||
obvious upgrade over the original ESP32. Espressif confirmed in their own
|
||
datasheet that ESP32-S3 only supports Bluetooth 5 LE — no BR/EDR — and
|
||
you cannot connect a Joy-Con to it over Bluetooth. The Bluepad32 FAQ
|
||
makes this explicit: *"controllers like Switch, Wii, DualSense, DualShock,
|
||
etc. only talk BR/EDR... you cannot use an ESP32-S3 as a Bluetooth HID
|
||
host to connect to Joy-Con controllers."*
|
||
|
||
**Recommended: original ESP32-WROOM-32 dev board** — any of the Amazon
|
||
"DOIT DEVKIT V1", "HiLetgo", "ELEGOO", "DIYmall" variants. They're all
|
||
the same chip, usually with a CP2102 or CH340 USB-to-serial chip on
|
||
board so you get programming + serial communication over a single USB
|
||
port. $8–12 Canadian. BT Classic is built into the ESP32-D0WDQ6 chip.
|
||
No native USB-OTG, so in Path B the chip talks to the phone over the
|
||
on-board USB-serial bridge.
|
||
|
||
**Also good: Raspberry Pi Pico W** — the CYW43439 WiFi/BT combo chip
|
||
on the Pico W supports BT Classic, and the RP2040 has native USB. If
|
||
you prefer C SDK + CMake to Arduino, Pico W is a nicer dev experience,
|
||
and the Bluepad32 library supports it as a first-class target alongside
|
||
the original ESP32.
|
||
|
||
**ESP32-WROVER-32** is the same silicon as WROOM-32 plus an extra PSRAM
|
||
chip. Works identically for this project but costs a bit more for PSRAM
|
||
we don't need. Fine if that's what you already have.
|
||
|
||
## Two architectures
|
||
|
||
### Path A — fully wireless (BT Classic host + BLE peripheral)
|
||
|
||
```
|
||
[Joy-Con] --BT Classic HID--> [ESP32-S3] --BLE HID--> [Phone]
|
||
```
|
||
|
||
The ESP32 runs two radio profiles at once on its dual-mode stack:
|
||
|
||
- **BT Classic HID host** (ESP-IDF `esp_hidh` API) — pairs with the Joy-Con,
|
||
runs the init subcommand sequence, subscribes to `0x30` input reports
|
||
- **BLE HID device** (ESP-IDF `esp_hids` API, or the `ESP32-BLE-Gamepad`
|
||
Arduino library) — advertises itself to the phone as a standard BLE
|
||
gamepad with a generic HID descriptor
|
||
|
||
The Android side needs **no code changes**. Samsung's kernel binds BLE HID
|
||
gamepads natively (BLE HID is a completely different code path from the
|
||
USB HID mess we've been fighting), the existing `InputDevice`/
|
||
`dispatchKeyEvent` path picks up button events, and our
|
||
`ControllerManager` routes them into the emulator the same way it does
|
||
for the 8BitDo today.
|
||
|
||
**Pros**
|
||
- No cable. Charge the ESP32 off a tiny LiPo or a power brick, use it as a
|
||
wireless dongle sitting next to the phone.
|
||
- Nothing to change in the app.
|
||
- Once it works, it works the same for every game on every emulator we
|
||
already support.
|
||
|
||
**Cons**
|
||
- Running BT Classic host + BLE peripheral simultaneously on the same radio
|
||
is non-trivial. ESP-IDF supports dual mode but the profiles have to share
|
||
a single controller — expect to spend time in `menuconfig` and the
|
||
Bluetooth controller's coexistence settings.
|
||
- Bigger firmware, more things that can go wrong during bring-up.
|
||
|
||
### Path B — wired hybrid (BT Classic host + USB-CDC serial)
|
||
|
||
```
|
||
[Joy-Con] --BT Classic HID--> [ESP32-S3] --USB CDC serial--> [Phone]
|
||
```
|
||
|
||
Same Joy-Con side. Different phone side: instead of re-broadcasting over
|
||
BLE, the ESP32 streams button state as short binary frames over USB CDC.
|
||
The phone reads them via `usb-serial-for-android` or directly via the
|
||
`UsbManager` + `bulkTransfer` APIs we already use.
|
||
|
||
**Pros**
|
||
- Simpler firmware — no dual-mode radio, no BLE HID descriptor, no BLE
|
||
pairing dance. Get it working in a weekend.
|
||
- We own the wire protocol, so it's trivially debuggable. Dump the serial
|
||
bytes in Serial Monitor and you see exactly what the Joy-Con is sending.
|
||
- **Selective-suspend goes away** because we control the firmware on
|
||
both sides. Send a 60-Hz heartbeat frame even when no buttons are held
|
||
and the USB bus stays active. The ESP32 can also be externally
|
||
powered, so the phone's bus-power policy becomes irrelevant.
|
||
- Direct fit for the existing `ControllerManager.dispatchButton` plumbing.
|
||
|
||
**Cons**
|
||
- Still a cable between the ESP32 and the phone (we're swapping the 8BitDo
|
||
OTG cable for an ESP32 OTG cable — ergonomically the same).
|
||
- Requires a small addition to the app to read USB serial and parse the
|
||
wire protocol.
|
||
|
||
Recommended order of attack: **build Path B first**. The hard part (Joy-Con
|
||
BT Classic host + init sequence + `0x30` report parsing) is identical in
|
||
both paths, so Path B gets you to a working gamepad fastest and validates
|
||
the Joy-Con side. Once Path B is rock-solid, swap the output stage for BLE
|
||
HID and you have Path A for free (modulo the dual-mode coexistence tuning).
|
||
|
||
## Wire protocol sketch (Path B)
|
||
|
||
Keep it tiny, keep it fixed-size, keep it self-synchronizing.
|
||
|
||
```
|
||
byte 0 0xA5 sync / frame header
|
||
byte 1 seq rolling sequence counter, wraps at 255
|
||
byte 2 btns_lo (A, B, X, Y, L1, R1, L2, R2)
|
||
byte 3 btns_hi (Start, Select, L3, R3, Home, Capture, reserved, reserved)
|
||
byte 4 dpad packed: 4 bits for hat direction (0-7 clockwise from N, 8=none),
|
||
4 bits for Joy-Con-specific buttons (SR/SL etc)
|
||
byte 5 lx signed int8, -127..127 (already deadzoned + calibrated on ESP32)
|
||
byte 6 ly signed int8
|
||
byte 7 rx signed int8
|
||
byte 8 ry signed int8
|
||
byte 9 flags bit 0: battery low, bit 1: charging, bit 2: imu_valid, bits 3-7: reserved
|
||
byte 10 crc8 CRC-8 over bytes 0..9 (or just XOR checksum if we're lazy)
|
||
```
|
||
|
||
11 bytes per frame, 60 Hz = 660 bytes/sec. USB CDC at 115200+ baud handles
|
||
this with orders of magnitude to spare. Even ESP32's default 921600 baud is
|
||
fine for 240 Hz polling.
|
||
|
||
If later we want motion / gyro for games that use it, we append another ~12
|
||
bytes and bump a version byte in the header. Forward-compatible.
|
||
|
||
## App-side changes (Path B)
|
||
|
||
All contained in `ControllerManager.kt` and a new helper file:
|
||
|
||
1. **New dependency** on [usb-serial-for-android](https://github.com/mik3y/usb-serial-for-android)
|
||
(tiny, ~50 KB; supports CH340, CP210x, FTDI, and CDC-ACM, which covers
|
||
every ESP32 dev board).
|
||
2. **Add ESP32-S3 native USB VID/PID** (`0x303A / 0x1001`) and CP2102
|
||
(`0x10C4 / 0xEA60`) to `targetVidPids` in `ControllerManager`.
|
||
3. **In `onUsbDeviceAttached`**, after the existing HID-class short-circuit,
|
||
check whether the device matches one of the ESP32 VID/PIDs. If so, open
|
||
it as a USB serial port instead of going through the HID path, start a
|
||
reader thread, and parse incoming `[0xA5, seq, btns_lo, btns_hi, dpad,
|
||
lx, ly, rx, ry, flags, crc]` frames.
|
||
4. **For each frame**, diff against the previous frame and call
|
||
`dispatchButton(...)` for any changed button bits, plus `onAnalogEvent(...)`
|
||
for stick updates. Existing plumbing carries it into the emulator core.
|
||
5. **Heartbeat / liveness**: track the frame sequence counter, and if we
|
||
don't see a frame for 500 ms, log a warning and mark the controller as
|
||
disconnected so the test screen reflects it.
|
||
|
||
Approximate LOC: ~150 lines in ControllerManager + 50 lines for the wire
|
||
protocol parser. No changes needed anywhere else in the app.
|
||
|
||
## Firmware-side notes
|
||
|
||
### Bluepad32 changes the math
|
||
|
||
There's a project called **[Bluepad32](https://bluepad32.readthedocs.io/)**
|
||
that already implements BT Classic HID host for original ESP32 and Pico W,
|
||
with **explicit first-class Joy-Con support** alongside DualShock 3/4/5,
|
||
Switch Pro Controller, Wii Remote, Xbox, and generic HID gamepads. It does
|
||
the init subcommand dance, SPI-flash calibration reads, and 0x30 input
|
||
report parsing for you. MIT licensed.
|
||
|
||
This means the Joy-Con side of this project essentially does not need to
|
||
be written — it's library-level `#include`. Our firmware job shrinks to:
|
||
|
||
1. Initialize Bluepad32 and register a gamepad callback.
|
||
2. Format the callback's button/stick state into our wire protocol.
|
||
3. Send it out the output stage (USB serial for Path B, BLE HID for Path A).
|
||
|
||
The Joy-Con init quirks, reconnect handling, stick calibration, and BT
|
||
pairing UX are already solved.
|
||
|
||
### Starting points
|
||
|
||
- **[Bluepad32](https://github.com/ricardoquesada/bluepad32)** — the main
|
||
library. Has ESP-IDF and Arduino examples. The `controllers/` demo example
|
||
pairs gamepads and prints button state to serial; adapting it to our
|
||
wire format is ~50 lines.
|
||
- For **Path A** (BLE output): glue the Bluepad32 input side to
|
||
[`ESP32-BLE-Gamepad`](https://github.com/lemmingDev/ESP32-BLE-Gamepad) —
|
||
working BLE HID gamepad profile in ~30 lines of Arduino. Caveat: BLE
|
||
peripheral coexisting with BT Classic host on one chip is tricky.
|
||
- For **Path B** (USB serial): just `Serial.write()` the frame from the
|
||
Bluepad32 callback. Trivial.
|
||
- If you'd rather not use Bluepad32 and write the protocol yourself (for
|
||
learning or licensing reasons), reference joycond (C++, Apache 2.0) or
|
||
the Linux kernel `drivers/hid/hid-nintendo.c` (C, GPL).
|
||
|
||
### Prior art
|
||
|
||
- https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering — full
|
||
protocol docs
|
||
- https://github.com/DanielOgorchock/joycond — Linux userspace init daemon
|
||
- https://github.com/pipe01/joycon-rs — Rust Joy-Con protocol library
|
||
- Various ESP32-based Nintendo Switch Pro Controller *emulators* also
|
||
exist — they go the other direction (pretend to be a Pro Controller
|
||
talking to a Switch console) but share the same protocol knowledge.
|
||
|
||
### Estimated effort (with Bluepad32)
|
||
|
||
- **Path B (serial out)**: a weekend to get working, another weekend to
|
||
harden against reconnect/pair-loss edge cases. Total ~10–20 hobby hours.
|
||
- **Path A (BLE out)**: same Path B starting point plus 1–2 weeks of
|
||
wrestling with BT Classic + BLE dual-mode coexistence on one chip.
|
||
Bluepad32 itself only targets single-mode BT Classic, so you'd be
|
||
extending it or running the BLE side in parallel via raw ESP-IDF calls.
|
||
Harder to estimate.
|
||
|
||
## The dongle rabbit hole (what we already tried and why it didn't work)
|
||
|
||
For anyone picking this up later, here's the short history so you don't
|
||
re-run the same experiments:
|
||
|
||
- **Direct Joy-Con over Bluetooth to Samsung Fold 6.** Pairs fine, kernel
|
||
creates two evdev nodes (main + IMU), IMU streams MSC_TIMESTAMP so the
|
||
link is alive, but the main node has only `BTN_TL`/`BTN_TL2` in its key
|
||
capability set (no face buttons, no dpad, no stick) and even those fire
|
||
zero events. Samsung's `hid-nintendo` driver parks the node waiting for a
|
||
userspace init handshake that nothing on the system provides, and shell
|
||
can't `chmod` `/sys/bus/hid/devices/.../` to kick it.
|
||
- **8BitDo adapter, XInput mode.** Enumerates as an Xbox 360 wired
|
||
controller (vendor-spec class 0xFF / subclass 0x5D). Android has no
|
||
XInput driver in its kernel. Adapter endlessly cycles modes because it
|
||
can't complete the Xbox 360 handshake with the phone.
|
||
- **8BitDo adapter, DInput mode.** Enumerates as a clean HID-class device,
|
||
but Samsung's kernel `usbhid` refuses to bind it for unknown reasons
|
||
(verified by uninstalling the app entirely and replugging — no
|
||
`/dev/input/event*` is ever created). Not fixable from userspace.
|
||
- **8BitDo adapter, macOS mode.** Enumerates as a Sony DualShock 4
|
||
(`054C:05C4`). Same failure as DInput — Samsung's kernel doesn't bind
|
||
it. Despite Android having native DS4 support.
|
||
- **8BitDo adapter, PS-Classic mode.** Enumerates as a "Sony Interactive
|
||
Entertainment Controller" (`054C:0CDA`). Samsung's kernel actually binds
|
||
this one as a real gamepad. **This is what we ship today.** Downside: the
|
||
adapter wedges every 60–120 seconds and needs an unplug+replug to
|
||
recover, presumably from OTG selective-suspend.
|
||
|
||
Things that partially work or haven't been fully explored:
|
||
- Plugging the adapter into a powered USB hub between the phone and the
|
||
adapter helps somewhat with the wedging but does not fully fix it.
|
||
- Disabling Samsung battery optimization for the app does not fix the
|
||
wedging.
|
||
- Holding `FLAG_KEEP_SCREEN_ON` on the game window helps (the system
|
||
stays out of the deep-sleep regime that aggravates selective-suspend)
|
||
but again doesn't fully fix it.
|
||
|
||
All three workarounds stack with the ESP32 bridge idea if we go Path B —
|
||
but Path A (BLE) makes them all irrelevant because there's no USB bus to
|
||
suspend in the first place.
|
||
|
||
## Open questions to resolve during bring-up
|
||
|
||
1. Does the Samsung Fold 6 kernel actually bind ESP32-S3 native USB-CDC
|
||
as a `/dev/bus/usb/` device visible to our app, or does it do something
|
||
weird to USB-CDC too? Should be fine — CDC-ACM is boring and
|
||
well-supported — but verify before writing firmware.
|
||
2. What's the latency floor from Joy-Con button press to emulator core?
|
||
Joy-Con → BT Classic (~4-8 ms) + ESP32 parse (<1 ms) + USB serial
|
||
(<1 ms) + app dispatch (<1 ms) should come in under 15 ms, comfortably
|
||
below the ~16 ms/frame budget at 60 Hz. Worth measuring once hardware
|
||
exists.
|
||
3. Left+Right Joy-Con combined as one "Pro Controller" style pad, or
|
||
single-Joy-Con sideways mode? Single-Joy-Con is simpler for NES/SNES
|
||
and matches the NES Joy-Con we have. Pairing two Joy-Cons to the same
|
||
ESP32 is a separate BT Classic multi-device problem that we can punt
|
||
on.
|
||
4. Rumble — do we care? Joy-Con rumble is HD Rumble, which is a nightmare
|
||
even by Joy-Con standards. Probably punt; the emulated consoles we
|
||
support didn't have rumble anyway.
|