mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 05:00:08 +02:00
Compare commits
104
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06f2dd63c1 | ||
|
|
ec870d2d7a | ||
|
|
80797c98b9 | ||
|
|
2fadbbe148 | ||
|
|
5ee07f99a8 | ||
|
|
f0b93b79d3 | ||
|
|
13a530d829 | ||
|
|
f260a6a9e4 | ||
|
|
2e09cf9efe | ||
|
|
9a4b66ff50 | ||
|
|
17269134ce | ||
|
|
d995b815f8 | ||
|
|
f18918f781 | ||
|
|
e0208687b6 | ||
|
|
c699e13104 | ||
|
|
9b12b7d74e | ||
|
|
c7c7c1f772 | ||
|
|
2486a601e3 | ||
|
|
de25633f87 | ||
|
|
53a1836dbb | ||
|
|
73bd0ea28f | ||
|
|
519c623ea9 | ||
|
|
8b1327fe1a | ||
|
|
2351cec310 | ||
|
|
1eb120e6b1 | ||
|
|
8321a99121 | ||
|
|
ea483f7ce3 | ||
|
|
e62ebb8669 | ||
|
|
6fb168a8b8 | ||
|
|
8dbcdecf90 | ||
|
|
b67c973f64 | ||
|
|
99aec84f62 | ||
|
|
e44837cee6 | ||
|
|
95c5d056cf | ||
|
|
e6a931f7ee | ||
|
|
60667dba1f | ||
|
|
0c95d1d731 | ||
|
|
fcb465a45a | ||
|
|
d6930a61f5 | ||
|
|
155deada52 | ||
|
|
597551cf23 | ||
|
|
33fb3bb611 | ||
|
|
6cae2e0afe | ||
|
|
af2ea7c0ee | ||
|
|
060b4957f8 | ||
|
|
57617d4a14 | ||
|
|
83850bb1b7 | ||
|
|
d5a15ebc36 | ||
|
|
b7dfe4759e | ||
|
|
5b6cfcd746 | ||
|
|
e1f7ccee29 | ||
|
|
d8c2ed0c58 | ||
|
|
7851ce90cc | ||
|
|
e1ee5ef395 | ||
|
|
3e7248a04e | ||
|
|
0680297efa | ||
|
|
a11cef49ba | ||
|
|
19032f8963 | ||
|
|
8a02e41b21 | ||
|
|
30e02e9db7 | ||
|
|
06c4ff9556 | ||
|
|
8760ca3083 | ||
|
|
a05e500d30 | ||
|
|
fda6302bd3 | ||
|
|
76e5c4932e | ||
|
|
ae4a1e30ab | ||
|
|
b0415cf150 | ||
|
|
5b08e43a5d | ||
|
|
a533bb973a | ||
|
|
8aaf7db05a | ||
|
|
7fbcd177d3 | ||
|
|
97b7fc837c | ||
|
|
3ebae6a88f | ||
|
|
554820ffc5 | ||
|
|
bdc84b24d4 | ||
|
|
79eb251d6c | ||
|
|
35196e2955 | ||
|
|
abb05eb68c | ||
|
|
1fb6e7a38e | ||
|
|
27b731878c | ||
|
|
3b2f065c85 | ||
|
|
c6531148d7 | ||
|
|
7b80592665 | ||
|
|
49b8e050db | ||
|
|
ff36356992 | ||
|
|
4d50739393 | ||
|
|
f60da63702 | ||
|
|
82d2107e1d | ||
|
|
12f2e844d5 | ||
|
|
ee2ce1a8d2 | ||
|
|
540d8c425c | ||
|
|
ba6ccdfbd1 | ||
|
|
77f28f0922 | ||
|
|
85532a54f3 | ||
|
|
3864741f53 | ||
|
|
12ad93d24c | ||
|
|
a7f9a8dd80 | ||
|
|
ac142f6e4a | ||
|
|
b417534425 | ||
|
|
c377133859 | ||
|
|
675ef3f0d5 | ||
|
|
fadc0f467c | ||
|
|
2487351556 | ||
|
|
4479fbfcc1 |
No files matched your search
@@ -26,7 +26,7 @@ A Steam Frame is someone's personal headset, and they may be wearing it while yo
|
||||
|
||||
- Don't kill or restart `gamescope`, `steam`, `vrserver`, `vrcompositor`, the gamescope session, or the Frametop desktop without asking. Each one ends or disrupts whatever is happening in VR.
|
||||
- Don't run host `sudo`, `steamos-readonly disable`, `steamos-devmode` changes, pacman installs, or reboots without explicit approval. Three installers need host `sudo`, and they ask for it: the Bluetooth fixes (`setup/bluetooth/install.sh`), hand tracking (`hands/run.sh install` and `caps`, which set ft-camd's file capabilities with `setcap`), and our own eye tracker's frame grabber (`gaze/tracker/install.sh`).
|
||||
- Write only inside the repo, `/tmp`, and the container unless told otherwise. The installers are the exception: they write the user services, launchers, and the SteamVR driver into the home folder. The Bluetooth fixes and the eye tracker's frame grabber also install root-owned files and system services under `/etc` (`/etc/steamframe`, `/etc/frametop`, `/etc/systemd/system`). When an installer starts writing something new outside the repo, add it to `uninstall.sh` too: users uninstall with that script, not with each installer's `uninstall`.
|
||||
- Write only inside the repo, `/tmp`, and the container unless told otherwise. The installers are the exception: they write the user services, launchers, and the SteamVR driver into the home folder. The Bluetooth fixes and the eye tracker's frame grabber also install root-owned files and system services under `/etc` (`/etc/steamframe`, `/etc/frametop`, `/etc/systemd/system`).
|
||||
- Never copy `.netrc`, SSH keys, or Steam config off the Frame or into this repo.
|
||||
|
||||
## SteamOS updates
|
||||
|
||||
@@ -171,22 +171,23 @@ Or by hand: `cd ~/frametop && git pull && ./install.sh`.
|
||||
|
||||
## Uninstall
|
||||
|
||||
In a terminal on the headset, run:
|
||||
|
||||
```
|
||||
curl -fsSL https://deejanuz.github.io/frametop/uninstall.sh | bash
|
||||
./desktops.sh uninstall # the launcher's Desktop entry goes back to the stock desktop
|
||||
./desktops.sh relay uninstall
|
||||
pointer/helper/run.sh uninstall
|
||||
power/run.sh uninstall
|
||||
pointer/driver/install.sh uninstall # then restart SteamVR
|
||||
input-settings/install.sh uninstall
|
||||
display-settings/install.sh uninstall
|
||||
remote/install.sh uninstall
|
||||
setup/bluetooth/install.sh uninstall # if you installed the Bluetooth fixes
|
||||
hands/run.sh uninstall # if you installed hand tracking by hand
|
||||
gaze/run.sh uninstall # if you installed the gaze service
|
||||
gaze/tracker/install.sh uninstall # if you installed our own eye tracker's frame grabber
|
||||
gaze/probe/install.sh uninstall # if you installed the gaze probe
|
||||
```
|
||||
|
||||
It works in two steps, so it never takes away the keyboard, mouse, or desktop you're using while it runs:
|
||||
|
||||
1. It stops Frametop from starting. Launch a program → Desktop opens the stock desktop again, and Frametop's services, its SteamVR driver, its menu entries, and the system files of our eye tracker and the Bluetooth fixes are removed (those need your `sudo` password). Everything running now keeps running until you restart the headset, and it offers to restart it for you.
|
||||
2. After the restart, run the same command again. It deletes the code in `~/frametop`, and asks whether to delete your settings, any eye or hand recordings, and the build container (1–2 GB) too.
|
||||
|
||||
To see what it would do without changing anything, add `-s -- --dry-run` after `bash`. If the code isn't in `~/frametop`, add `-s -- --dir <folder>`. From the repo, the same script is `./uninstall.sh`.
|
||||
|
||||
Don't delete `~/frametop` by hand before you uninstall and restart: the desktop and the input relay run from it, and without it Launch a program → Desktop no longer opens anything. If you've already deleted it, the command above still works, since it doesn't need the repo.
|
||||
|
||||
Unless you ask for them to go, your settings stay: `~/.config/frametop.conf`, `frametop-input.json` (button maps and key combinations), `frametop-layout.json` (the layout and profiles), `frametop-float.json`, and `frametop-remote/` in `~/.config`, the gaze calibration in `~/.local/state/frametop`, and the desktop's own Plasma setup in `~/.config/frametop`. A later install picks them up again.
|
||||
Your settings stay: `~/.config/frametop.conf`, `frametop-input.json` (button maps and key combinations), `frametop-layout.json` (the layout and profiles), `frametop-float.json`, and `frametop-remote/` in `~/.config`, and the gaze calibration in `~/.local/state/frametop/gaze`. So does the desktop's own Plasma setup, in `~/.config/frametop`. Delete them too for a clean slate.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -196,7 +197,6 @@ A Plasma session runs nested inside ft-screens (`screens/`), a small Wayland com
|
||||
| --- | --- |
|
||||
| `get.sh` | The one-line installer: picks stable or experimental, clones or updates the repo, and runs `install.sh`. |
|
||||
| `install.sh` | The one-step installer. Safe to re-run. |
|
||||
| `uninstall.sh` | The uninstaller: run it, restart the headset, and run it again. It doesn't need the rest of the repo. |
|
||||
| `desktops.sh` | Start, stop, and configure the desktop, and install the input relay. |
|
||||
| `screens/` | ft-screens, the compositor (wlroots and OpenVR). |
|
||||
| `session/` | The desktop session script and its config example. |
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Install with FrameDrop (proof of concept)
|
||||
|
||||
[FrameDrop](https://framedropvr.com) is a Windows app that sideloads onto a Steam Frame: it copies a build to the headset and adds it to the Steam library. Issue #25 asks for an "Install with FrameDrop" button. Frametop isn't an app FrameDrop can copy over as is: it builds in a container, installs user services and a SteamVR driver, and asks questions in a terminal. So FrameDrop installs a small installer instead. Playing "Frametop" from the library runs the one-line installer (`get.sh --yes`) and shows its progress.
|
||||
|
||||
Nothing here is published yet: no release asset, no manifest on Pages, no button.
|
||||
|
||||
## How FrameDrop installs a Linux zip
|
||||
|
||||
It uses Valve's SteamOS Devkit path: pair once with the headset's devkit service, then rsync the unpacked zip into `~/devkit-game/<name>` over SSH, and register it with Steam as a Devkit Game with a start command. `devkit.sh` here makes the same calls with Valve's devkit-utils, so all of this can be tested on the Frame without a PC.
|
||||
|
||||
## What the probe found (SteamOS 0.3.0, build 20260922.6101926)
|
||||
|
||||
`probe/probe.sh`, started as a Devkit Game, recorded:
|
||||
|
||||
- Devkit titles run on the host, not in a container, as user `steamos`, from a process tree that Steam's reaper owns. This held even with the compat tool set to `SteamLinuxRuntime_4-arm64`: Steam recorded the mapping and still ran it on the host.
|
||||
- On the host, everything the installer needs works: git, curl to GitHub, podman (sees the `dev` container), `systemctl --user`, `systemd-run --user`, and GTK 4 with libadwaita.
|
||||
- A GTK window opens in gamescope (an X11 window on `:1`, drawn through gamescope's Vulkan WSI).
|
||||
- Steam puts its overlay in `LD_PRELOAD` and Steam runtime paths in `LD_LIBRARY_PATH` and `PATH`. Every host tool prints a preload error unless they're cleared.
|
||||
- Inside the Steam Linux Runtime 4 container (started by hand with its `run` script), there's no git, podman, systemctl, or GTK, but `flatpak-spawn --host` runs commands on the host.
|
||||
- Steam refuses Devkit Game names with a `-` ("missing/invalid arguments").
|
||||
- Steam doesn't make the start command executable: without the exec bit, the title exits in a second and nothing runs.
|
||||
|
||||
## The installer
|
||||
|
||||
`installer/frametop-install.sh` is the start command.
|
||||
|
||||
1. In the container, it starts itself again on the host with `flatpak-spawn --host`.
|
||||
2. It clears Steam's preload and library paths.
|
||||
3. It runs `curl get.sh | bash -s -- --yes` in a transient user service, `frametop-framedrop-install`, unless one is already running. The service is used because Steam ends the title's whole process tree when it's quit, and starts it with an OOM score of 900.
|
||||
4. `installer/progress.py` (GTK 4 and libadwaita) follows the service's log, shows `install.sh`'s steps as a progress bar, and reports the result. Closing it leaves the install running. Playing the title again reattaches.
|
||||
|
||||
`--yes` keeps the version that's installed, or installs stable. It skips the parts that need sudo (our eye tracker and the Bluetooth fixes) and the SteamVR restart. The window says to restart SteamVR.
|
||||
|
||||
`--dry-run` clones into `~/.cache/frametop-framedrop/dry-run` and stops there (`get.sh --clone-only`).
|
||||
|
||||
## Try it on the Frame
|
||||
|
||||
```
|
||||
framedrop/devkit.sh add FrametopTest framedrop/installer "./frametop-install.sh --dry-run"
|
||||
framedrop/devkit.sh run FrametopTest # or Play it from the Steam library
|
||||
framedrop/devkit.sh remove FrametopTest
|
||||
framedrop/devkit.sh add FrametopProbe framedrop/probe "./probe.sh native" # the probe
|
||||
```
|
||||
|
||||
The probe writes `~/.cache/frametop-framedrop/probe-native.log`, and the installer writes `~/.cache/frametop-framedrop/install.log`.
|
||||
|
||||
## Build the download
|
||||
|
||||
```
|
||||
framedrop/build.sh [ZIP_URL]
|
||||
```
|
||||
|
||||
This writes `framedrop/build/Frametop.zip` (reproducible) and `frametop.framedrop.json`, FrameDrop's manifest with the zip's sha256. By default, `ZIP_URL` points at a `framedrop-installer` release asset. The button's link would be `https://framedropvr.com/install?manifest=https://deejanuz.github.io/frametop/frametop.framedrop.json`, with the manifest committed to main for Pages.
|
||||
|
||||
## Open questions, for a test with FrameDrop on a Windows PC
|
||||
|
||||
- Does FrameDrop keep or set the exec bit on `frametop-install.sh`? A zip unpacked on Windows loses it, and without it nothing runs.
|
||||
- What start command does FrameDrop pick for this zip, and which runtime?
|
||||
- Does the manifest's `name` become the Devkit Game name? It has to stay free of `-`.
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the FrameDrop download: framedrop/build/Frametop.zip (the installer in a "Frametop"
|
||||
# folder) and framedrop/build/frametop.framedrop.json, the manifest an "Install with
|
||||
# FrameDrop" button points at. The zip is reproducible: same files, same sha256.
|
||||
#
|
||||
# Usage: framedrop/build.sh [ZIP_URL]
|
||||
# ZIP_URL where the zip will be downloaded from (default: the framedrop-installer release)
|
||||
set -euo pipefail
|
||||
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
url=${1:-https://github.com/DeeJanuz/frametop/releases/download/framedrop-installer/Frametop.zip}
|
||||
out=$here/build
|
||||
mkdir -p "$out"
|
||||
|
||||
python3 - "$here/installer" "$out/Frametop.zip" <<'EOF'
|
||||
import os, sys, zipfile
|
||||
src, dest = sys.argv[1:]
|
||||
with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for name in ("frametop-install.sh", "progress.py"):
|
||||
info = zipfile.ZipInfo(f"Frametop/{name}", date_time=(2026, 1, 1, 0, 0, 0))
|
||||
info.create_system = 3 # unix, so the permissions below count
|
||||
info.external_attr = (0o100755 if os.access(os.path.join(src, name), os.X_OK) else 0o100644) << 16
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
with open(os.path.join(src, name), "rb") as f:
|
||||
z.writestr(info, f.read())
|
||||
EOF
|
||||
|
||||
sha=$(sha256sum "$out/Frametop.zip" | cut -d' ' -f1)
|
||||
python3 - "$url" "$sha" >"$out/frametop.framedrop.json" <<'EOF'
|
||||
import json, sys
|
||||
url, sha = sys.argv[1:]
|
||||
print(json.dumps({
|
||||
"schema": "framedrop.install/v1",
|
||||
"name": "Frametop",
|
||||
"files": [{"url": url, "sha256": sha}],
|
||||
}, indent=2))
|
||||
EOF
|
||||
echo "built $out/Frametop.zip ($sha)"
|
||||
echo " $out/frametop.framedrop.json"
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# Do on the Frame what FrameDrop does from a PC: copy a folder into ~/devkit-game/NAME and
|
||||
# register it with Steam as a "Devkit Game" (Valve's devkit-utils, the same calls FrameDrop and
|
||||
# the SteamOS Devkit Client make over SSH). For testing the FrameDrop installer and the probe
|
||||
# without a PC. Steam must be running, and Developer Mode on.
|
||||
#
|
||||
# Usage: devkit.sh add NAME DIR COMMAND [--compat TOOL]
|
||||
# devkit.sh run NAME # start it, as the library's Play button does
|
||||
# devkit.sh remove NAME # delete the folder and the Steam entry
|
||||
# devkit.sh list
|
||||
#
|
||||
# NAME can't contain "-": Steam answers "missing/invalid arguments". COMMAND is relative to
|
||||
# the folder, like FrameDrop's start command ("./probe.sh native"). --compat sets the runtime
|
||||
# (SteamLinuxRuntime_4-arm64, say); on SteamOS 0.3.0 Steam recorded it but still ran the
|
||||
# title on the host. There's no environment option: this Steam ignores the devkit env file.
|
||||
#
|
||||
# devkit-utils is Valve's (LGPL, gitlab.steamos.cloud/devkit/steamos-devkit). FrameDrop and
|
||||
# the devkit client copy it to ~/devkit-utils; if that isn't there, it's fetched at a pinned
|
||||
# commit into ~/.cache/frametop-framedrop.
|
||||
set -euo pipefail
|
||||
|
||||
devkit_rev=a00ceb7d91ea44a0c3e714a91a06417d6e5cdb33
|
||||
cache=$HOME/.cache/frametop-framedrop
|
||||
|
||||
usage() { sed -n '7,10p' "$0" | sed 's/^# \{0,1\}//' >&2; exit 2; }
|
||||
|
||||
utils() {
|
||||
if [ -e "$HOME/devkit-utils/steam-client-create-shortcut" ]; then
|
||||
echo "$HOME/devkit-utils"; return
|
||||
fi
|
||||
if [ ! -e "$cache/devkit-utils/steam-client-create-shortcut" ]; then
|
||||
echo "fetching devkit-utils ($devkit_rev)" >&2
|
||||
local tmp
|
||||
tmp=$(mktemp -d)
|
||||
git -C "$tmp" init -q
|
||||
git -C "$tmp" fetch -q --depth 1 https://gitlab.steamos.cloud/devkit/steamos-devkit.git "$devkit_rev"
|
||||
git -C "$tmp" checkout -q FETCH_HEAD
|
||||
mkdir -p "$cache"
|
||||
rm -rf "$cache/devkit-utils"
|
||||
cp -r "$tmp/client/devkit-utils" "$cache/devkit-utils"
|
||||
rm -rf "$tmp"
|
||||
fi
|
||||
echo "$cache/devkit-utils"
|
||||
}
|
||||
|
||||
[ $# -ge 1 ] || usage
|
||||
cmd=$1; shift
|
||||
case $cmd in
|
||||
add)
|
||||
[ $# -ge 3 ] || usage
|
||||
name=$1 src=$2 start=$3; shift 3
|
||||
case $name in *-*) echo "NAME can't contain '-' (Steam refuses it)" >&2; exit 2 ;; esac
|
||||
compat=
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
--compat) compat=${2:?}; shift ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
u=$(utils)
|
||||
dir=$(python3 "$u/steamos-prepare-upload" --gameid "$name" | python3 -c 'import json,sys; print(json.load(sys.stdin)["directory"])')
|
||||
# FrameDrop rsyncs the unpacked zip; --delete as a clean upload would.
|
||||
rsync -a --delete "$src/" "$dir/"
|
||||
parms=$(python3 - "$name" "$dir" "$start" "$compat" <<'EOF'
|
||||
import json, sys
|
||||
name, directory, start, compat = sys.argv[1:]
|
||||
print(json.dumps({
|
||||
"gameid": name, "directory": directory,
|
||||
"argv": [start], "env": {},
|
||||
"settings": {"steam_play": "0", "compat_tool": compat},
|
||||
"force_appid": "", "lepton_args": "",
|
||||
}))
|
||||
EOF
|
||||
)
|
||||
res=$(python3 "$u/steam-client-create-shortcut" --parms "$parms" | tail -1)
|
||||
echo "Steam: $res"
|
||||
case $res in *'"error"'*) exit 1 ;; esac
|
||||
echo "registered $name ($dir)"
|
||||
;;
|
||||
run)
|
||||
[ $# -eq 1 ] || usage
|
||||
python3 "$(utils)/steam-devkit-rpc" run-game "gameid=$1" | tail -1
|
||||
echo
|
||||
;;
|
||||
remove)
|
||||
[ $# -eq 1 ] || usage
|
||||
python3 "$(utils)/steamos-delete" --delete-title "$1"
|
||||
rm -f "$HOME/devkit-game/$1"-*.json
|
||||
;;
|
||||
list)
|
||||
python3 "$(utils)/steam-devkit-rpc" list-shortcuts
|
||||
;;
|
||||
*) usage ;;
|
||||
esac
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# Frametop's FrameDrop installer: the start command of the "Frametop" title that FrameDrop
|
||||
# puts in the Steam library. Playing it installs Frametop (or updates it) with the one-line
|
||||
# installer, get.sh --yes, and shows its progress in a window.
|
||||
#
|
||||
# Steam ends a title's whole process tree when it's quit, and starts it with a high OOM score,
|
||||
# so the install runs in a user service of its own (frametop-framedrop-install) and the window
|
||||
# only follows its log. Quitting or closing the window leaves the install running; playing the
|
||||
# title again reattaches to it.
|
||||
#
|
||||
# Usage: frametop-install.sh [--dry-run]
|
||||
# --dry-run clone into ~/.cache/frametop-framedrop/dry-run instead of ~/frametop, and
|
||||
# don't run install.sh (get.sh --clone-only), for testing this flow
|
||||
set -u
|
||||
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
self=$here/$(basename "$0")
|
||||
|
||||
# A Linux title can be started in the Steam Linux Runtime container, which has no git, podman,
|
||||
# systemctl, or GTK. Start this again on the host. (SteamOS 0.3.0 runs devkit titles on the
|
||||
# host anyway; this is for when FrameDrop or Steam picks the runtime.)
|
||||
if [ -e /run/pressure-vessel ]; then
|
||||
exec flatpak-spawn --host --watch-bus --env=DISPLAY="${DISPLAY-}" \
|
||||
--env=XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR-}" --env=SteamAppId="${SteamAppId-}" \
|
||||
--env=ENABLE_GAMESCOPE_WSI="${ENABLE_GAMESCOPE_WSI-}" /usr/bin/bash "$self" "$@"
|
||||
fi
|
||||
|
||||
# Steam adds its overlay and runtime libraries to every child; host tools don't want them.
|
||||
unset LD_PRELOAD LD_LIBRARY_PATH
|
||||
export PATH=/usr/local/bin:/usr/bin:/bin
|
||||
|
||||
dry_run=0
|
||||
[ "${1-}" = --dry-run ] && dry_run=1
|
||||
|
||||
unit=frametop-framedrop-install
|
||||
state=$HOME/.cache/frametop-framedrop
|
||||
log=$state/install.log
|
||||
mkdir -p "$state"
|
||||
|
||||
get_args="--yes"
|
||||
[ "$dry_run" = 1 ] && get_args="--yes --clone-only --dir $state/dry-run"
|
||||
|
||||
# Start the install unless one is running already. RemainAfterExit keeps its result for the
|
||||
# window; the next start clears it.
|
||||
if [ "$(systemctl --user show -P ActiveState "$unit" 2>/dev/null)" != activating ]; then
|
||||
systemctl --user stop "$unit" 2>/dev/null
|
||||
systemctl --user reset-failed "$unit" 2>/dev/null
|
||||
: >"$log"
|
||||
systemd-run --user --unit="$unit" --description="Frametop install (FrameDrop)" \
|
||||
--property=Type=oneshot --property=RemainAfterExit=yes \
|
||||
--property=StandardOutput="truncate:$log" --property=StandardError=inherit \
|
||||
--setenv=HOME="$HOME" --setenv=PATH="$PATH" --setenv=TERM=dumb \
|
||||
--working-directory="$HOME" --quiet --no-block \
|
||||
/usr/bin/bash -c 'set -o pipefail; curl -fsSL https://deejanuz.github.io/frametop/get.sh | bash -s -- '"$get_args" ||
|
||||
{ echo "couldn't start the install service" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
exec /usr/bin/python3 "$here/progress.py" "$unit" "$log"
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Progress window for the FrameDrop installer: follows the install service's log and state.
|
||||
|
||||
Usage: progress.py UNIT LOG
|
||||
|
||||
Closing the window doesn't stop the install; it keeps running in the user service UNIT.
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
from gi.repository import Adw, GLib, Gtk, Pango # noqa: E402
|
||||
|
||||
UNIT, LOG = sys.argv[1], sys.argv[2]
|
||||
ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
||||
# install.sh marks each step with "== N/10 what it is"
|
||||
STEP = re.compile(r"^== (\d+)/(\d+) (.*)$")
|
||||
DONE_TEXT = ("Frametop is installed. Restart SteamVR once, or reboot the headset, so it loads "
|
||||
"Frametop's driver. Then open Launch a program, then Desktop.")
|
||||
|
||||
|
||||
def unit_state():
|
||||
out = subprocess.run(
|
||||
["systemctl", "--user", "show", "-P", "ActiveState", UNIT],
|
||||
capture_output=True, text=True).stdout.strip()
|
||||
return out or "inactive"
|
||||
|
||||
|
||||
class Window(Adw.ApplicationWindow):
|
||||
def __init__(self, app):
|
||||
super().__init__(application=app, title="Frametop")
|
||||
self.set_default_size(900, 600)
|
||||
self.offset = 0
|
||||
self.partial = ""
|
||||
|
||||
self.status = Gtk.Label(label="Installing Frametop", xalign=0, wrap=True)
|
||||
self.status.add_css_class("title-2")
|
||||
self.detail = Gtk.Label(label="Starting...", xalign=0, wrap=True)
|
||||
self.bar = Gtk.ProgressBar()
|
||||
self.bar.pulse()
|
||||
|
||||
self.text = Gtk.TextView(editable=False, cursor_visible=False, monospace=True,
|
||||
wrap_mode=Pango.WrapMode.WORD_CHAR)
|
||||
self.text.set_left_margin(8)
|
||||
self.text.set_right_margin(8)
|
||||
scroll = Gtk.ScrolledWindow(vexpand=True, child=self.text)
|
||||
self.scroll = scroll
|
||||
|
||||
self.note = note = Gtk.Label(
|
||||
label="You can close this window: the install keeps going. Play Frametop again to "
|
||||
"come back to it.", xalign=0, wrap=True)
|
||||
note.add_css_class("dim-label")
|
||||
close = Gtk.Button(label="Close", halign=Gtk.Align.END)
|
||||
close.connect("clicked", lambda *_: self.close())
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12,
|
||||
margin_top=18, margin_bottom=18, margin_start=18, margin_end=18)
|
||||
for w in (self.status, self.detail, self.bar, scroll, note, close):
|
||||
box.append(w)
|
||||
view = Adw.ToolbarView(content=box)
|
||||
view.add_top_bar(Adw.HeaderBar())
|
||||
self.set_content(view)
|
||||
|
||||
self.finished = False
|
||||
GLib.timeout_add(500, self.tick)
|
||||
self.tick()
|
||||
|
||||
def add_lines(self, lines):
|
||||
buf = self.text.get_buffer()
|
||||
for line in lines:
|
||||
m = STEP.match(line)
|
||||
if m:
|
||||
n, total, what = int(m[1]), int(m[2]), m[3]
|
||||
self.bar.set_fraction((n - 1) / total)
|
||||
self.detail.set_label(f"Step {n} of {total}: {what}")
|
||||
buf.insert(buf.get_end_iter(), line + "\n")
|
||||
# Keep the view at the newest line.
|
||||
end = buf.create_mark(None, buf.get_end_iter(), False)
|
||||
self.text.scroll_mark_onscreen(end)
|
||||
buf.delete_mark(end)
|
||||
|
||||
def tick(self):
|
||||
try:
|
||||
with open(LOG, "rb") as f:
|
||||
f.seek(self.offset)
|
||||
data = f.read()
|
||||
self.offset += len(data)
|
||||
except FileNotFoundError:
|
||||
data = b""
|
||||
if data:
|
||||
text = self.partial + ANSI.sub("", data.decode("utf-8", "replace")).replace("\r", "\n")
|
||||
*lines, self.partial = text.split("\n")
|
||||
self.add_lines(lines)
|
||||
|
||||
state = unit_state()
|
||||
if state == "activating":
|
||||
if self.bar.get_fraction() == 0:
|
||||
self.bar.pulse()
|
||||
return True
|
||||
if self.partial:
|
||||
self.add_lines([self.partial])
|
||||
self.partial = ""
|
||||
self.note.set_visible(False)
|
||||
if state == "active":
|
||||
self.status.set_label("Done")
|
||||
self.detail.set_label(DONE_TEXT)
|
||||
self.bar.set_fraction(1)
|
||||
else:
|
||||
self.status.set_label("The install stopped")
|
||||
self.detail.set_label("The log above says why. Play Frametop again to try again.")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
app = Adw.Application(application_id="io.github.deejanuz.FrametopInstall")
|
||||
app.connect("activate", lambda a: Window(a).present())
|
||||
app.run([])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+114
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env bash
|
||||
# FrameDrop proof of concept, step 1: run as a Steam "Devkit Game" (what FrameDrop installs a
|
||||
# Linux zip as) and record what an installer started from Steam could use: host or Steam Linux
|
||||
# Runtime container, podman, the user's systemd, git, the network, GTK, and a window on screen.
|
||||
# Register and start it: framedrop/devkit.sh add FrametopProbe framedrop/probe "./probe.sh native"
|
||||
# framedrop/devkit.sh run FrametopProbe
|
||||
# Usage: probe.sh LABEL
|
||||
# Writes ~/.cache/frametop-framedrop/probe-LABEL.log, replacing the previous one.
|
||||
label=${1:-unlabeled}
|
||||
out=$HOME/.cache/frametop-framedrop
|
||||
mkdir -p "$out"
|
||||
log=$out/probe-$label.log
|
||||
exec >"$log" 2>&1
|
||||
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
section() { printf '\n== %s\n' "$1"; }
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
check() { # check NAME CMD...: one line, ok or failed with the exit status
|
||||
local name=$1; shift
|
||||
if out_=$(timeout 20 "$@" 2>&1); then echo "ok $name: $(echo "$out_" | head -1)"
|
||||
else echo "FAILED $name (exit $?): $(echo "$out_" | head -2 | tr '\n' ' ')"; fi
|
||||
}
|
||||
|
||||
section "when, who, where"
|
||||
date -Is
|
||||
echo "label=$label uid=$(id -u) user=$(id -un) pwd=$PWD here=$here"
|
||||
echo "args: $*"
|
||||
grep -E '^(ID|VARIANT_ID|VERSION_ID|BUILD_ID|PRETTY_NAME)=' /etc/os-release
|
||||
|
||||
section "container?"
|
||||
for p in /run/pressure-vessel /.flatpak-info /run/host/os-release; do
|
||||
[ -e "$p" ] && echo "present: $p" || echo "absent: $p"
|
||||
done
|
||||
echo "container=${container-} PRESSURE_VESSEL_RUNTIME=${PRESSURE_VESSEL_RUNTIME-}"
|
||||
[ -e /run/host/os-release ] && grep -E '^(ID|VARIANT_ID)=' /run/host/os-release
|
||||
|
||||
section "environment"
|
||||
env | grep -E '^(PATH|HOME|XDG_[A-Z_]+|DISPLAY|WAYLAND_DISPLAY|DBUS_SESSION_BUS_ADDRESS|SteamAppId|SteamGameId|STEAM_COMPAT_[A-Z_]+|LD_LIBRARY_PATH|LD_PRELOAD|SDL_[A-Z_]+|GDK_BACKEND|ENABLE_[A-Z_]+|PRESSURE_VESSEL_[A-Z_]+)=' | sort
|
||||
echo "process tree:"
|
||||
pid=$$
|
||||
for _ in 1 2 3 4 5 6 7 8; do
|
||||
[ "$pid" -le 1 ] 2>/dev/null && break
|
||||
printf ' %s %s\n' "$pid" "$(tr '\0' ' ' </proc/"$pid"/cmdline 2>/dev/null | cut -c1-200)"
|
||||
pid=$(awk '/^PPid:/{print $2}' /proc/"$pid"/status 2>/dev/null)
|
||||
done
|
||||
|
||||
# Steam adds its overlay (LD_PRELOAD) and runtime libraries to every child. An installer has
|
||||
# to drop them before running host tools, as the checks below do.
|
||||
unset LD_PRELOAD
|
||||
LD_LIBRARY_PATH=$(printf %s "${LD_LIBRARY_PATH-}" | tr ':' '\n' | grep -v -e '/Steam/' -e '^$' -e 'x86_64' -e 'i386' | paste -sd:)
|
||||
[ -n "$LD_LIBRARY_PATH" ] && export LD_LIBRARY_PATH || unset LD_LIBRARY_PATH
|
||||
PATH=$(printf %s "$PATH" | tr ':' '\n' | grep -v '/Steam/' | paste -sd:)
|
||||
echo "cleaned: PATH=$PATH LD_LIBRARY_PATH=${LD_LIBRARY_PATH-}"
|
||||
|
||||
section "tools"
|
||||
for t in bash git curl python3 podman distrobox systemctl systemd-run busctl gdbus flatpak-spawn \
|
||||
steam-runtime-launch-client konsole zenity kdialog; do
|
||||
printf '%-28s %s\n' "$t" "$(command -v "$t" || echo -)"
|
||||
done
|
||||
|
||||
section "what an installer needs"
|
||||
check "home writable" sh -c 'f=$HOME/.cache/frametop-framedrop/.w && : >"$f" && rm "$f" && echo yes'
|
||||
check "~/frametop visible" sh -c 'ls -d "$HOME/frametop" && git -C "$HOME/frametop" log -1 --format=%h'
|
||||
have git && check "git ls-remote github" git ls-remote --heads https://github.com/DeeJanuz/frametop.git experimental
|
||||
have curl && check "curl get.sh" sh -c 'curl -fsSL https://deejanuz.github.io/frametop/get.sh | head -1'
|
||||
have podman && check "podman ps" podman ps --format '{{.Names}}'
|
||||
have distrobox && check "distrobox list" distrobox list
|
||||
have systemctl && check "systemctl --user" systemctl --user is-system-running
|
||||
have systemctl && check "user units visible" systemctl --user is-enabled frametop-input-relay.service
|
||||
have python3 && check "python3 gi Gtk 4" python3 -c 'import gi; gi.require_version("Gtk","4.0"); gi.require_version("Adw","1"); from gi.repository import Gtk, Adw; print(Gtk.get_major_version(), Gtk.get_minor_version())'
|
||||
|
||||
section "escape to the host (needed if this is a container)"
|
||||
# A transient user unit runs in the host's user manager, outside any container.
|
||||
if have systemd-run; then
|
||||
rm -f "$out/escape-$label"
|
||||
check "systemd-run --user" systemd-run --user --wait --collect --quiet -- \
|
||||
sh -c "grep -E '^(ID|VARIANT_ID)=' /etc/os-release > '$out/escape-$label'; command -v podman git >> '$out/escape-$label'"
|
||||
[ -s "$out/escape-$label" ] && sed 's/^/ host says: /' "$out/escape-$label"
|
||||
fi
|
||||
if have busctl; then
|
||||
check "busctl --user (systemd1)" busctl --user get-property org.freedesktop.systemd1 /org/freedesktop/systemd1 org.freedesktop.systemd1.Manager Version
|
||||
fi
|
||||
|
||||
section "window"
|
||||
# A plain GTK 4 window for 20 s. Watch for it on the Frame; "window: mapped" means GTK showed it.
|
||||
if have python3; then
|
||||
timeout 40 python3 - <<'EOF'
|
||||
import sys
|
||||
try:
|
||||
import gi
|
||||
gi.require_version("Gtk", "4.0")
|
||||
from gi.repository import Gtk, GLib
|
||||
except Exception as e:
|
||||
print("window: no GTK 4:", e); sys.exit(0)
|
||||
app = Gtk.Application(application_id="io.github.deejanuz.FrametopProbe")
|
||||
def on_activate(app):
|
||||
w = Gtk.ApplicationWindow(application=app, title="Frametop installer probe")
|
||||
w.set_default_size(640, 240)
|
||||
w.set_child(Gtk.Label(label="Frametop installer probe\n\nIf you can read this, a FrameDrop install can show progress.\nThis window closes in 20 seconds."))
|
||||
w.connect("map", lambda *_: print("window: mapped", flush=True))
|
||||
w.present()
|
||||
GLib.timeout_add_seconds(20, app.quit)
|
||||
app.connect("activate", on_activate)
|
||||
print("window: display", Gtk.Widget.get_display(Gtk.Label()) if hasattr(Gtk.Widget, "get_display") else "?", flush=True)
|
||||
app.run([])
|
||||
print("window: closed")
|
||||
EOF
|
||||
echo "window exit: $?"
|
||||
else
|
||||
echo "window: no python3"
|
||||
fi
|
||||
|
||||
section "done"
|
||||
date -Is
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
title: Install the Frametop Hand Recorder
|
||||
---
|
||||
|
||||
# Install the Frametop Hand Recorder
|
||||
|
||||
The Hand Recorder records your hands with the Steam Frame's tracking cameras for Frametop's open hand dataset ([DeeJanuz/frametop-hands](https://huggingface.co/datasets/DeeJanuz/frametop-hands) on Hugging Face). These are the Konsole commands to install it.
|
||||
|
||||
> **Known issue (October 5, 2026):** the recorder currently works only on headsets with the Arcturus color passthrough module attached. Without the module, the camera check stops with "Not all of the headset's tracking cameras are running (ft-camd publishes only 2 of 4 mono cameras ...)", and restarting SteamVR or the headset doesn't help. A fix for headsets without the module will be published within 24 hours (by October 6). Once it's out, run the commands under [Update](#update).
|
||||
|
||||
Join the [Frametop Discord](https://discord.gg/W3X9f7z3Bc) for questions and help with recording.
|
||||
|
||||
For now the recorder runs inside Frametop's desktop, so these steps install Frametop first. A standalone recorder that runs from the SteamVR dashboard without Frametop is planned.
|
||||
|
||||
## Before you start
|
||||
|
||||
- You must be 18 or older, and for now you can't take part if you live in Illinois, Texas or Washington (USA). The [consent text](https://github.com/DeeJanuz/frametop/blob/main/hands/rec/CONSENT.md) explains what's recorded and what you agree to. The recorder shows it again before your first session.
|
||||
- You need a Steam Frame on the stable SteamOS release (not the beta), an internet connection, and a keyboard (Bluetooth, or the on-screen one).
|
||||
- You need a `sudo` password. If you've never set one, run `passwd` in Konsole first.
|
||||
- Recordings are several gigabytes per round, and uploading one needs about the same again free while it runs. `df -h ~` shows your free space.
|
||||
|
||||
## Open Konsole
|
||||
|
||||
1. In the launcher, choose Launch a program → Desktop.
|
||||
2. In the application menu, open System → Konsole.
|
||||
|
||||
## 1. Install Frametop
|
||||
|
||||
```
|
||||
curl -fsSL https://deejanuz.github.io/frametop/get.sh | bash -s -- --stable
|
||||
```
|
||||
|
||||
This clones Frametop into `~/frametop` and runs its installer. The first run downloads 1–2 GB. The installer asks a few questions (gaze mode, the eye tracker, the Bluetooth fixes); the defaults are fine. At the end SteamVR restarts, which closes Konsole. If Frametop is already installed, this updates it.
|
||||
|
||||
## 2. Install the Hand Recorder
|
||||
|
||||
After SteamVR restarts, choose Launch a program → Desktop again, open Konsole, and run:
|
||||
|
||||
```
|
||||
~/frametop/hands/rec/install.sh
|
||||
```
|
||||
|
||||
This builds the camera broker, the hand tracker and the headset panel (the first build takes a few minutes), asks for your `sudo` password once to let the camera broker read the cameras, and adds Frametop Hand Recorder to the application menu.
|
||||
|
||||
## 3. Record
|
||||
|
||||
Open Frametop Hand Recorder from the desktop's application menu. It walks you through the consent text, a short checklist, the recording, a review of what you recorded, and the upload to Hugging Face. Nothing leaves the headset until you press Upload.
|
||||
|
||||
## Update
|
||||
|
||||
```
|
||||
curl -fsSL https://deejanuz.github.io/frametop/get.sh | bash -s -- --stable
|
||||
~/frametop/hands/rec/install.sh
|
||||
```
|
||||
|
||||
Run the second command after SteamVR has restarted, as in the install.
|
||||
|
||||
## Uninstall
|
||||
|
||||
```
|
||||
~/frametop/hands/rec/install.sh uninstall
|
||||
```
|
||||
|
||||
This removes the menu entry. Your recordings stay in `~/.local/share/frametop/hands/contrib`; delete that folder to remove them. To remove Frametop as well, follow [Uninstall](https://github.com/DeeJanuz/frametop#uninstall) in the README.
|
||||
|
||||
## Help
|
||||
|
||||
Ask in the [Frametop Discord](https://discord.gg/W3X9f7z3Bc), the [Frametop issues](https://github.com/DeeJanuz/frametop/issues), or the dataset's [discussion page](https://huggingface.co/datasets/DeeJanuz/frametop-hands/discussions). All three are public.
|
||||
+5
-42
@@ -56,19 +56,13 @@ ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||
STAMP = re.compile(r"^\w{3} \w{3} \d{2} \d{4} (\d{2}:\d{2}:\d{2})\.\d+ (\w+): ?(.*)$")
|
||||
# Lines worth reading; anything else is skipped before the regexes (the log grows by MBs a day).
|
||||
KEYS = ("FPGA", "VCINT", "Created", "TrackingCameraInit", "Closing tracking camera", "Streaming",
|
||||
"systemd suspend", "systemd resume", "XRService logging to", "Exiting XRService", "ISP ")
|
||||
# XRService's numbering of its tracking cameras (the TrackingCameraInit index): ft-hands names
|
||||
# them this way too (track/main.cpp, cameras_from_xrservice_log).
|
||||
NAMES = ("slam_left", "slam_right", "upper_left", "upper_right")
|
||||
"systemd suspend", "systemd resume", "XRService logging to", "Exiting XRService")
|
||||
RE_PASSTHRU = re.compile(r"Passthrough connected but FPGA is (\S+) - loading VCINT")
|
||||
RE_INTERLEAVE = re.compile(r"Upper cameras FPGA interleaving support: (\d)")
|
||||
RE_TASKS = re.compile(r"Created (\d+) tasks \((\d+) tracking, (\d+) passthrough\)")
|
||||
RE_INIT = re.compile(r"TrackingCameraInit: index: (\d+)\. video device: /dev/video(\d+)")
|
||||
RE_STREAM = re.compile(r"Streaming resumed \(FPGA: (\S+), VC interleaving: (\w+)\)")
|
||||
RE_STATE = re.compile(r"FPGA state check: (\S+)")
|
||||
# Without the colour module XRService runs the side cameras through the ISP, as NV12 on other
|
||||
# capture pipes (vfe0 and vfe1), and the upper pair on vfe3 and vfe4.
|
||||
RE_ISP = re.compile(r"ISP (enabled|disabled) for tracking cameras")
|
||||
|
||||
|
||||
class LogState:
|
||||
@@ -91,7 +85,7 @@ class LogState:
|
||||
def _new_episode(self, t):
|
||||
self.closed = False
|
||||
self.episode = {"start": t, "fpga_before": "", "vcint": "", "interleave": None, "tasks": None,
|
||||
"inits": {}, "stream": "", "isp": None, "failure": "", "evidence": []}
|
||||
"inits": {}, "stream": "", "failure": "", "evidence": []}
|
||||
if self.closed_at:
|
||||
self.episode["evidence"].append(self.closed_at)
|
||||
|
||||
@@ -152,12 +146,6 @@ class LogState:
|
||||
ep["interleave"] = int(m.group(1))
|
||||
ep["evidence"].append(short)
|
||||
return
|
||||
m = RE_ISP.search(text)
|
||||
if m:
|
||||
ep = self._ep(t)
|
||||
ep["isp"] = m.group(1) == "enabled"
|
||||
ep["evidence"].append(short)
|
||||
return
|
||||
m = RE_TASKS.search(text)
|
||||
if m:
|
||||
ep = self._ep(t)
|
||||
@@ -196,12 +184,6 @@ class LogState:
|
||||
got = tuple(self.nodes[i] for i in (2, 3) if i in self.nodes)
|
||||
return got if len(got) == 2 else UPPER_NODES
|
||||
|
||||
def camera_map(self):
|
||||
"""{calibration name: /dev/videoN's N} from the latest camera start's TrackingCameraInit
|
||||
lines (the whole log's when that start has none yet)."""
|
||||
inits = (self.episode or {}).get("inits") or self.nodes
|
||||
return {NAMES[i]: node for i, node in sorted(inits.items()) if 0 <= i < len(NAMES)}
|
||||
|
||||
def tracking_nodes(self):
|
||||
got = tuple(self.nodes[i] for i in range(TRACKING) if i in self.nodes)
|
||||
return got if len(got) == TRACKING else SIDE_NODES + UPPER_NODES
|
||||
@@ -371,9 +353,7 @@ def check(log=None, proc=True, ring=True, ring_path=None):
|
||||
status, reason = "unknown", "no XRService log in %s" % LOG_DIR
|
||||
out = {"log": path, "xrservice": None, "ring": None,
|
||||
"episode": state.snapshot()["episode"] if state else {},
|
||||
"failure": state.snapshot()["failure"] if state else "",
|
||||
"map": {name: {"node": n, "pipe": pipe_name(n)} for name, n in state.camera_map().items()} if state else {},
|
||||
"ring_missing": []}
|
||||
"failure": state.snapshot()["failure"] if state else ""}
|
||||
|
||||
if proc:
|
||||
if pid is None:
|
||||
@@ -409,30 +389,13 @@ def check(log=None, proc=True, ring=True, ring_path=None):
|
||||
evidence.append("ft-camd (pid %d, %s): %d mono cameras: %s" % (
|
||||
r["writer_pid"], "running" if r["alive"] else "stale ring", len(r["mono"]), names))
|
||||
if r["alive"] and len(r["mono"]) < TRACKING and status == "ok":
|
||||
have = {c["node"] for c in r["mono"]}
|
||||
want = state.tracking_nodes() if state else SIDE_NODES + UPPER_NODES
|
||||
out["ring_missing"] = [n for n in want if n not in have]
|
||||
status, reason = "degraded", ("ft-camd publishes only %d of %d mono cameras, missing %s" % (
|
||||
len(r["mono"]), TRACKING, " ".join("video%d" % n for n in out["ring_missing"]) or "?"))
|
||||
status, reason = "degraded", ("ft-camd publishes only %d of %d mono cameras (it started while "
|
||||
"they were missing: restart it)" % (len(r["mono"]), TRACKING))
|
||||
out.update(status=status, reason=reason, evidence=evidence,
|
||||
summary="ok" if status == "ok" else "%s: %s" % (status, reason))
|
||||
return out
|
||||
|
||||
|
||||
def pipe_name(node):
|
||||
"""The capture pipe behind /dev/videoN (msm_vfe3_video0 and so on), or ""."""
|
||||
try:
|
||||
with open("/sys/class/video4linux/video%d/name" % node) as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def is_ring_short(result):
|
||||
"""XRService runs all the tracking cameras, but ft-camd doesn't publish them all."""
|
||||
return bool(result) and result.get("status") == "degraded" and bool(result.get("ring_missing"))
|
||||
|
||||
|
||||
def is_vcint_failure(result):
|
||||
return bool(result) and result.get("status") == "degraded" and result.get("reason") == VCINT_REASON
|
||||
|
||||
|
||||
+2
-25
@@ -379,15 +379,9 @@ static bool resolve_block(cam_t *c)
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
* Through the ISP (no colour module), the near-black exposures come out all zeros,
|
||||
* the same every time, so their dequeues change no buffer and get no votes. Such
|
||||
* an index is left unmapped (slot -1): on_frame counts its frames as dark without
|
||||
* reading them. Most of a camera's indices silent means it isn't streaming yet.
|
||||
*/
|
||||
static bool resolve_each(cam_t *c)
|
||||
{
|
||||
int depth = c->maxindex + 1, silent = 0;
|
||||
int depth = c->maxindex + 1;
|
||||
bool taken[MAX_SLOTS] = { false };
|
||||
|
||||
for (int i = 0; i < depth; i++) {
|
||||
@@ -402,12 +396,6 @@ static bool resolve_each(cam_t *c)
|
||||
}
|
||||
}
|
||||
|
||||
if (c->nobs[i] >= 3 && v1 <= 0.2 * c->nobs[i]) {
|
||||
c->slot_of[i] = -1;
|
||||
silent++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c->nobs[i] < 3 || best < 0 || taken[best] || v1 < 0.8 * c->nobs[i] || v2 > 0.3 * c->nobs[i])
|
||||
return false;
|
||||
|
||||
@@ -415,14 +403,9 @@ static bool resolve_each(cam_t *c)
|
||||
c->slot_of[i] = best;
|
||||
}
|
||||
|
||||
if (silent * 2 > depth)
|
||||
return false;
|
||||
|
||||
printf("%s: queue depth %d, buffers mapped one by one:", c->slug, depth);
|
||||
for (int i = 0; i < depth; i++)
|
||||
c->slot_of[i] < 0 ? printf(" -") : printf(" %d", c->slot_of[i]);
|
||||
if (silent)
|
||||
printf(" (- : %d indices whose frames are all zeros, skipped as dark)", silent);
|
||||
printf(" %d", c->slot_of[i]);
|
||||
printf("\n");
|
||||
return true;
|
||||
}
|
||||
@@ -694,12 +677,6 @@ static void on_frame(cam_t *c, int64_t index, uint32_t seq, uint64_t ts, uint64_
|
||||
|
||||
int slot = c->slot_of[index];
|
||||
|
||||
if (slot < 0) { /* an index whose frames are all zeros (resolve_each): a dark one */
|
||||
c->dark++;
|
||||
c->rc->dropped++;
|
||||
return;
|
||||
}
|
||||
|
||||
/* color runs at 60 fps: skip frames early enough that the asked rate holds, before any sync */
|
||||
if (c->color && color_fps > 0 && evtime - c->last_pub_ns < (uint64_t)(1e9 / color_fps) - 3000000) {
|
||||
c->paced++;
|
||||
|
||||
+7
-24
@@ -544,26 +544,22 @@ static void probe_cameras(xr_state_t *st)
|
||||
/*
|
||||
* qcom-camss can report bytesperline as the visible width while the VFE
|
||||
* writes a larger aligned pitch. sizeimage is right, so derive the pitch.
|
||||
* For NV12, plane 0 normally holds the chroma rows after the luma (the side
|
||||
* cameras through the ISP: 1056 wide, 1152 bytes a row); if it's too small for
|
||||
* that, it holds the luma alone.
|
||||
*/
|
||||
unsigned xr_camera_stride(const xr_camera_t *c)
|
||||
{
|
||||
if (!c->height || !c->planesize[0])
|
||||
return c->bytesperline ? c->bytesperline : c->width;
|
||||
|
||||
bool yuv = c->pixfmt == V4L2_PIX_FMT_NV12 || c->pixfmt == V4L2_PIX_FMT_NV21;
|
||||
unsigned s = (unsigned)((double)c->planesize[0] / ((double)c->height * (yuv ? 1.5 : 1.0)));
|
||||
double bpp = 1.0;
|
||||
|
||||
if (c->pixfmt == V4L2_PIX_FMT_NV12 || c->pixfmt == V4L2_PIX_FMT_NV21)
|
||||
bpp = 1.5;
|
||||
|
||||
unsigned s = (unsigned)((double)c->planesize[0] / ((double)c->height * bpp));
|
||||
|
||||
if (s >= c->width && s <= c->width * 4)
|
||||
return s;
|
||||
|
||||
s = (unsigned)(c->planesize[0] / c->height);
|
||||
|
||||
if (yuv && s >= c->width && s <= c->width * 4)
|
||||
return s;
|
||||
|
||||
return c->bytesperline ? c->bytesperline : c->width;
|
||||
}
|
||||
|
||||
@@ -595,20 +591,7 @@ void xr_camera_layout(const xr_camera_t *c, xr_layout_t *l)
|
||||
l->pitch = xr_camera_stride(c);
|
||||
l->width = c->width < l->pitch ? c->width : l->pitch;
|
||||
|
||||
/*
|
||||
* Without the colour module, XRService runs the side cameras through the
|
||||
* ISP ("ISP enabled for tracking cameras (main VFE available)" in its log),
|
||||
* and they come out NV12. They're mono sensors, so the luma is the image.
|
||||
*/
|
||||
bool yuv = c->pixfmt == V4L2_PIX_FMT_NV12 || c->pixfmt == V4L2_PIX_FMT_NV21;
|
||||
|
||||
if (yuv && c->role && !strcmp(c->role, "tracking")) {
|
||||
l->fmt = XR_FMT_GREY8;
|
||||
l->rows = c->height;
|
||||
return;
|
||||
}
|
||||
|
||||
if (yuv) {
|
||||
if (c->pixfmt == V4L2_PIX_FMT_NV12 || c->pixfmt == V4L2_PIX_FMT_NV21) {
|
||||
l->fmt = XR_FMT_NV12;
|
||||
l->rows = c->height + c->height / 2;
|
||||
} else {
|
||||
|
||||
@@ -457,12 +457,11 @@ class Backend(QObject):
|
||||
"""Start stays off: the check found the cameras degraded (unless --ignore-cameras)."""
|
||||
return not self._ignore_cameras and self._camera.get("status") == "degraded"
|
||||
|
||||
def _check_now(self, repair=False):
|
||||
"""repair (off this thread only: it may restart ft-camd, up to 15 s): see session.camera_check."""
|
||||
def _check_now(self):
|
||||
mod = self._runner()
|
||||
if not mod or self._session_options.get("dry_run"):
|
||||
return {"status": "unknown", "summary": "not checked (dry run)", "reason": "dry run", "evidence": []}
|
||||
return mod.camera_check(repair=repair)
|
||||
return mod.camera_check()
|
||||
|
||||
@Slot()
|
||||
def checkCameras(self):
|
||||
@@ -471,7 +470,7 @@ class Backend(QObject):
|
||||
return
|
||||
self._camera_busy = True
|
||||
self.cameraChanged.emit()
|
||||
self._thread(lambda: self._cameraArrived.emit(self._check_now(repair=True)))
|
||||
self._thread(lambda: self._cameraArrived.emit(self._check_now()))
|
||||
|
||||
def _on_camera(self, result):
|
||||
self._camera = result
|
||||
@@ -500,7 +499,7 @@ class Backend(QObject):
|
||||
end = time.monotonic() + RECHECK_FOR_S
|
||||
time.sleep(RECHECK_S * 2)
|
||||
while time.monotonic() < end:
|
||||
result = self._check_now(repair=True)
|
||||
result = self._check_now()
|
||||
# Done once a new XRService (a new log) has opened its cameras, ok or not.
|
||||
if result.get("status") in ("ok", "degraded") and result.get("log") != before:
|
||||
break
|
||||
|
||||
+3
-33
@@ -1153,33 +1153,11 @@ def _steamvr_version():
|
||||
return ""
|
||||
|
||||
|
||||
_camd_restarted = set() # ft-camd pids camera_check(repair=True) has restarted: once each
|
||||
|
||||
|
||||
def _unit_pid(unit):
|
||||
r = subprocess.run(host_command("systemctl", "--user", "show", "-p", "MainPID", "--value", unit),
|
||||
capture_output=True, text=True, timeout=30)
|
||||
try:
|
||||
return int(r.stdout.strip() or 0)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def camera_check(repair=False):
|
||||
def camera_check():
|
||||
"""camcheck.check() (the XRService log, its open cameras where readable, ft-camd's ring);
|
||||
never raises: a failure is "unknown". repair (the window, never during a session): when
|
||||
XRService runs every tracking camera but the recorder's own ft-camd doesn't publish them all
|
||||
(it started while some were missing), restart it, once per ft-camd, and check again."""
|
||||
never raises: a failure is "unknown"."""
|
||||
try:
|
||||
r = camcheck.check()
|
||||
pid = (r.get("ring") or {}).get("writer_pid")
|
||||
if repair and camcheck.is_ring_short(r) and pid not in _camd_restarted and _unit_pid(CAMD_UNIT) == pid:
|
||||
_camd_restarted.add(pid)
|
||||
stop_unit(CAMD_UNIT)
|
||||
start_camd()
|
||||
r = camcheck.check()
|
||||
r["evidence"].append("restarted ft-camd (pid %d) because it published only some of the cameras" % pid)
|
||||
return r
|
||||
return camcheck.check()
|
||||
except Exception as e:
|
||||
return {"status": "unknown", "summary": "unknown: the camera check failed (%s)" % e,
|
||||
"reason": str(e), "evidence": []}
|
||||
@@ -1191,10 +1169,6 @@ def camera_text(result):
|
||||
return ""
|
||||
if camcheck.is_vcint_failure(result):
|
||||
return camcheck.USER_TEXT
|
||||
if camcheck.is_ring_short(result):
|
||||
return ("The headset's tracking cameras are all running, but the recorder can't read some of them (%s). "
|
||||
"Close the Hand Recorder, run ~/frametop/hands/rec/install.sh again, and open it again. If that "
|
||||
"doesn't help, ask in the Frametop Discord." % result.get("reason", ""))
|
||||
return ("Not all of the headset's tracking cameras are running (%s). Restart SteamVR, or restart the "
|
||||
"headset if that doesn't fix it." % result.get("reason", ""))
|
||||
|
||||
@@ -1477,10 +1451,6 @@ class Session:
|
||||
cam = self._status.get("camera")
|
||||
if cam:
|
||||
self._session_json["camera"] = {"status": cam.get("status"), "reason": cam.get("reason", "")}
|
||||
# which device each calibrated camera was (XRService's numbering) and whether XRService
|
||||
# ran the side cameras through the ISP (no colour module): to check the names later
|
||||
self._session_json["device"]["camera_map"] = cam.get("map") or {}
|
||||
self._session_json["device"]["isp"] = (cam.get("episode") or {}).get("isp")
|
||||
if self.dry_run:
|
||||
self._session_json["dry_run"] = True
|
||||
if self.speed != 1:
|
||||
|
||||
@@ -66,22 +66,6 @@ RESUME_OK = [L("12:30:00", "[SystemdInhibitor] Received systemd resume notificat
|
||||
for i, n in enumerate((9, 13, 6, 7))] + \
|
||||
[L("12:30:02", "[DeckardCaptureSource] Streaming resumed (FPGA: VCINT, VC interleaving: enabled)")]
|
||||
EXIT = [L("13:00:00", "XRService - main thread exiting"), L("13:00:00", "Exiting XRService")]
|
||||
# The colour module unplugged while SteamVR runs (2026-10-05 12:42): XRService reopens the
|
||||
# cameras with the side pair through the ISP on vfe0 and vfe1 (NV12); VCINT stays loaded, so the
|
||||
# upper pair stays on vfe2.
|
||||
UNPLUG = [L("12:42:25", "Received passthrough camera connection event (connected=0)"),
|
||||
L("12:42:25", "[DeckardCaptureSource] Closing tracking camera interfaces camerasToUse: 1111"),
|
||||
L("12:42:26", "FPGA state check: VCINT (register value: 0x00021211)"),
|
||||
L("12:42:26", "Upper cameras FPGA interleaving support: 1 (Driver features available = 1 | VCINT loaded = 1)"),
|
||||
L("12:42:26", "[buildMediaCtlSetupTasks] ISP enabled for tracking cameras (main VFE available)"),
|
||||
L("12:42:26", "[buildMediaCtlSetupTasks] Created 4 tasks (4 tracking, 0 passthrough)")] + \
|
||||
[L("12:42:26", "TrackingCameraInit: index: %d. video device: /dev/video%d. v4l subdevice: x" % (i, n))
|
||||
for i, n in enumerate((0, 3, 6, 7))]
|
||||
# Started without the module (FrameEyeCameraFeed's layout): the upper pair on vfe3 and vfe4.
|
||||
NO_MODULE = [L("10:00:02", "[buildMediaCtlSetupTasks] ISP enabled for tracking cameras (main VFE available)"),
|
||||
L("10:00:02", "[buildMediaCtlSetupTasks] Created 4 tasks (4 tracking, 0 passthrough)")] + \
|
||||
[L("10:00:03", "TrackingCameraInit: index: %d. video device: /dev/video%d. v4l subdevice: x" % (i, n))
|
||||
for i, n in enumerate((0, 3, 9, 13))]
|
||||
|
||||
|
||||
def state(lines):
|
||||
@@ -144,23 +128,6 @@ class SyntheticLogs(unittest.TestCase):
|
||||
self.assertEqual(status, "degraded")
|
||||
self.assertEqual(reason, "only 2 of 4 tracking cameras running")
|
||||
|
||||
def test_camera_map_with_module(self):
|
||||
st = state(START + GOOD_OPEN)
|
||||
self.assertEqual(st.camera_map(), {"slam_left": 9, "slam_right": 13, "upper_left": 6, "upper_right": 7})
|
||||
|
||||
def test_module_unplugged(self):
|
||||
st = state(START + GOOD_OPEN + UNPLUG)
|
||||
self.assertEqual(st.verdict()[0], "ok")
|
||||
self.assertIs(st.episode["isp"], True)
|
||||
self.assertEqual(st.camera_map(), {"slam_left": 0, "slam_right": 3, "upper_left": 6, "upper_right": 7})
|
||||
self.assertEqual(st.tracking_nodes(), (0, 3, 6, 7))
|
||||
|
||||
def test_started_without_module(self):
|
||||
st = state(START + NO_MODULE)
|
||||
self.assertEqual(st.verdict()[0], "ok")
|
||||
self.assertEqual(st.camera_map(), {"slam_left": 0, "slam_right": 3, "upper_left": 9, "upper_right": 13})
|
||||
self.assertEqual(st.upper_nodes(), (9, 13))
|
||||
|
||||
def test_exited_and_empty(self):
|
||||
self.assertEqual(state(START + GOOD_OPEN + EXIT).verdict()[0], "unknown")
|
||||
self.assertEqual(state([]).verdict()[0], "unknown")
|
||||
@@ -256,9 +223,9 @@ class RealLog(unittest.TestCase):
|
||||
self.assertEqual(out.getvalue().split("\n")[0], "ok")
|
||||
|
||||
|
||||
def make_ring(path, mono_names, alive=True, nodes=None):
|
||||
def make_ring(path, mono_names, alive=True):
|
||||
"""A ring header as ft-camd writes it (camd/fhring.h), no frames."""
|
||||
cams = [(b"og01a1b", n.encode(), nodes[i] if nodes else 9 + i) for i, n in enumerate(mono_names)]
|
||||
cams = [(b"og01a1b", n.encode(), 9 + i) for i, n in enumerate(mono_names)]
|
||||
hb = time.clock_gettime_ns(time.CLOCK_MONOTONIC) if alive else 1
|
||||
data = bytearray(camcheck.RING_HDR.pack(b"FHRING01", 1, 0, len(cams), 0, 0, 4242, 0))
|
||||
struct.pack_into("<Q", data, 40, hb)
|
||||
@@ -285,19 +252,6 @@ class Ring(unittest.TestCase):
|
||||
self.assertEqual(r["status"], "degraded")
|
||||
self.assertIn("ft-camd publishes only 2 of 4", r["reason"])
|
||||
|
||||
def test_ring_missing_the_side_cameras(self):
|
||||
# an ft-camd from before NV12 support, without the colour module: only the upper pair
|
||||
with open(self.log, "w") as f:
|
||||
f.write("\n".join(START + NO_MODULE) + "\n")
|
||||
make_ring(self.ring, ["og0ve10_5-003e_video9", "og0ve10_5-0060_video13"], nodes=[9, 13])
|
||||
r = camcheck.check(log=self.log, proc=False, ring_path=self.ring)
|
||||
self.assertEqual(r["status"], "degraded")
|
||||
self.assertEqual(r["ring_missing"], [0, 3])
|
||||
self.assertIn("missing video0 video3", r["reason"])
|
||||
self.assertTrue(camcheck.is_ring_short(r))
|
||||
self.assertFalse(camcheck.is_vcint_failure(r))
|
||||
self.assertEqual(r["map"]["slam_left"]["node"], 0)
|
||||
|
||||
def test_four_cameras_in_ring(self):
|
||||
make_ring(self.ring, ["a_video9", "b_video13", "c_video6", "d_video7", "a_video9_dk"])
|
||||
r = camcheck.check(log=self.log, proc=False, ring_path=self.ring)
|
||||
|
||||
@@ -34,28 +34,8 @@ from tools.show_set import index, read_set # noqa: E402
|
||||
from tools import calib # noqa: E402
|
||||
|
||||
|
||||
PIPES = {'msm_vfe3_video0': 'slam_left', 'msm_vfe4_video0': 'slam_right', # with the colour module
|
||||
PIPES = {'msm_vfe3_video0': 'slam_left', 'msm_vfe4_video0': 'slam_right', # as ft-hands maps them
|
||||
'msm_vfe2_video0': 'upper_left', 'msm_vfe2_video1': 'upper_right'}
|
||||
|
||||
|
||||
def ring_names():
|
||||
"""{/dev/videoN's N: calibration name} as ft-hands names them: by XRService's log
|
||||
(camcheck.py), else by capture pipe (PIPES)."""
|
||||
import camcheck
|
||||
try:
|
||||
by_log = {node: name for name, node in camcheck.read_log(camcheck.newest_log()).camera_map().items()}
|
||||
except (OSError, ValueError):
|
||||
by_log = {}
|
||||
if by_log:
|
||||
return by_log
|
||||
out = {}
|
||||
for path in os.listdir('/sys/class/video4linux'):
|
||||
if path.startswith('video'):
|
||||
with open('/sys/class/video4linux/%s/name' % path) as f:
|
||||
name = PIPES.get(f.read().strip())
|
||||
if name:
|
||||
out[int(path[5:])] = name
|
||||
return out
|
||||
PAIRS = {'side': ('slam_left', 'slam_right'), 'upper': ('upper_left', 'upper_right')}
|
||||
|
||||
|
||||
@@ -120,9 +100,8 @@ def live_pairs(count, names=PAIRS['side']):
|
||||
if not ring.alive():
|
||||
sys.exit('ft-camd isn\'t running (no heartbeat)')
|
||||
cams = {}
|
||||
names_of = ring_names()
|
||||
for c in ring.cams:
|
||||
name = names_of.get(c.node)
|
||||
name = PIPES.get(open('/sys/class/video4linux/video%d/name' % c.node).read().strip())
|
||||
if name and not c.name.endswith('-dark'):
|
||||
cams[name] = c
|
||||
for k in range(count):
|
||||
|
||||
+11
-64
@@ -63,34 +63,7 @@ namespace {
|
||||
|
||||
volatile std::sig_atomic_t g_stop = 0, g_record = 0;
|
||||
|
||||
// Which calibrated camera each video device carries. XRService numbers its tracking cameras
|
||||
// (index 0 to 3: slam_left, slam_right, upper_left, upper_right) and logs the device each one
|
||||
// opened ("TrackingCameraInit: index: 0. video device: /dev/video9"). The devices depend on
|
||||
// the colour module: with it, the side cameras are on vfe3 and vfe4 and the upper pair on
|
||||
// vfe2; without it, XRService runs the side cameras through the ISP on vfe0 and vfe1, and the
|
||||
// upper pair on vfe3 and vfe4. So the running XRService's log decides; when it can't be read,
|
||||
// the capture pipes as they are with the module. {} if the log has no cameras.
|
||||
std::map<int, std::string> cameras_from_xrservice_log() {
|
||||
static const char *const names[] = {"slam_left", "slam_right", "upper_left", "upper_right"};
|
||||
const char *home = std::getenv("HOME");
|
||||
std::ifstream in(std::string(home ? home : "") + "/.local/share/Steam/logs/xrservice.txt");
|
||||
const std::string key = "TrackingCameraInit: index: ";
|
||||
std::map<int, int> node_of; // index -> N of /dev/videoN, from the latest camera start
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
if (line.find("XRService logging to") != std::string::npos) node_of.clear();
|
||||
const auto at = line.find(key);
|
||||
int index = -1, node = -1;
|
||||
if (at != std::string::npos &&
|
||||
std::sscanf(line.c_str() + at + key.size(), "%d. video device: /dev/video%d", &index, &node) == 2 &&
|
||||
index >= 0 && index < 4)
|
||||
node_of[index] = node;
|
||||
}
|
||||
std::map<int, std::string> out;
|
||||
for (auto &[index, node] : node_of) out[node] = names[index];
|
||||
return out;
|
||||
}
|
||||
|
||||
// which calibrated camera each capture pipe carries (XRService's fixed routing)
|
||||
const char *camera_for_pipe(int node) {
|
||||
char path[64], name[64] = "";
|
||||
std::snprintf(path, sizeof path, "/sys/class/video4linux/video%d/name", node);
|
||||
@@ -329,7 +302,6 @@ int main(int argc, char **argv) {
|
||||
double decided_after_s = -1;
|
||||
if (sides_mode != "auto") truth = names_swapped, decided_by = sides_from, sides_state = "forced";
|
||||
std::string rec_dir;
|
||||
std::string cams_json; // which device each calibrated camera is (set below), for the sides files
|
||||
std::vector<std::pair<size_t, bool>> rec_names; // from which recorded set on, names_swapped was what
|
||||
// DIR/sides.json beside a recording's sets.bin: how its side cameras are named. A set's
|
||||
// names are right when its names_swapped equals swapped (null: not known when recorded).
|
||||
@@ -340,8 +312,7 @@ int main(int argc, char **argv) {
|
||||
write_file(rec_dir + "/sides.json",
|
||||
"{\"swapped\": " + json_bool(truth) + ", \"decided_by\": " + json_str(truth ? decided_by : "") +
|
||||
", \"names_swapped\": [" + runs + "]" +
|
||||
(decision_evidence.empty() ? "" : ", \"evidence\": " + decision_evidence) +
|
||||
(cams_json.empty() ? "" : ", \"cameras\": " + cams_json) + "}\n");
|
||||
(decision_evidence.empty() ? "" : ", \"evidence\": " + decision_evidence) + "}\n");
|
||||
};
|
||||
auto start_recording = [&](const std::string &dir, std::string &e) {
|
||||
rec = std::make_unique<Recorder>();
|
||||
@@ -369,42 +340,19 @@ int main(int argc, char **argv) {
|
||||
// (--with-color). Recorded names hold 15 characters, so "upper_right_dark" wouldn't fit.
|
||||
std::map<std::string, int> dark;
|
||||
std::map<std::string, Camera> used;
|
||||
// ft-camd's cameras by XRService's numbering, else by capture pipe (see camera_for_pipe);
|
||||
// ft-ringplay's (no device) by the name it gives
|
||||
const std::map<int, std::string> by_log = cameras_from_xrservice_log();
|
||||
const char *named_by = by_log.empty() ? "capture pipe" : "XRService's log";
|
||||
for (int i = 0; i < ring.cameras(); ++i) {
|
||||
const fh_ring_cam_t &rc = ring.camera(i);
|
||||
if (rc.flags & FH_CAM_COLOR) {
|
||||
const std::string name = "color_video" + std::to_string(rc.node);
|
||||
if (ring.camera(i).flags & FH_CAM_COLOR) {
|
||||
const std::string name = "color_video" + std::to_string(ring.camera(i).node);
|
||||
dark[name] = i, color[name] = i;
|
||||
continue;
|
||||
}
|
||||
const auto it = by_log.find(rc.node);
|
||||
const char *name = rc.node < 0 ? rc.name
|
||||
: !by_log.empty() ? (it == by_log.end() ? nullptr : it->second.c_str())
|
||||
: camera_for_pipe(rc.node);
|
||||
if (!name || !calib.count(name)) {
|
||||
if (!(rc.flags & FH_CAM_DARK)) std::fprintf(stderr, "video%d (%s): not one of the calibrated cameras, left out\n", rc.node, rc.name);
|
||||
continue;
|
||||
// ft-camd's cameras by capture pipe; ft-ringplay's (no device) by the name it gives
|
||||
const char *name = camera_for_pipe(ring.camera(i).node);
|
||||
if (!name && ring.camera(i).node < 0) name = ring.camera(i).name;
|
||||
if (!name || !calib.count(name)) continue;
|
||||
if (ring.camera(i).flags & FH_CAM_DARK) dark[std::string(name) + "_dk"] = i;
|
||||
else index[name] = i, used[name] = calib[name];
|
||||
}
|
||||
if (int(rc.width) != calib[name].width || int(rc.height) != calib[name].height) {
|
||||
std::fprintf(stderr, "video%d (%s) is %ux%u, but %s is calibrated at %dx%d: left out\n", rc.node, rc.name,
|
||||
rc.width, rc.height, name, calib[name].width, calib[name].height);
|
||||
continue;
|
||||
}
|
||||
if (rc.flags & FH_CAM_DARK) {
|
||||
dark[std::string(name) + "_dk"] = i;
|
||||
} else if (index.count(name)) {
|
||||
std::fprintf(stderr, "video%d (%s) would be %s too (video%d is): left out\n", rc.node, rc.name, name,
|
||||
ring.camera(index[name]).node);
|
||||
} else {
|
||||
index[name] = i, used[name] = calib[name];
|
||||
cams_json += std::string(cams_json.empty() ? "" : ", ") + json_str(name) + ": {\"node\": " +
|
||||
std::to_string(rc.node) + ", \"ring\": " + json_str(rc.name) + "}";
|
||||
}
|
||||
}
|
||||
cams_json = "{\"named_by\": " + json_str(named_by) + ", \"cameras\": {" + cams_json + "}}";
|
||||
// ft-camd tells the side cameras' buffers apart by XRService's allocation order, which
|
||||
// some XRService restarts reverse (see the top).
|
||||
const bool have_sides = index.count("slam_left") && index.count("slam_right");
|
||||
@@ -449,7 +397,7 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
const bool switching = automatic && !color.empty();
|
||||
Cams mode = switching || color.empty() ? Cams::Mono : fixed;
|
||||
std::printf("cameras (by %s):", named_by);
|
||||
std::printf("cameras:");
|
||||
for (auto &[name, i] : index) std::printf(" %s=video%d", name.c_str(), ring.camera(i).node);
|
||||
for (auto &[name, i] : color) std::printf(" %s", name.c_str());
|
||||
std::printf(" tracking with %s%s models: %s%s, %d threads on CPUs", cams_name(mode),
|
||||
@@ -498,7 +446,6 @@ int main(int argc, char **argv) {
|
||||
", \"decided_after_s\": " + std::to_string(decided_after_s) +
|
||||
", \"evidence\": " + (decision_evidence.empty() ? "null" : decision_evidence) +
|
||||
", \"checking\": " + (checking ? side_check.json() : "null") +
|
||||
", \"cameras\": " + (cams_json.empty() ? "null" : cams_json) +
|
||||
", \"updated_ns\": " + std::to_string(mono_ns()) + "}\n");
|
||||
};
|
||||
write_sides();
|
||||
|
||||
-241
@@ -1,241 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Uninstall Frametop from the Steam Frame. In a terminal on the headset (Konsole in the desktop,
|
||||
# or over SSH):
|
||||
#
|
||||
# curl -fsSL https://deejanuz.github.io/frametop/uninstall.sh | bash
|
||||
#
|
||||
# or ~/frametop/uninstall.sh. It doesn't use the rest of the repo, so it also works when
|
||||
# ~/frametop is gone or broken.
|
||||
#
|
||||
# It never stops what you're using now: the input relay carries the keyboard and mouse, and the
|
||||
# desktop runs from the repo. So it goes in two steps:
|
||||
# 1. Frametop stops starting. The launcher's Desktop entry goes back to the stock desktop, and
|
||||
# Frametop's services, SteamVR driver, menu entries, and system files (our eye tracker's
|
||||
# frame grabber and the Bluetooth fixes, with sudo) are removed. What runs now keeps running
|
||||
# until you restart the headset.
|
||||
# 2. After the restart, run it again. It deletes the code (~/frametop) and, if you want, your
|
||||
# settings and the build container.
|
||||
# When nothing of Frametop is running, one run does both.
|
||||
#
|
||||
# Options (piped, they go after "bash -s --"):
|
||||
# --dir DIR where the repo is, if not ~/frametop
|
||||
# --dry-run show what it would do, and change nothing
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage: uninstall.sh [--dir DIR] [--dry-run]
|
||||
piped: curl -fsSL https://deejanuz.github.io/frametop/uninstall.sh | bash -s -- [options]
|
||||
EOF
|
||||
}
|
||||
|
||||
dry=0
|
||||
apps=$HOME/.local/share/applications
|
||||
override=$apps/deckard-nested-desktop.desktop
|
||||
relay_unit=$HOME/.config/systemd/user/frametop-input-relay.service
|
||||
driver=$HOME/.local/share/frametop/ft_pointer
|
||||
vrpathreg=/opt/steamvr/bin/linuxarm64/vrpathreg
|
||||
handsctl=$HOME/.local/bin/ft-handsctl
|
||||
eyegrab_files=(/etc/systemd/system/frametop-eyegrab.service /etc/frametop/ft-eyegrab)
|
||||
bt_files=(/etc/systemd/system/steamframe-bt-fixups.service /etc/systemd/system/bluetooth.service.d/steamframe.conf
|
||||
/etc/steamframe/bt-fixups.sh)
|
||||
|
||||
step() { printf '\n\033[1m== %s\033[0m\n' "$*"; }
|
||||
run() { # run a command, or with --dry-run, show it
|
||||
if [ "$dry" = 1 ]; then printf ' would run: %s\n' "$*"; else "$@"; fi
|
||||
}
|
||||
ask() { # ask "question" default(y|n)
|
||||
local hint answer
|
||||
if [ "$dry" = 1 ]; then echo "$1 [dry run: yes]"; return 0; fi
|
||||
hint=$([ "$2" = y ] && echo "Y/n" || echo "y/N")
|
||||
read -r -p "$1 [$hint] " answer </dev/tty || answer=
|
||||
answer=${answer:-$2}
|
||||
[[ $answer =~ ^[Yy] ]]
|
||||
}
|
||||
exists() { local f; for f in "$@"; do [ -e "$f" ] && return 0; done; return 1; }
|
||||
size() { du -shc "$@" 2>/dev/null | tail -1 | cut -f1; }
|
||||
|
||||
# Is this folder Frametop's code? Only then is it deleted.
|
||||
is_repo() { [ "$1" != "$HOME" ] && [ -f "$1/desktops.sh" ] && [ -f "$1/session/frametop-session.sh" ]; }
|
||||
find_repo() {
|
||||
local p
|
||||
[ -n "$dir" ] && { echo "$dir"; return; }
|
||||
# The launcher entry and the relay's unit point into the repo, until step 1 removes them.
|
||||
p=$(sed -n 's#^Exec=\(.*\)/session/frametop-session\.sh.*#\1#p' "$override" 2>/dev/null | head -1)
|
||||
[ -z "$p" ] && p=$(sed -n 's#^ExecStart=/usr/bin/python3 \(.*\)/input/input-relay\.py.*#\1#p' "$relay_unit" 2>/dev/null | head -1)
|
||||
[ -z "$p" ] && [ -f "${BASH_SOURCE[0]:-}" ] && p=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
echo "${p:-$HOME/frametop}"
|
||||
}
|
||||
|
||||
# Frametop's programs that are running now (step 1 leaves them running until the restart).
|
||||
running() {
|
||||
local n out=()
|
||||
for n in ft-screens ft-pointer ft-powerd ft-eyegrab ft-camd ft-hands; do
|
||||
pgrep -x "$n" >/dev/null && out+=("$n")
|
||||
done
|
||||
pgrep -f '[i]nput/input-relay\.py' >/dev/null && out+=("input relay")
|
||||
pgrep -f '[g]aze/ft-gazed' >/dev/null && out+=("gaze service")
|
||||
pgrep -f '[f]rametop-session\.sh' >/dev/null && out+=("desktop session")
|
||||
echo "${out[*]}"
|
||||
}
|
||||
|
||||
main() {
|
||||
local units=() entries=() names=() sys=0 repo now f
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
--dir) dir=${2:?--dir needs a folder}; shift ;;
|
||||
--dry-run) dry=1 ;;
|
||||
-h|--help) usage; return 0 ;;
|
||||
*) echo "unknown option: $1" >&2; usage >&2; return 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if ! { grep -qx 'ID=steamos' /etc/os-release && grep -qE '^VARIANT_ID="?vr"?$' /etc/os-release; } 2>/dev/null; then
|
||||
echo "This uninstalls Frametop from a Steam Frame (SteamOS, VR variant). Run it in a terminal on the headset." >&2
|
||||
return 1
|
||||
fi
|
||||
if [ "$dry" = 0 ] && ! { : </dev/tty; } 2>/dev/null; then
|
||||
echo "This asks questions, and there's no terminal to ask in. Run it in one (over SSH: ssh -t)." >&2
|
||||
return 1
|
||||
fi
|
||||
[ "$dry" = 1 ] && echo "Dry run: nothing changes."
|
||||
repo=$(find_repo)
|
||||
|
||||
# Step 1: what makes Frametop start. Nothing here stops a running program.
|
||||
[ -f "$override" ] && grep -q 'Frametop' "$override" || override=
|
||||
units=("$HOME"/.config/systemd/user/frametop-*.service)
|
||||
for f in ft-input-settings ft-display-settings ft-layout-reset ft-screens-toggle ft-remote-settings ft-gazeprobe; do
|
||||
[ -e "$apps/$f.desktop" ] && entries+=("$apps/$f.desktop")
|
||||
done
|
||||
[ -e "$apps/frametop-handrec.desktop" ] && entries+=("$apps/frametop-handrec.desktop")
|
||||
entries+=("$apps"/frametop-profile-*.desktop) # one per layout profile (layout/ft_layout.py)
|
||||
[ -L "$handsctl" ] || handsctl=
|
||||
exists "${eyegrab_files[@]}" "${bt_files[@]}" && sys=1
|
||||
|
||||
if [ -n "$override" ] || [ ${#units[@]} -gt 0 ] || [ ${#entries[@]} -gt 0 ] || [ -d "$driver" ] ||
|
||||
[ -n "$handsctl" ] || [ "$sys" = 1 ]; then
|
||||
step "Step 1 of 2: stop Frametop from starting"
|
||||
echo "This removes:"
|
||||
[ -n "$override" ] && echo " - the launcher's Desktop entry (Launch a program -> Desktop opens the stock desktop again)"
|
||||
for f in "${units[@]}"; do echo " - the service $(basename "$f")"; done
|
||||
[ -d "$driver" ] && echo " - the 3D mouse's SteamVR driver (ft_pointer)"
|
||||
[ ${#entries[@]} -gt 0 ] && echo " - ${#entries[@]} menu entries (Frametop Display Settings, Input Settings, ...)"
|
||||
[ -n "$handsctl" ] && echo " - $handsctl"
|
||||
exists "${eyegrab_files[@]}" && echo " - our eye tracker's frame grabber (a system service: needs your password)"
|
||||
exists "${bt_files[@]}" && echo " - the Bluetooth fixes (system files: needs your password)"
|
||||
echo "What runs now keeps running until you restart the headset, so your keyboard, mouse, and"
|
||||
echo "this terminal keep working. Your settings and the code stay for now."
|
||||
ask "Uninstall Frametop?" n || { echo "Nothing changed."; return 0; }
|
||||
|
||||
[ -n "$override" ] && run rm -f "$override"
|
||||
if [ ${#units[@]} -gt 0 ]; then
|
||||
for f in "${units[@]}"; do names+=("$(basename "$f")"); done
|
||||
run systemctl --user disable "${names[@]}" 2>/dev/null || true
|
||||
run rm -f "${units[@]}"
|
||||
run systemctl --user daemon-reload
|
||||
fi
|
||||
if [ -d "$driver" ]; then
|
||||
if [ -x "$vrpathreg" ]; then
|
||||
LD_LIBRARY_PATH=$(dirname "$vrpathreg") run "$vrpathreg" removedriver "$driver" ||
|
||||
echo "warning: SteamVR's vrpathreg couldn't unregister the driver; SteamVR may log that it's missing" >&2
|
||||
fi
|
||||
run rm -rf "$driver"
|
||||
fi
|
||||
[ ${#entries[@]} -gt 0 ] && run rm -f "${entries[@]}"
|
||||
[ -n "$handsctl" ] && run rm -f "$handsctl"
|
||||
if [ "$sys" = 1 ]; then
|
||||
echo "The system files need your password (sudo)."
|
||||
if ! run sudo bash -c '
|
||||
for u in frametop-eyegrab steamframe-bt-fixups; do systemctl disable $u.service 2>/dev/null; done
|
||||
rm -f "$@"
|
||||
rmdir /etc/frametop /etc/steamframe /etc/systemd/system/bluetooth.service.d 2>/dev/null
|
||||
systemctl daemon-reload; true' sys "${eyegrab_files[@]}" "${bt_files[@]}"; then
|
||||
echo "warning: the system files weren't removed (no password?). Run this again to retry." >&2
|
||||
fi
|
||||
fi
|
||||
[ "$dry" = 1 ] || echo "Frametop no longer starts."
|
||||
fi
|
||||
|
||||
# Step 2: delete what's left, once nothing of Frametop runs.
|
||||
now=$(running)
|
||||
if [ -n "$now" ]; then
|
||||
step "Restart the headset to finish"
|
||||
echo "Still running from before: $now."
|
||||
echo "They stop when the headset restarts. After that, run this again to delete the code"
|
||||
echo "($repo) and, if you want, your settings and the build container:"
|
||||
echo
|
||||
if [ "$repo" = "$HOME/frametop" ]; then
|
||||
echo " curl -fsSL https://deejanuz.github.io/frametop/uninstall.sh | bash"
|
||||
else
|
||||
echo " curl -fsSL https://deejanuz.github.io/frametop/uninstall.sh | bash -s -- --dir $(printf %q "$repo")"
|
||||
fi
|
||||
echo
|
||||
if ask "Restart the headset now? This closes everything open, in VR and on the desktop." n; then
|
||||
run systemctl reboot || echo "Couldn't restart it from here: restart the headset from Steam's power menu." >&2
|
||||
fi
|
||||
[ "$dry" = 1 ] || return 0
|
||||
echo "Dry run: after the restart, step 2 would go like this."
|
||||
fi
|
||||
|
||||
step "Step 2 of 2: delete what's left"
|
||||
if [ -d "$repo" ] && is_repo "$repo"; then
|
||||
if [ -f "$repo/.git" ]; then
|
||||
echo "Leaving $repo: it's a git worktree. Remove it with git worktree remove."
|
||||
else
|
||||
local def=y
|
||||
if [ -n "$(git -C "$repo" status --porcelain --untracked-files=no 2>/dev/null)" ] ||
|
||||
[ -n "$(git -C "$repo" log --branches --not --remotes --oneline 2>/dev/null | head -1)" ]; then
|
||||
echo "$repo has changes of its own (git -C $repo status)."
|
||||
def=n
|
||||
fi
|
||||
if ask "Delete the Frametop code in $repo ($(size "$repo"))?" "$def"; then
|
||||
cd "$HOME"
|
||||
run rm -rf "$repo"
|
||||
fi
|
||||
fi
|
||||
elif [ -e "$repo" ]; then
|
||||
echo "Leaving $repo: it doesn't look like Frametop's code."
|
||||
fi
|
||||
|
||||
# Rebuilt by the desktop at each start, so nothing to keep.
|
||||
run rm -rf "$HOME/.local/share/frametop/apps" "$HOME/.local/share/kwin/decorations/kwin4_decoration_qml_frametop" \
|
||||
"$HOME/.cache/frametop"
|
||||
|
||||
local settings=("$HOME"/.config/frametop.conf* "$HOME"/.config/frametop-*.json* "$HOME/.config/frametop-remote"
|
||||
"$HOME/.config/frametop" "$HOME/.local/state/frametop")
|
||||
local kept=()
|
||||
for f in "${settings[@]}"; do [ -e "$f" ] && kept+=("$f"); done
|
||||
if [ ${#kept[@]} -gt 0 ]; then
|
||||
echo "Your settings: the screen layout and profiles, button maps, gaze calibration, the remote"
|
||||
echo "desktop password, and the Frametop desktop's own Plasma setup (${kept[*]/#$HOME/\~})."
|
||||
ask "Delete your settings too? Keep them to pick up where you left off if you reinstall." n &&
|
||||
run rm -rf "${kept[@]}"
|
||||
fi
|
||||
|
||||
local recs=()
|
||||
for f in "$HOME/.local/share/frametop/eyes" "$HOME/.local/share/frametop/hands"; do [ -e "$f" ] && recs+=("$f"); done
|
||||
if [ ${#recs[@]} -gt 0 ]; then
|
||||
ask "Delete your eye and hand recordings in ~/.local/share/frametop ($(size "${recs[@]}"))?" n &&
|
||||
run rm -rf "${recs[@]}"
|
||||
fi
|
||||
[ "$dry" = 1 ] || rmdir "$HOME/.local/share/frametop" 2>/dev/null || true
|
||||
|
||||
if command -v podman >/dev/null && podman container exists dev 2>/dev/null; then
|
||||
echo "The build container (dev) holds Frametop's compilers and libraries, 1-2 GB. If you put"
|
||||
echo "anything else in it, that goes too."
|
||||
if ask "Delete the build container?" n; then
|
||||
run "$HOME/.local/bin/distrobox" rm --force dev </dev/null
|
||||
run podman image rm registry.fedoraproject.org/fedora-toolbox:44 >/dev/null 2>&1 || true
|
||||
echo "distrobox stays in ~/.local/bin, for any other containers. To remove it too:"
|
||||
echo " ~/dev/src/distrobox/uninstall --prefix ~/.local"
|
||||
fi
|
||||
fi
|
||||
|
||||
step "Done"
|
||||
echo "Frametop is uninstalled."
|
||||
}
|
||||
|
||||
dir=
|
||||
main "$@"
|
||||
Reference in new issue
Block a user