diff --git a/docs-site/content/docs/automation.md b/docs-site/content/docs/automation.md index f01ef40f..6111781b 100644 --- a/docs-site/content/docs/automation.md +++ b/docs-site/content/docs/automation.md @@ -151,3 +151,28 @@ The canonical "decide, don't just observe" pattern — approve pairing from your `pairing.pending`, send yourself a notification, and call `POST /api/v1/native/pending/{id}/approve` when you tap yes. The full API is documented at [`/api/docs`](/api) on your host. + +## Recipe: full controller passthrough (VirtualHere) + +To get a controller's *native* features on the host — DualSense gyro, touchpad, adaptive +triggers, USB rumble — instead of the emulated pad, share the physical device from the couch with +[VirtualHere](https://www.virtualhere.com/) (USB-over-IP) and bind it to the host only while a +client is connected. The couch runs the VirtualHere **server** (sharing the pad); the host runs +the VirtualHere **client** and this automation drives its `-t` IPC. + +Zero-code, bracketed on the stream with two hooks: + +```json +{ + "hooks": [ + { "on": "stream.started", "run": "vhclientx86_64 -t \"USE,couch-deck.11\"" }, + { "on": "stream.stopped", "run": "vhclientx86_64 -t \"STOP USING,couch-deck.11\"" } + ] +} +``` + +`couch-deck.11` is the device's VirtualHere address (`vhclientx86_64 -t LIST`); same-LAN setups +auto-discover it, otherwise `MANUAL HUB ADD,:7575` once. For a version that resolves the +device by name, filters to one couch, and releases the pad on a clean shutdown, see the +[`virtualhere-dualsense.ts`](https://git.unom.io/unom/punktfunk/src/branch/main/sdk/examples/virtualhere-dualsense.ts) +SDK example. diff --git a/sdk/examples/virtualhere-dualsense.ts b/sdk/examples/virtualhere-dualsense.ts new file mode 100644 index 00000000..bf65c48e --- /dev/null +++ b/sdk/examples/virtualhere-dualsense.ts @@ -0,0 +1,86 @@ +// ── Recipe · VirtualHere USB passthrough → full DualSense ──────────────────────────────────── +// Hand a *real* USB DualSense to the host for the length of a session, then give it back — so the +// game sees native gyro, touchpad, adaptive triggers and USB rumble instead of an emulated pad. +// +// Topology (USB-over-IP): the DualSense is plugged into the *couch* device, which runs the +// VirtualHere **Server** and shares it. The punktfunk **host** — where this script and the game +// run — runs the VirtualHere **Client** and mounts it. This script drives that client's `-t` IPC: +// `USE` the pad when a client connects, `STOP USING` it when they leave, so the couch keeps its +// own controller whenever you're not streaming. +// +// On the host you need: the VirtualHere client running (service or app) and the couch's shared +// DualSense visible to it — same-LAN setups auto-discover; otherwise add it once with +// ` -t "MANUAL HUB ADD,:7575"`. +// +// VH_DEVICE=couch-deck.11 bun examples/virtualhere-dualsense.ts # address from `-t LIST` +// VH_DEVICE=DualSense bun examples/virtualhere-dualsense.ts # …or match by name substring +// +// Env: VH_DEVICE required — a VirtualHere address (`server.port`) or a device-name substring. +// VH_CLIENT client binary. Default `vhclientx86_64` (Linux); Windows `vhui64.exe`, +// macOS `vhclientosx`, ARM Linux `vhclientarm64`. +// VH_ONLY_CLIENT optional — only bind for this punktfunk client label (multi-couch setups). +import { execFile } from "node:child_process"; +import { connect } from "../src/index.js"; + +const VH_CLIENT = process.env.VH_CLIENT ?? "vhclientx86_64"; +const VH_DEVICE = process.env.VH_DEVICE; +const ONLY_CLIENT = process.env.VH_ONLY_CLIENT; +if (!VH_DEVICE) + throw new Error("set VH_DEVICE to a VirtualHere address (e.g. couch-deck.11) or a device-name substring"); + +// Send one command to the local VirtualHere client over its IPC channel and return the reply. +const vh = (command: string) => + new Promise((resolve, reject) => + execFile(VH_CLIENT, ["-t", command], (err, stdout) => (err ? reject(err) : resolve(stdout))), + ); + +// Turn a name substring into a VirtualHere address (`server.port`); pass an address straight through. +async function resolveAddress(target: string): Promise { + if (/^[\w.-]+\.\d+$/.test(target)) return target; + for (const line of (await vh("LIST")).split("\n")) + if (line.toLowerCase().includes(target.toLowerCase())) { + const addr = line.match(/[\w.-]+\.\d+/); // the address token on the device's line + if (addr) return addr[0]; + } + return null; +} + +const pf = await connect(); +let bound: string | null = null; // the address we currently hold, so we can release exactly it +console.log(`VirtualHere passthrough ready — "${VH_DEVICE}" via ${VH_CLIENT}`); + +// Bind the pad to the host as soon as a couch connects. (Prefer `stream.started`/`stream.stopped` +// instead if you'd rather pass it through only while video is actually flowing.) +pf.events.on("client.connected", async (e) => { + if (ONLY_CLIENT && e.client.name !== ONLY_CLIENT) return; + try { + const addr = await resolveAddress(VH_DEVICE); + if (!addr) + return console.warn(`"${VH_DEVICE}" not visible to the VirtualHere client — is the couch sharing it?`); + await vh(`USE,${addr}`); + bound = addr; + console.log(`bound ${addr} → ${e.client.name}`); + } catch (err) { + console.error("USE failed:", err); + } +}); + +async function release(why: string): Promise { + if (!bound) return; + try { + await vh(`STOP USING,${bound}`); + console.log(`released ${bound} (${why})`); + } catch (err) { + console.error("STOP USING failed:", err); + } + bound = null; +} + +pf.events.on("client.disconnected", (e) => { + if (ONLY_CLIENT && e.client.name !== ONLY_CLIENT) return; + void release(`${e.client.name} left: ${e.reason}`); +}); + +// Clean shutdown (`systemctl stop`, ^C): hand the pad back to the couch before exiting. +for (const sig of ["SIGINT", "SIGTERM"] as const) + process.on(sig, () => void release("runner stopping").then(() => process.exit(0)));