Files
punktfunk/sdk/examples/virtualhere-dualsense.ts
T
enricobuehler 081ff64087
apple / swift (push) Successful in 1m22s
ci / web (push) Successful in 1m11s
ci / docs-site (push) Successful in 1m11s
ci / bench (push) Successful in 5m39s
apple / screenshots (push) Successful in 6m38s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 57s
deb / build-publish (push) Successful in 9m0s
deb / build-publish-host (push) Successful in 9m20s
windows-host / package (push) Successful in 9m46s
android / android (push) Successful in 18m37s
arch / build-publish (push) Successful in 18m48s
docker / deploy-docs (push) Successful in 28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m44s
ci / rust (push) Successful in 26m30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m2s
docs: explain how to actually run the VirtualHere passthrough example
The VirtualHere DualSense recipe linked the `virtualhere-dualsense.ts` SDK
example as a deployable recipe but skipped everything needed to run it: that
VirtualHere is a server (couch) + client (host) pair, and that the example —
like every example — imports `../src/index.js`, which only resolves inside the
SDK repo. A user copying it out had no way to know they must
`bun add @punktfunk/host` and swap that import.

- automation.md: rewrite the recipe into a full walkthrough — the two-sided
  VirtualHere setup, the `-t` verbs, the zero-code hooks version, and a new
  scripted section with exact deploy steps, env vars, and running it as a
  service (its own SIGTERM handler makes `systemctl stop` release the pad).
- sdk/README.md: say how to run an example in-repo vs deployed on a host.
- virtualhere-dualsense.ts: header note on the import swap + service setup,
  since that file is where the doc link lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 17:28:23 +02:00

92 lines
4.5 KiB
TypeScript

// ── 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
//
// To run this *outside* the repo (the normal case), drop it in its own dir, `bun add
// @punktfunk/host`, and change the import below from `../src/index.js` to `@punktfunk/host`; then
// keep it alive as a systemd user unit (its SIGTERM release hands the pad back on `systemctl
// stop`). Full walkthrough: docs → Events & hooks → "full controller passthrough (VirtualHere)".
//
// 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)));