feat(sdk): VirtualHere DualSense passthrough example + automation recipe
Add sdk/examples/virtualhere-dualsense.ts — bind a real USB DualSense (shared from the couch via VirtualHere USB-over-IP) to the host for the length of each connection and release it after, for full gyro/touchpad/adaptive-trigger/USB- rumble passthrough instead of the emulated pad. Brackets on client.connected/ disconnected and releases the pad on SIGTERM for a clean runner stop. Document it in the Events & hooks page with a zero-code hooks.json variant. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -151,3 +151,28 @@ The canonical "decide, don't just observe" pattern — approve pairing from your
|
|||||||
`pairing.pending`, send yourself a notification, and call
|
`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
|
`POST /api/v1/native/pending/{id}/approve` when you tap yes. The full API is documented at
|
||||||
[`/api/docs`](/api) on your host.
|
[`/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,<couch-ip>: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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
// `<VH_CLIENT> -t "MANUAL HUB ADD,<couch-ip>: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<string>((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<string | null> {
|
||||||
|
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<void> {
|
||||||
|
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)));
|
||||||
Reference in New Issue
Block a user