Skip to content

Commit bbdfc4c

Browse files
committed
feat: keyboard passthrough mode for keyboard-only games (2026.6.15.0-5AB1)
Add a second run-mode USB persona that presents the Pico as a USB HID boot keyboard instead of an Xbox 360 controller, for console games that need a keyboard such as Typing of the Dead on the Dreamcast. - The persona is stored in the flash credential record and chosen at boot. Records written before this field default to controller, so existing boards keep working without re-provisioning. - Switch over Wi-Fi with `couchlink keyboard` / `couchlink controller`: a new SET_PERSONA datagram persists the choice and reboots the board. `couchlink run` reads the persona from the discovery ack and streams the matching input type automatically. - Keyboard input comes from Parsec-injected keystrokes via a low-level keyboard hook that forwards only injected events, so the host's own typing is ignored, the same way the controller path reads Parsec's virtual gamepad. - Adds KEY_STATE/KEY_HEARTBEAT wire types carrying an 8-byte HID boot report, an ack persona flag, persona-aware diagnostics, and tests.
1 parent 5ab1977 commit bbdfc4c

30 files changed

Lines changed: 1571 additions & 91 deletions

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ Parsec CouchLink lets a remote Parsec player use a real retro console as player
44

55
The Windows host reads the Parsec virtual Xbox controller, sends the button state over Wi-Fi, and a Raspberry Pi Pico 2 W or Pico W presents that input as a wired Xbox 360 controller to a USB-to-console adapter such as USB4MAPLE.
66

7+
For games that need a keyboard instead -- Typing of the Dead on the Dreamcast, for one -- the same Pico can switch to a USB keyboard with `couchlink.exe keyboard` and forward the player's typing. See [Controller Routing](https://github.com/RealWhyKnot/ParsecCouchLink/wiki/Controller-Routing) for keyboard mode.
8+
79
**[Releases](https://github.com/RealWhyKnot/ParsecCouchLink/releases)** | **[Wiki](https://github.com/RealWhyKnot/ParsecCouchLink/wiki)** | **[Quick Start](https://github.com/RealWhyKnot/ParsecCouchLink/wiki/Quick-Start)** | **[Troubleshooting](https://github.com/RealWhyKnot/ParsecCouchLink/wiki/Troubleshooting)**
810

911
## What You Need
@@ -54,6 +56,8 @@ Useful commands:
5456

5557
```powershell
5658
.\couchlink.exe doctor
59+
.\couchlink.exe keyboard
60+
.\couchlink.exe controller
5761
.\couchlink.exe logs --tail
5862
.\couchlink.exe configure-wifi
5963
.\couchlink.exe recover

bridge/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ nusb = "0.1"
5151
windows = { version = "0.58", features = [
5252
"Win32_Foundation",
5353
"Win32_UI_Input_XboxController",
54+
"Win32_UI_WindowsAndMessaging",
55+
"Win32_System_LibraryLoader",
5456
"Win32_UI_Shell",
5557
"Win32_System_Com",
5658
"Win32_System_Com_StructuredStorage",

bridge/src/cmd_home.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1224,6 +1224,7 @@ mod tests {
12241224
uptime_seconds: 12,
12251225
unique_id_short: uid,
12261226
},
1227+
persona: protocol::Persona::Controller,
12271228
}
12281229
}
12291230

bridge/src/cmd_persona.rs

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
//! `couchlink keyboard` / `couchlink controller` -- switch a Pico's USB
2+
//! persona over Wi-Fi and optionally start streaming to it.
3+
//!
4+
//! The persona is persisted on the Pico and applied at the next boot, so
5+
//! switching reboots the board. Because the Pico lives plugged into the
6+
//! console (not the host), the switch has to happen over UDP -- the same
7+
//! reason `reboot-to-setup` exists. After the switch we wait for the
8+
//! board to rejoin Wi-Fi advertising the new persona before handing off
9+
//! to the streaming loop.
10+
11+
use std::collections::HashSet;
12+
use std::time::{Duration, Instant};
13+
14+
use anyhow::{bail, Result};
15+
16+
use crate::protocol::Persona;
17+
use crate::{cmd_run, pico_mode, support};
18+
19+
const DISCOVER: Duration = Duration::from_secs(5);
20+
const REBOOT_WAIT: Duration = Duration::from_secs(60);
21+
22+
pub async fn run(desired: Persona, selectors: Vec<String>, all: bool, stream: bool) -> Result<()> {
23+
let picos = cmd_run::discover_picos_with_auto_recovery(DISCOVER, false).await?;
24+
if picos.is_empty() {
25+
bail!("{}", support::no_pico_wifi_help(DISCOVER.as_secs()));
26+
}
27+
28+
let targets = select_targets(&picos, &selectors, all)?;
29+
30+
let mut switched_uids = Vec::new();
31+
for t in &targets {
32+
if t.persona == desired {
33+
println!(
34+
"{} is already in {} mode.",
35+
t.short_label(),
36+
desired.label()
37+
);
38+
continue;
39+
}
40+
println!(
41+
"Switching {} to {} mode...",
42+
t.short_label(),
43+
desired.label()
44+
);
45+
pico_mode::request_set_persona(t, desired).await?;
46+
switched_uids.push(t.info.unique_id_short);
47+
}
48+
49+
let final_targets = if switched_uids.is_empty() {
50+
targets
51+
} else {
52+
println!(
53+
"Waiting up to {}s for the Pico(s) to reboot into {} mode...",
54+
REBOOT_WAIT.as_secs(),
55+
desired.label()
56+
);
57+
let reappeared = wait_for_persona(&switched_uids, desired, REBOOT_WAIT).await?;
58+
merge_targets(targets, reappeared, &switched_uids)
59+
};
60+
61+
for t in &final_targets {
62+
let mark = if t.persona == desired {
63+
"ok"
64+
} else {
65+
"pending"
66+
};
67+
println!(
68+
" [{mark}] {} is now in {} mode",
69+
t.short_label(),
70+
t.persona.label()
71+
);
72+
}
73+
let pending: Vec<_> = final_targets
74+
.iter()
75+
.filter(|t| t.persona != desired)
76+
.map(|t| t.uid_hex())
77+
.collect();
78+
if !pending.is_empty() {
79+
println!(
80+
"Note: {} did not confirm {} mode yet. Give it a moment and run `couchlink test discover --all`.",
81+
pending.join(", "),
82+
desired.label()
83+
);
84+
}
85+
86+
if !stream {
87+
println!(
88+
"Run `couchlink run` to start streaming, or re-run with --stream to switch and stream."
89+
);
90+
return Ok(());
91+
}
92+
93+
let ready: Vec<_> = final_targets
94+
.into_iter()
95+
.filter(|t| t.persona == desired)
96+
.collect();
97+
if ready.is_empty() {
98+
bail!(
99+
"no Pico is in {} mode yet; nothing to stream",
100+
desired.label()
101+
);
102+
}
103+
let routes = cmd_run::auto_routes(ready, Some((0..4).collect()))?;
104+
cmd_run::stream_routes(routes, cmd_run::StreamOptions::default()).await
105+
}
106+
107+
fn select_targets(
108+
picos: &[cmd_run::PicoTarget],
109+
selectors: &[String],
110+
all: bool,
111+
) -> Result<Vec<cmd_run::PicoTarget>> {
112+
if all {
113+
return Ok(picos.to_vec());
114+
}
115+
if !selectors.is_empty() {
116+
let mut out: Vec<cmd_run::PicoTarget> = Vec::new();
117+
for s in selectors {
118+
let p = cmd_run::match_pico_selector(s, picos)?;
119+
if !out
120+
.iter()
121+
.any(|q| q.info.unique_id_short == p.info.unique_id_short)
122+
{
123+
out.push(p);
124+
}
125+
}
126+
return Ok(out);
127+
}
128+
match picos {
129+
[one] => Ok(vec![one.clone()]),
130+
[] => bail!("no Pico found on Wi-Fi"),
131+
_ => bail!("more than one Pico found; pass a UID/IP/board selector or --all"),
132+
}
133+
}
134+
135+
async fn wait_for_persona(
136+
uids: &[u32],
137+
desired: Persona,
138+
timeout: Duration,
139+
) -> Result<Vec<cmd_run::PicoTarget>> {
140+
let want: HashSet<u32> = uids.iter().copied().collect();
141+
let deadline = Instant::now() + timeout;
142+
loop {
143+
let picos = cmd_run::discover_picos(Duration::from_secs(2)).await?;
144+
let matched: Vec<cmd_run::PicoTarget> = picos
145+
.into_iter()
146+
.filter(|p| want.contains(&p.info.unique_id_short))
147+
.collect();
148+
let all_confirmed = want.iter().all(|uid| {
149+
matched
150+
.iter()
151+
.any(|p| p.info.unique_id_short == *uid && p.persona == desired)
152+
});
153+
if all_confirmed {
154+
return Ok(matched);
155+
}
156+
if Instant::now() >= deadline {
157+
return Ok(matched);
158+
}
159+
tokio::time::sleep(Duration::from_millis(500)).await;
160+
}
161+
}
162+
163+
fn merge_targets(
164+
original: Vec<cmd_run::PicoTarget>,
165+
reappeared: Vec<cmd_run::PicoTarget>,
166+
switched_uids: &[u32],
167+
) -> Vec<cmd_run::PicoTarget> {
168+
let switched: HashSet<u32> = switched_uids.iter().copied().collect();
169+
// Keep the originals that weren't switched, then add whatever the
170+
// re-discovery turned up for the switched ones.
171+
let mut out: Vec<cmd_run::PicoTarget> = original
172+
.into_iter()
173+
.filter(|p| !switched.contains(&p.info.unique_id_short))
174+
.collect();
175+
out.extend(reappeared);
176+
out
177+
}

0 commit comments

Comments
 (0)