mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-10 06:00:18 +02:00
Compare commits
188
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4daa8fea5b | ||
|
|
78fac8ca0a | ||
|
|
09cec0c980 | ||
|
|
325d3d91a4 | ||
|
|
48a3c0af87 | ||
|
|
d7cc82ab09 | ||
|
|
a4a1d8819c | ||
|
|
e2b4eb2a81 | ||
|
|
95938a1f27 | ||
|
|
9c0facd0c5 | ||
|
|
eaaab1ccaf | ||
|
|
84a354536e | ||
|
|
14cbfc310e | ||
|
|
40c7606ace | ||
|
|
00a377a0fb | ||
|
|
8a88fbea05 | ||
|
|
9411148596 | ||
|
|
95ccaa30de | ||
|
|
b655cd7446 | ||
|
|
0e506aaf81 | ||
|
|
fcafd43679 | ||
|
|
4764c21548 | ||
|
|
eae2f0b754 | ||
|
|
38bdfd856a | ||
|
|
6dd9fb1ad8 | ||
|
|
6dc4521905 | ||
|
|
96452bbee5 | ||
|
|
b6908737c4 | ||
|
|
f6a9845996 | ||
|
|
050be96bf0 | ||
|
|
7dfa04aab2 | ||
|
|
2a675b9121 | ||
|
|
f0df502a23 | ||
|
|
e402b134ad | ||
|
|
083fa89f52 | ||
|
|
eb13cfc5dc | ||
|
|
c40bb645f2 | ||
|
|
195fb1e441 | ||
|
|
f6fe1455fc | ||
|
|
2052dff70d | ||
|
|
0d7ed2d75c | ||
|
|
a94af27131 | ||
|
|
d4558c7187 | ||
|
|
aef18d3601 | ||
|
|
221d01db32 | ||
|
|
282b038a3c | ||
|
|
63bcea49a6 | ||
|
|
9b11162b61 | ||
|
|
983a4654b3 | ||
|
|
85b8717bb8 | ||
|
|
e2f51477a3 | ||
|
|
525c77e60c | ||
|
|
64d3a9eb9c | ||
|
|
d1430d20e8 | ||
|
|
6825f80096 | ||
|
|
8086350e06 | ||
|
|
fb6ef934a4 | ||
|
|
54ec0853aa | ||
|
|
8fc8cf2063 | ||
|
|
b15dce6a0a | ||
|
|
b52df75bc0 | ||
|
|
dfd1bbca0f | ||
|
|
0318bb72ab | ||
|
|
3519a7a0e1 | ||
|
|
8117a4fb7e | ||
|
|
4ba49af487 | ||
|
|
82fbc6fc1b | ||
|
|
8bd2ac4c92 | ||
|
|
ac692c38ea | ||
|
|
d6556ca813 | ||
|
|
9d66e36439 | ||
|
|
dac4188392 | ||
|
|
a9f1bcfdde | ||
|
|
effcfd0e48 | ||
|
|
3be79efade | ||
|
|
030ef2faf1 | ||
|
|
44c0b2d796 | ||
|
|
56aa3e4b4b | ||
|
|
977e9a0950 | ||
|
|
82979e4423 | ||
|
|
18e436d7b4 | ||
|
|
bb88599085 | ||
|
|
302df6c2da | ||
|
|
8298754437 | ||
|
|
d6f6f0fe7f | ||
|
|
491202acfc | ||
|
|
b234abc245 | ||
|
|
f8f33c5663 | ||
|
|
b33c5df41f | ||
|
|
06288ff20c | ||
|
|
7854d6ab9d | ||
|
|
891d84ae2b | ||
|
|
434b4aadc9 | ||
|
|
ed6caf6ff0 | ||
|
|
5fa557afbb | ||
|
|
a0a5468c59 | ||
|
|
e984418b25 | ||
|
|
77f7cc82be | ||
|
|
897488ff02 | ||
|
|
17f3a2c55c | ||
|
|
72e5ad4489 | ||
|
|
e88ae95c0c | ||
|
|
2ce81c553a | ||
|
|
332633c919 | ||
|
|
d2a9e2bbc9 | ||
|
|
318a4a1682 | ||
|
|
60095b02bf | ||
|
|
ea47422872 | ||
|
|
c119442c70 | ||
|
|
fb1c587d69 | ||
|
|
102e859cf8 | ||
|
|
b21dfd1bb0 | ||
|
|
bcf8516ebd | ||
|
|
db0d526f81 | ||
|
|
81f24a5d04 | ||
|
|
55f00f300d | ||
|
|
b2d32e8934 | ||
|
|
f6b4323669 | ||
|
|
903abd75dd | ||
|
|
f65afce59a | ||
|
|
1bb8e73cb9 | ||
|
|
0821b1013e | ||
|
|
3cccad3525 | ||
|
|
845bff0037 | ||
|
|
2ede132b62 | ||
|
|
a70463d95b | ||
|
|
a84889b4a5 | ||
|
|
8b9edcfbe2 | ||
|
|
0f1132ed27 | ||
|
|
4828dd46b5 | ||
|
|
d4e5e2b442 | ||
|
|
82da3525b7 | ||
|
|
2a2bca336c | ||
|
|
cd47dde566 | ||
|
|
a5d001d4b5 | ||
|
|
fa65954834 | ||
|
|
c747bea791 | ||
|
|
e918359c8f | ||
|
|
c53a4f07e9 | ||
|
|
cb34ba45a1 | ||
|
|
740aededff | ||
|
|
8655395030 | ||
|
|
6f65145553 | ||
|
|
cce37b5835 | ||
|
|
ff3724cb3c | ||
|
|
48bceaef64 | ||
|
|
c1ba71aba1 | ||
|
|
9c6647538b | ||
|
|
8a77364767 | ||
|
|
b61b4d6c6a | ||
|
|
edf605f896 | ||
|
|
b5fe574ae6 | ||
|
|
76d6542071 | ||
|
|
a94a0fb432 | ||
|
|
b1b1a16f5b | ||
|
|
276c1409e8 | ||
|
|
a0fe3bb55e | ||
|
|
13198bc203 | ||
|
|
42a26753d3 | ||
|
|
0f0674ff36 | ||
|
|
3a796b5013 | ||
|
|
67a3c02e31 | ||
|
|
662919babf | ||
|
|
98bd6ce21c | ||
|
|
608ad6034a | ||
|
|
336a60ce32 | ||
|
|
e1f27958ca | ||
|
|
c969963b83 | ||
|
|
9f2ee5ad68 | ||
|
|
dfc8e6eb5f | ||
|
|
1037d4e125 | ||
|
|
307e2b7308 | ||
|
|
d39fbb9146 | ||
|
|
7fe5d8c0dd | ||
|
|
6df6f97937 | ||
|
|
06f2dd63c1 | ||
|
|
ca9492be00 | ||
|
|
41b5b68a41 | ||
|
|
9d7c8a0b91 | ||
|
|
5e3323c188 | ||
|
|
25504e0ed4 | ||
|
|
66160aed55 | ||
|
|
ec870d2d7a | ||
|
|
80797c98b9 | ||
|
|
2fadbbe148 | ||
|
|
5ee07f99a8 | ||
|
|
d995b815f8 | ||
|
|
64c4eec59f |
No files matched your search
@@ -0,0 +1,18 @@
|
||||
# Keep the build context small: what .gitignore ignores, and regenerated files.
|
||||
# Unlike .gitignore, patterns here only match at the top unless they start
|
||||
# with **/, so the ones that can appear in any folder do.
|
||||
.git/
|
||||
.worktrees/
|
||||
.venv/
|
||||
**/build/
|
||||
**/target/
|
||||
**/captures/
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
# Eye-camera frame dumps are biometric (see .gitignore): never in an image.
|
||||
**/*.raw
|
||||
**/*.pgm
|
||||
**/.env
|
||||
**/.env.*
|
||||
frametop-report-*.txt
|
||||
.frame-job.d/
|
||||
@@ -0,0 +1,91 @@
|
||||
# Build Frametop's release on Depot's arm64 runners (the Frame is aarch64): the image from
|
||||
# pack/Containerfile, the test gate inside it, then Frametop.zip (framedrop/build.sh), the
|
||||
# image with its installer: FrameDrop installs it from a PC, or you unpack it on the headset
|
||||
# and run Frametop/frametop-install.sh, or get.sh --release downloads it (pack/README.md,
|
||||
# Releases). It's about 1.1 GB, under GitHub's 2 GB a file.
|
||||
#
|
||||
# A v* tag makes a draft GitHub release with the zip, its FrameDrop manifest, its
|
||||
# frametop-release.json, and SHA256SUMS (a prerelease when the tag has a "-", like
|
||||
# v0.3.0-exp.1). Nothing is public until someone publishes the draft. A manual run keeps the
|
||||
# zip as the run's artifact for a week.
|
||||
#
|
||||
# podman, as on the Frame: the zip holds podman save's archive, which the headset loads with
|
||||
# podman. Not on pushes or pull requests: Depot's runners are paid, and a fork's pull request
|
||||
# would run on them. Actions are pinned to commit SHAs (the version in the comment).
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write # the draft release
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: depot-ubuntu-24.04-arm-4
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
FT_ENGINE: podman
|
||||
steps:
|
||||
# actions/checkout@v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
|
||||
- name: version
|
||||
run: |
|
||||
if [ "$GITHUB_REF_TYPE" = tag ]; then v=${GITHUB_REF_NAME#v}; else v=0.0.0-ci.${GITHUB_SHA::7}; fi
|
||||
echo "VERSION=$v" >> "$GITHUB_ENV"
|
||||
echo "FT_IMAGE=localhost/frametop:$v" >> "$GITHUB_ENV"
|
||||
|
||||
- name: build the image
|
||||
run: ./ft dev build
|
||||
|
||||
# The unit gate: Python (strict), C, and shell, inside the image, so what passes here is
|
||||
# what the zip installs.
|
||||
- name: test (python, c, bash)
|
||||
run: ./ft dev test
|
||||
|
||||
- name: smoke (what a release installs from the image)
|
||||
run: |
|
||||
podman run --rm --entrypoint sh "$FT_IMAGE" -c '
|
||||
set -e
|
||||
for p in ft-screens ft-pointer ft-powerd ft-gaze ft-gazepanel ft-stream ft-eyegrab; do
|
||||
test -x /opt/frametop/bin/$p
|
||||
done
|
||||
test -f /src/frametop/pointer/driver/build/driver_ft_pointer.so
|
||||
test -x /src/frametop/pack/build/distrobox/install
|
||||
/src/frametop/gaze/tracker/build/venv/bin/python -c "import numpy, cv2"
|
||||
python3 -c "import PySide6"'
|
||||
|
||||
# The FrameDrop manifest names the zip's URL in this repo's release (a fork's, in a fork).
|
||||
- name: Frametop.zip
|
||||
run: |
|
||||
url=()
|
||||
[ "$GITHUB_REF_TYPE" = tag ] &&
|
||||
url=("https://github.com/$GITHUB_REPOSITORY/releases/download/$GITHUB_REF_NAME/Frametop.zip")
|
||||
framedrop/build.sh --image "$FT_IMAGE" --version "$VERSION" --commit "$GITHUB_SHA" "${url[@]}"
|
||||
|
||||
- name: keep the zip (manual runs)
|
||||
if: github.ref_type != 'tag'
|
||||
# actions/upload-artifact@v7.0.2
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
|
||||
with:
|
||||
name: Frametop-${{ env.VERSION }}
|
||||
path: framedrop/build/
|
||||
retention-days: 7
|
||||
|
||||
- name: draft release (tags)
|
||||
if: github.ref_type == 'tag'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
pre=()
|
||||
[[ $VERSION == *-* ]] && pre=(--prerelease)
|
||||
gh release create "$GITHUB_REF_NAME" --draft --verify-tag "${pre[@]}" \
|
||||
--title "Frametop $VERSION" --notes-file pack/release-notes.md --generate-notes \
|
||||
framedrop/build/Frametop.zip framedrop/build/frametop.framedrop.json \
|
||||
framedrop/build/frametop-release.json framedrop/build/SHA256SUMS
|
||||
@@ -0,0 +1 @@
|
||||
3.14
|
||||
@@ -25,14 +25,22 @@ scripts/frame.sh --host '<cmd>' # runs on the SteamOS host
|
||||
A Steam Frame is someone's personal headset, and they may be wearing it while you work.
|
||||
|
||||
- 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`).
|
||||
- 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 `uninstall` and `uncaps`, which take them back), 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, or sets file capabilities, add it to `uninstall.sh` too: users uninstall with that script, not with each installer's `uninstall`.
|
||||
- Never copy `.netrc`, SSH keys, or Steam config off the Frame or into this repo.
|
||||
|
||||
## SteamOS updates
|
||||
|
||||
A SteamOS update replaces SteamVR, KWin, and gamescope with the rest of the OS image. When a change starts depending on something from the image (a host file, an OpenVR interface outside the bundled header, an undocumented layout or output format, a SteamVR or KWin quirk), add a check for it to `scripts/update-check.py`, or a retest hint for its package there. [docs/design.md](docs/design.md) has the background.
|
||||
|
||||
## The image (`pack/`)
|
||||
|
||||
`pack/Containerfile` builds Frametop as an OCI image, the groundwork for installing without building on the headset; no installer uses it yet. [pack/design.md](pack/design.md) explains why and what is open, and [pack/README.md](pack/README.md) documents the specifics. Rules:
|
||||
|
||||
- **Pin every input.** Base images by digest, Python via `uv.lock` (commit the lock, never a bare `uv pip install`), downloads by tag or commit and sha256, CI actions by commit SHA.
|
||||
- **One build recipe.** The image runs the components' own `build.sh` scripts (`FRAME_IN_BOX=1`), and the OpenVR SDK they build against is pinned in `scripts/openvr.sh`. Change a build there, not in the Containerfile, and keep the Containerfile buildable on arm64, the only architecture the Frame has.
|
||||
- **Keep the tests green in the image.** `./ft dev test` runs `just test` inside it, and CI runs the same recipes. A new offline test that needs no headset goes in the `justfile` too.
|
||||
|
||||
## Names
|
||||
|
||||
User-facing names are "Frametop", "Frametop Display Settings", and "Frametop Input Settings". Programs and files use the `ft-` / `ft_` prefix (`ft-screens`, `ft-pointer`, `ft-layout`, the `ft_pointer` driver); config, units, and overlay keys use `frametop`. Program names must stay within 15 characters: Linux truncates process names there, and the scripts find programs with `pgrep -x` / `pkill -x`.
|
||||
@@ -33,7 +33,7 @@ You need a Steam Frame with an internet connection, a keyboard (Bluetooth, or th
|
||||
|
||||
The installer sets up distrobox in your home folder (the system files aren't touched), a Fedora build container, and everything else. The first run downloads 1–2 GB. It asks you four things along the way: whether to install gaze mode (experimental, yes by default), our own eye tracker for it (yes by default), and the Bluetooth fixes, then whether to restart SteamVR. The eye tracker and the Bluetooth fixes need your `sudo` password; if you've never set one, run `passwd` first, or skip them for now. SteamVR has to restart once at the end, which closes everything open in VR, including the terminal. Rebooting the headset works too.
|
||||
|
||||
After the restart, Launch a program → Desktop opens the multi-screen desktop, with its screens arranged around where you're facing. Frametop Display Settings and Frametop Input Settings are in the desktop's application menu, under Settings.
|
||||
After the restart, Launch a program → Desktop opens the multi-screen desktop, with its screens arranged around where you're facing. Frametop Display Settings and Frametop Input Settings are in the desktop's application menu, under Settings. SteamOS's own single-screen desktop is still there, as Native Desktop in the same list.
|
||||
|
||||
If you work in the desktop for long stretches, or leave the headset on a stand, open Frametop Display Settings → Power. Turn on Stay awake while plugged in: by default Steam puts the Frame to sleep after an hour without input, even while it charges. And choose when the displays turn off while the headset isn't used. SteamVR turns them off a few seconds after you take the headset off, but a stand or mount that covers the proximity sensor inside it makes the headset seem worn, and its displays stay on all night.
|
||||
|
||||
@@ -157,7 +157,7 @@ In a terminal on the headset, run:
|
||||
cd ~/frametop && scripts/report.sh
|
||||
```
|
||||
|
||||
This writes `frametop-report-<date>.txt` with version numbers, service states, settings, and recent logs. Bluetooth addresses and the headset's serial number are masked. Then [open an issue](https://github.com/DeeJanuz/frametop/issues), describe what you did, what you expected, and what happened, and attach the file. Quick questions can go to [Discord](https://discord.gg/W3X9f7z3Bc) instead.
|
||||
This writes `frametop-report-<date>.txt` with version numbers, service states, settings, and recent logs. Bluetooth addresses and the headset's serial number are masked. It takes up to half a minute, because it also checks gaze mode: it starts the gaze service for a moment to see whether the eye tracker sends. If gaze or its calibration doesn't work, run it while you wear the headset. `scripts/gaze-report.py` prints only the gaze part, with what looks wrong first. Then [open an issue](https://github.com/DeeJanuz/frametop/issues), describe what you did, what you expected, and what happened, and attach the file. Quick questions can go to [Discord](https://discord.gg/W3X9f7z3Bc) instead.
|
||||
|
||||
## Update
|
||||
|
||||
@@ -171,23 +171,22 @@ Or by hand: `cd ~/frametop && git pull && ./install.sh`.
|
||||
|
||||
## Uninstall
|
||||
|
||||
In a terminal on the headset, run:
|
||||
|
||||
```
|
||||
./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
|
||||
curl -fsSL https://deejanuz.github.io/frametop/uninstall.sh | bash
|
||||
```
|
||||
|
||||
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.
|
||||
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 the Native Desktop entry, Frametop's services, its SteamVR driver, and its menu entries are removed, along with the system files of our eye tracker and the Bluetooth fixes and the file capabilities of hand tracking's camera broker (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.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -197,6 +196,7 @@ 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. |
|
||||
@@ -213,6 +213,10 @@ A Plasma session runs nested inside ft-screens (`screens/`), a small Wayland com
|
||||
| `setup/` | The build container and the Bluetooth fixes. See [setup/README.md](setup/README.md). |
|
||||
| `scripts/` | Helpers the installers use. They run commands locally on the Frame, or over SSH from a PC. |
|
||||
|
||||
## Packaging
|
||||
|
||||
`pack/` builds Frametop as an OCI image: the toolchain, the native binaries, and the locked Python environment (via [uv](https://docs.astral.sh/uv/)), built and tested by GitHub Actions. It is the groundwork for installing Frametop without building anything on the headset. No installer uses it yet. For development, `./ft dev build` builds the image and `./ft dev test` runs the tests inside it. [pack/design.md](pack/design.md) explains why an image and what is still open, and [pack/README.md](pack/README.md) documents the image itself.
|
||||
|
||||
## Developing from a PC
|
||||
|
||||
The scripts also work from a Linux or WSL PC over SSH, which is easier for editing code. On the Frame they use the local checkout; on a PC they sync the repo to `~/dev/frametop` on the Frame and run there.
|
||||
|
||||
+13
-2
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start, stop, or inspect the multi-screen Plasma desktop in VR on the Frame.
|
||||
# Usage: desktops.sh start [screens] | stop | restart | status | log [lines]
|
||||
# desktops.sh install # make the VR launcher's "Desktop" entry start Frametop
|
||||
# desktops.sh install # make the VR launcher's "Desktop" entry start Frametop, and add
|
||||
# # "Native Desktop" for the stock SteamOS desktop
|
||||
# desktops.sh uninstall # give the launcher back the stock SteamOS desktop
|
||||
# desktops.sh screens N # set the default screen count in ~/.config/frametop.conf
|
||||
# desktops.sh remote on|off|info # VNC access over the tailnet (applies on next start)
|
||||
@@ -16,6 +17,8 @@ action=${1:-start}
|
||||
screens=${2:-${FT_SCREENS:-}}
|
||||
session=$FRAME_REPO/session
|
||||
override=.local/share/applications/deckard-nested-desktop.desktop
|
||||
native_copy=.local/share/applications/native-deckard-nested-desktop.desktop
|
||||
stock=/usr/share/applications/deckard-nested-desktop.desktop
|
||||
log=/tmp/frametop-session.log
|
||||
# Bracketed first letter so pgrep/pkill never match the ssh shell running them.
|
||||
match='[v]r-overlay-key frametop '
|
||||
@@ -35,15 +38,23 @@ systemd-run --user --collect --quiet --unit frametop-desktop \
|
||||
sleep 12; echo \"plasmashell processes: \$(pgrep -c plasmashell)\"
|
||||
$running && echo 'started' || { echo 'failed:'; tail -20 $log; exit 1; }" ;;
|
||||
install)
|
||||
# Also "Native Desktop", a copy of SteamOS's entry for its own desktop. Optional, so a SteamOS
|
||||
# without the stock entry still installs. No X-Steam-Special, so Steam can only single out ours.
|
||||
"$root/scripts/sync.sh" >/dev/null
|
||||
"$frame" --host "set -e; mkdir -p ~/.local/share/applications
|
||||
sed 's|@SESSION@|$session/frametop-session.sh|' $session/deckard-nested-desktop.desktop > ~/$override
|
||||
if [ -r $stock ]; then
|
||||
sed -e 's/^Name=.*/Name=Native Desktop/' -e '/^Name\\[/d' -e '/^X-Steam-Special=/d' -e '/pick this out/d' \\
|
||||
$stock > ~/$native_copy.new && mv ~/$native_copy.new ~/$native_copy
|
||||
else
|
||||
rm -f ~/$native_copy; echo 'no $stock: no Native Desktop entry' >&2
|
||||
fi
|
||||
[ -f ~/.config/frametop.conf ] || cp $session/frametop.conf.example ~/.config/frametop.conf
|
||||
echo \"installed ~/$override\"; grep ^Exec= ~/$override; echo; cat ~/.config/frametop.conf" ;;
|
||||
uninstall)
|
||||
# Also what the session puts in place at each start: Launch as Standalone's app copies and
|
||||
# the title bar decoration (float/ft_apps.py, decoration/).
|
||||
"$frame" --host "rm -f ~/$override
|
||||
"$frame" --host "rm -f ~/$override ~/$native_copy
|
||||
rm -rf ~/.local/share/frametop/apps ~/.local/share/kwin/decorations/kwin4_decoration_qml_frametop
|
||||
rmdir ~/.local/share/frametop 2>/dev/null; echo 'removed; the launcher uses the stock desktop again'" ;;
|
||||
screens)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Launch Frametop Display Settings from a Plasma session on the Frame host.
|
||||
# The app runs in the dev container (PySide6 and Kirigami come from Fedora there).
|
||||
# The app runs in Frametop's container (PySide6 and Kirigami come from Fedora there).
|
||||
# podman needs the real XDG_RUNTIME_DIR and the real user bus (to reach systemd for
|
||||
# the container's cgroup; the Frametop session runs on a private bus from
|
||||
# dbus-run-session). The session's Wayland socket and bus go to the app itself.
|
||||
@@ -10,8 +10,7 @@ case $wl in /*) ;; *) wl="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/$wl" ;; esac
|
||||
session_bus=${DBUS_SESSION_BUS_ADDRESS:-}
|
||||
export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
||||
export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus
|
||||
"$here/../scripts/container-up.sh"
|
||||
exec "$HOME/.local/bin/distrobox" enter dev -- env WAYLAND_DISPLAY="$wl" DISPLAY="${DISPLAY:-}" \
|
||||
exec "$here/../scripts/in-box" env WAYLAND_DISPLAY="$wl" DISPLAY="${DISPLAY:-}" \
|
||||
XAUTHORITY="${XAUTHORITY:-}" DBUS_SESSION_BUS_ADDRESS="$session_bus" \
|
||||
QT_QPA_PLATFORM="wayland;xcb" \
|
||||
python3 "$here/ft_display_settings.py" "$@"
|
||||
@@ -16,6 +16,8 @@ the dev container:
|
||||
saved from where the screens are, with a preview; arrange now; save the current
|
||||
arrangement under a name; rename and delete; arrange automatically when the
|
||||
desktop starts.
|
||||
- Remote displays (other computers' monitors as screens) have their own app, Frametop
|
||||
Remote Displays (remote-displays/); a button opens it.
|
||||
- Power: how long the headset can go unused before ft-powerd turns its displays off
|
||||
(DISPLAY_OFF_MIN; the service's state comes from its control socket, @ft_powerd),
|
||||
and whether the Frame stays awake while plugged in, which is Steam's own setting
|
||||
@@ -54,6 +56,7 @@ SCREEN_RESOLUTIONS = [(1920, 1080, ""), (2560, 1440, ""), (3840, 2160, "4K"), (2
|
||||
(2560, 1600, "16:10"), (1080, 1920, "portrait"), (1440, 2560, "portrait"),
|
||||
(2160, 3840, "portrait 4K")]
|
||||
FT_SCREENS = "\0ft_screens"
|
||||
REMOTE_DISPLAYS = os.path.join(HERE, "..", "remote-displays", "ft_remote_displays.py")
|
||||
FT_POWERD = "\0ft_powerd"
|
||||
# Steam's default for "When Plugged In and Idle -> Sleep after", to go back to when
|
||||
# nothing was saved.
|
||||
@@ -658,6 +661,13 @@ class Backend(QObject):
|
||||
def capture(self):
|
||||
self._run("Saving the current arrangement", "capture")
|
||||
|
||||
# --- remote displays: their own app ---
|
||||
@Slot()
|
||||
def openRemoteDisplays(self):
|
||||
"""Frametop Remote Displays (we're in the dev container already, as it runs)."""
|
||||
if not QProcess.startDetached(sys.executable, [os.path.abspath(REMOTE_DISPLAYS)]):
|
||||
self.message.emit("Couldn't start Frametop Remote Displays", True)
|
||||
|
||||
# --- ft-layout on the host ---
|
||||
def _run(self, label, *args):
|
||||
if self._proc is not None:
|
||||
|
||||
@@ -180,6 +180,13 @@ Kirigami.ApplicationWindow {
|
||||
icon.name: "view-visible"
|
||||
onTriggered: backend.toggleScreens()
|
||||
},
|
||||
Kirigami.Action {
|
||||
visible: spage.md
|
||||
text: "Remote displays"
|
||||
icon.name: "network-workgroup"
|
||||
tooltip: "Other computers' monitors as screens: Frametop Remote Displays"
|
||||
onTriggered: backend.openRemoteDisplays()
|
||||
},
|
||||
Kirigami.Action {
|
||||
visible: backend.desktopRunning
|
||||
text: "Restart desktop"
|
||||
|
||||
+23
-5
@@ -42,6 +42,8 @@ Wherever ft-screens needs to know where a laser points (showing the controls, th
|
||||
|
||||
`ComputeOverlayIntersection` ignores `SetOverlayIntersectionMask`, and a control can't be allowed to cover part of its screen, so the resize tab sits entirely outside the corner.
|
||||
|
||||
What SteamVR hits isn't the texture's shape but the mouse scale's: an overlay is as tall, for SteamVR's laser and `ComputeOverlayIntersection`, as its width times the mouse scale's height over its width, and the default scale is 1 × 1. With it, the grab bar (a 256 × 24 texture) took hits in a square as tall as the bar is wide, so it caught clicks meant for the bottom tenth or so of the screen above it. Measured with a 0.2 m wide 256 × 24 overlay: a hit area 199 mm tall at the default scale, 18 mm at 256 × 24 (the bar itself is 18.8 mm), and no change from an intersection mask. `MakeChrome` sets each control's mouse scale to its texture size.
|
||||
|
||||
### Pinning
|
||||
|
||||
Pinning started as "bring the screen to your wrist", which doesn't work for big screens, because their centre is far from the edge you bring close. It became aiming: while a screen is carried, the line from the carrying device to its bar is tested against the other hand controllers. Crossing a controller's 6 cm ring arms the pin (leaving past 9 cm, so it doesn't flicker), and crossing it again disarms it. The pin happens on release, with the screen's pose at that moment, so you can arm it and then turn the screen. An earlier version pinned the moment the laser touched the wrist, which left the screen at whatever angle the carrying hand had while pointing there.
|
||||
@@ -85,12 +87,13 @@ In a game, Frametop's panels work like SteamVR's own floating windows: point a c
|
||||
|
||||
### The KWin script
|
||||
|
||||
The KWin side is a script (`float/frametop-float.js`), not a C++ effect, because a script keeps working across KWin updates and an effect would have to match the host's exact KWin build. KWin scripts can call D-Bus but can't serve it, so ft-floatd's commands come back through a long poll: the script calls `NextCommand`, which answers when a command is ready, or empty after 20 seconds, under KWin's 25-second D-Bus timeout. A few things about KWin's script engine:
|
||||
The KWin side is a script (`float/frametop-float.js`), not a C++ effect, because a script keeps working across KWin updates and an effect would have to match the host's exact KWin build. KWin scripts can call D-Bus but can't serve it, so ft-floatd's commands come back through a long poll: the script calls `NextCommand`, which answers when a command is ready, or empty after 20 seconds, under KWin's 25-second D-Bus timeout. If no reply comes within 30 seconds, a watchdog calls again, and waits twice as long each time until a reply comes (up to 5 minutes). The 30 seconds have to stay above KWin's timeout, or a late reply would start a second poll. A few things about KWin's script engine:
|
||||
|
||||
- `windowAdded` reports popups as windows of their own (`popupWindow` true, `transient` true) with their geometry.
|
||||
- Setting `frameGeometry` applies asynchronously: the app has to answer the new size first.
|
||||
- A script can't read a window's maximize mode, so the script counts a window as maximized when it fills its output's maximize area.
|
||||
- `globalThis` isn't defined. `print` goes to the journal unless `QT_FORCE_STDERR_LOGGING=1`.
|
||||
- `callDBus` never calls back when a call fails (an error reply, the name gone from the bus, or the 25-second timeout). KWin only logs `Received D-Bus message is error`, so a script that waits for the callback waits forever.
|
||||
|
||||
The title bar's float button is Frametop's own window decoration (`decoration/`), written in QML for KWin's Aurorae engine, which loads it without compiling. A C++ fork of Breeze would have to match SteamOS's exact KDecoration build. A decoration can only make the window requests KWin offers it, so the button toggles keep-below, which has no visible effect on a window alone on its own output, and the script treats keep-below as the floating flag.
|
||||
|
||||
@@ -133,7 +136,7 @@ A few overlays need special handling:
|
||||
|
||||
Head follow is experimental and off by default. It works, but it's only lightly tested, and the feel is mostly a matter of its settings; polishing it is left open. With it on (`POINTER_FOLLOW=1`, or a mouse button mapped to Head follow on/off), the cursor rides on a reference direction, where you were facing when your head last settled, and keeps its offset from it. The mouse can put the cursor anywhere up to `POINTER_FOLLOW_REACH` (70 degrees) from the reference, a corner of your view included. While your head stays within `POINTER_LEASH_DEG` of the reference, nothing moves on its own. Once your head has been past the leash for `POINTER_LEASH_DELAY` (0.2 s, so a glance out and back doesn't count), the reference eases to where you're facing (time constant `POINTER_LEASH_RETURN`, 0.2 s), never falling further behind than the leash, and the cursor ends up back where it was in your view. Then it waits for the leash again. Two earlier versions didn't work out. Moving the reference only while your head pulled at the end of the leash left it up to the leash off after you turned back, and getting it centred again meant overshooting with your head. Easing it toward your facing all the time moved the cursor on every small head movement. A leash of 0 makes the reference your facing direction, so the cursor is locked to your view, and mouse movement shifts it within the view. Head roll is ignored, so tilting your head doesn't swing the cursor around. While the left button is held the cursor stays put in the room, so your head can't nudge a click or a drag. When you let go, it carries on from where it is instead of jumping.
|
||||
|
||||
Gaze mode is experimental and off by default (`POINTER_GAZE=1`, the Gaze page of Frametop Input Settings, `gaze/ft-gazectl on`, or a mouse button or key combination mapped to Gaze pointer on/off). It's MAGIC pointing (Zhai, Morimoto and Ihde, 1999): the pointer goes where you look, and the mouse does the last bit. The gaze service (`gaze/ft-gazed`) sends the helper the corrected gaze at 90 Hz (from one eye while the tracker has lost the other), and while the gaze has the pointer, the cursor ray is that gaze from the eye. The pointer is aimed at the gaze each frame, not steered toward it, so nothing can pile up. An earlier try in the gaze probe steered the pointer with relative moves, and lost it when the pointer went idle or a controller had the laser. By default (`POINTER_GAZE_MOUSE_MOVE=held`, the Gaze page's Mouse movement switch) moving the mouse does nothing while the gaze has the pointer: it moves the pointer only while a button is held, as a correction. A bumped or drifting mouse can't pull the pointer off what you're looking at, and every mouse move is a correction, so the lessons aren't polluted by mouse moves to somewhere else (they used to be kept out by an 8 degree limit, which also dropped real corrections when the tracker was further off). With the gaze stale for a second, in a game, or with the headset off, the mouse moves the pointer as usual; with `free`, moving the mouse takes the pointer from the gaze. A left press while the gaze has the pointer isn't sent at once: the pointer stops where the gaze put it, you drag it onto what you meant with the button still down (panels only see it hover), and the release clicks there. Clicking at once clicked wherever the gaze was, often the wrong thing, before you could correct it. The drag is the correction. Snapping the pointer onto buttons and links is deferred: it needs accessibility (AT-SPI) on in the Frametop session, where it's off (no registry runs), plus app restarts, and it makes Chromium and Electron apps use more CPU. A press held still for `POINTER_GAZE_HOLD` (0.5 s) becomes a real press, so drags still work: hold, then move. The right button works the same way, with the right click on the release, and pressing it while the left press is held back starts a drag where the pointer is, like Meta+J then Meta+K. That drag lasts while either button (or key) is held, so a second right press, or a second Meta+K, is free to pan and tilt the panel being dragged; with the keyboard, the head turns it. Outside games the pointer then stays: the mouse going idle doesn't release it. A moving controller still releases it, as without gaze. Gaze mode is a mouse and keyboard feature: Steam reads the Frame controllers itself, outside SteamVR's bindings, so controller clicks at the gaze kept knocking SteamVR out of laser mode (see `docs/gaze-controllers.md`). Keyboard clicks (Meta+J, Meta+K) hold the dot still in your view while the keys are down, so the head, not the mouse, does the last bit; a quick tap clicks where the dot was at the press, since the head moves as you hit the keys. The relay hides Meta from the desktop as soon as such a combination fires, because KWin takes Meta with a mouse button as a window move or resize, which swallowed the clicks. The dot shows all the time by default. With `POINTER_GAZE_DOT=moving` it shows only while the mouse moves it (`POINTER_GAZE_SHOW`), while a press is held, and as a pulse for each click; otherwise it's transparent, so the laser still lands on it. Looking more than `POINTER_GAZE_RETAKE` (5 degrees) away from it, with the mouse still, gives it back, so small eye movements around the pointer don't pull it off what you're doing. A mouse nudge before a click whose correction is within `POINTER_GAZE_NUDGE_MAX` (55 degrees, half of what the headset shows across) is sent to the gaze service as a lesson: you were looking at where you clicked when the mouse took over, so the nudge is the eye tracker's error there. Using it is what calibrates it. A one-dot check in a panel fixed to the headset tops that up when the headset goes on, when our tracker thinks it moved, and when a correction is past that limit (the tracker is far off, so a click there isn't trusted as a lesson), and the full calibration and the headset fit check run in the same panel, so everything a user does to calibrate happens in one place in the headset; the gaze probe, a fullscreen GTK app, is the development tool. The limit was 8 degrees, which dropped every correction while our tracker was 12 off. Its dots sit at known directions from the headset, so the panel needs no screen geometry. The quick check's dot takes the gaze once it has held still, so what the tracker says doesn't have to be close for the capture to work. The full calibration's and the five-dot check's dots wait for a click while you look at the dot (a left click or Meta+J), because a steady gaze isn't always on the dot, and take the gaze held still up to the click; a right click or MetLine truncated
|
||||
Gaze mode is experimental and off by default (`POINTER_GAZE=1`, the Gaze page of Frametop Input Settings, `gaze/ft-gazectl on`, or a mouse button or key combination mapped to Gaze pointer on/off). It's MAGIC pointing (Zhai, Morimoto and Ihde, 1999): the pointer goes where you look, and the mouse does the last bit. The gaze service (`gaze/ft-gazed`) sends the helper the corrected gaze at 90 Hz (from one eye while the tracker has lost the other), and while the gaze has the pointer, the cursor ray is that gaze from the eye. The pointer is aimed at the gaze each frame, not steered toward it, so nothing can pile up. An earlier try in the gaze probe steered the pointer with relative moves, and lost it when the pointer went idle or a controller had the laser. By default (`POINTER_GAZE_MOUSE_MOVE=held`, the Gaze page's Mouse movement switch) moving the mouse does nothing while the gaze has the pointer: it moves the pointer only while a button is held, as a correction. A bumped or drifting mouse can't pull the pointer off what you're looking at, and every mouse move is a correction, so the lessons aren't polluted by mouse moves to somewhere else (they used to be kept out by an 8 degree limit, which also dropped real corrections when the tracker was further off). With the gaze stale for a second, in a game, or with the headset off, the mouse moves the pointer as usual; with `free`, moving the mouse takes the pointer from the gaze. A left press while the gaze has the pointer isn't sent at once: the pointer stops where the gaze put it, you drag it onto what you meant with the button still down (panels only see it hover), and the release clicks there. Clicking at once clicked wherever the gaze was, often the wrong thing, before you could correct it. The drag is the correction. Snapping the pointer onto buttons and links is deferred: the session now starts an AT-SPI registry, but apps still need to expose useful accessibility trees (and may need restarting), and it makes Chromium and Electron apps use more CPU. A press held still for `POINTER_GAZE_HOLD` (0.5 s) becomes a real press, so drags still work: hold, then move. The right button works the same way, with the right click on the release, and pressing it while the left press is held back starts a drag where the pointer is, like Meta+J then Meta+K. That drag lasts while either button (or key) is held, so a second right press, or a second Meta+K, is free to pan and tilt the panel being dragged; with the keyboard, the head turns it. Outside games the pointer then stays: the mouse going idle doesn't release it. A moving controller still releases it, as without gaze. Gaze mode is a mouse and keyboard feature: Steam reads the Frame controllers itself, outside SteamVR's bindings, so controller clicks at the gaze kept knocking SteamVR out of laser mode (see `docs/gaze-controllers.md`). Keyboard clicks (Meta+J, Meta+K) hold the dot still in your view while the keys are down, so the head, not the mouse, does the last bit; a quick tap clicks where the dot was at the press, since the head moves as you hit the keys. The relay hides Meta from the desktop as soon as such a combination fires, because KWin takes Meta with a mouse button as a window move or resize, which swallowed the clicks. The dot shows all the time by default. With `POINTER_GAZE_DOT=moving` it shows only while the mouse moves it (`POINTER_GAZE_SHOW`), while a press is held, and as a pulse for each click; otherwise it's transparent, so the laser still lands on it. Looking more than `POINTER_GAZE_RETAKE` (5 degrees) away from it, with the mouse still, gives it back, so small eye movements around the pointer don't pull it off what you're doing. A mouse nudge before a click whose correction is within `POINTER_GAZE_NUDGE_MAX` (55 degrees, half of what the headset shows across) is sent to the gaze service as a lesson: you were looking at where you clicked when the mouse took over, so the nudge is the eye tracker's error there. Using it is what calibrates it. A one-dot check in a panel fixed to the headset tops that up when the headset goes on, when our tracker thinks it moved, and when a correction is past that limit (the tracker is far off, so a click there isn't trusted as a lesson), and the full calibration and the headset fit check run in the same panel, so everything a user does to calibrate happens in one place in the headset; the gaze probe, a fullscreen GTK app, is the development tool. The limit was 8 degrees, which dropped every correction while our tracker was 12 off. Its dots sit at known directions from the headset, so the panel needs no screen geometry. The quick check's dot takes the gaze once it has held still, so what the tracker says doesn't have to be close for the capture to work. The full calibration's and the five-dot check's dots wait for a click while you look at the dot (a left click or Meta+J), because a steady gaze isn't always on the dot, and take the gaze held still up to the click; a rightLine truncated
|
||||
|
||||
Replacing a loaded driver's files, as re-running the installer used to do, leaves SteamVR honoring the virtual controller's hand role but not its laser claim: the dashboard pointer stays unassigned until SteamVR restarts. The driver installer now leaves an unchanged driver in place.
|
||||
|
||||
@@ -159,6 +162,8 @@ The relay never waits on the pointer helper. Its socket to the helper used to bl
|
||||
|
||||
An ungrabbed keyboard reaches both sides at once. In VR, gamescope reads every input device itself (the SteamOS build's `InputStealer`, libinput with udev hotplug, so new devices too) and types into its focused app, and ft-screens types the same keys into the desktop. So Space in the desktop also paused Spotify on the dashboard. Typing now follows the last click. ft-screens sees clicks on its own screens, from the mouse or a controller. A click anywhere else is only visible for the mouse: overlay apps get SteamVR's `OverlayFocusChanged` (which panel the laser is on) but no controller button events, so the pointer helper reports the panel under the dot on each left press. ft-screens tells the relay where typing goes every second, from an unbound socket so the relay's replies can't loop back into its control socket, and the relay grabs pass-through keyboards while it's the desktop. A grab waits until the keyboard has no key down, so no key stays held on either side, and the relay lets go if ft-screens stops reporting. A program that reads every keyboard for a hotkey (a dictation tool, say) loses a grabbed keyboard. Repeating the keys on another input device doesn't work: gamescope reads that device too, whether it's the relay's virtual keyboard or one created later, and every Space, typed or dictated, paused Spotify again. So with `SHARE_KEYS=1` the relay sends a grabbed keyboard's keys to `@frametop_keys` as datagrams (`key <code> <value> <device name>`). It's off by default, because the relay can't tell who is listening: abstract sockets have no permissions, and any local process that binds the name first gets every key typed into the desktop, passwords included. A listener should accept only its own user (`SO_PASSCRED`) and skip any keyboard of its own that the relay grabs too.
|
||||
|
||||
A mouse button that pointer mode passes through as a key (a side button for Back) is a pointer button to ft-screens, not a key, so it goes to the screen the pointer is on rather than where typing goes. As a key, its release was lost whenever typing moved while it was held (the dashboard closing, a click on another panel, a pause), and wlroots counts presses per button: from then on that button, the lasers' left click included, did nothing in the desktop, and KWin kept it held. So ft-screens tracks the relay's buttons itself, lets a release through whenever it took the press, and releases them when the pointer leaves the screens, its screen hides, or Frametop pauses.
|
||||
|
||||
Volume keys must never reach gamescope. With the openvr backend, gamescope sends volume up and down to Steam by moving keyboard focus to Steam for the key and then back to the previously focused surface. When nothing had focus, the one it moves back to is null, and wlroots aborts on a null focus surface (`wlr_seat_keyboard_notify_enter: Assertion 'surface' failed`), which ends the whole VR session. Keyboard focus is often empty while you work in VR, so one press of the headset's volume button could take everything down. gamescope reads the headset's buttons and every keyboard itself (`InputStealer`), as do SteamVR's processes, so the relay has to stop volume keys at the device. Grabbing `gpio-keys` would also take the headset's click button, so the relay remaps the volume entries in each device's keymap (`EVIOCSKEYCODE`) and handles the stand-in codes itself. That fix covers every device at once, including keyboards that aren't grabbed.
|
||||
|
||||
Frametop's keyboard opens by itself for a text field on the desktop. The apps run inside the nested KWin, so only KWin knows when a text field has focus, and the way it tells anyone is its input method protocol (`zwp_input_method_v1`): KWin starts one input method program and activates it whenever the focused app turns on text input. `input/ft-textinput` is that program, speaking the Wayland wire protocol directly so it needs nothing but Python on the host. It only reports focus. The gamescope session puts `QT_IM_MODULE=xim` and `GTK_IM_MODULE=xim` in the systemd user environment; with those, Qt and GTK apps use X input methods and never turn on Wayland text input, so the session script drops them.
|
||||
@@ -169,10 +174,22 @@ The Frame controllers can be mapped like mouse buttons, but they aren't input de
|
||||
|
||||
## The desktop session
|
||||
|
||||
The session is modeled on SteamOS's `steamos-nested-desktop` and runs beside it. It has its own runtime directory, config (`~/.config/frametop`), and state, so it never disturbs the stock desktop's layout or panels. It runs on a private D-Bus from `dbus-run-session`, which has two consequences. KDE only launches apps in systemd scopes when systemd is on the session bus, so everything started in the desktop lands in its systemd unit, and stopping the unit would kill all of it; `session/keep-apps.sh` moves those programs out first. And tools that need the real user bus, like podman and `distrobox-host-exec`, have to be pointed at it explicitly.
|
||||
The session is modeled on SteamOS's `steamos-nested-desktop` and runs beside it. It has its own runtime directory, config (`~/.config/frametop`), and state, so it never disturbs the stock desktop's layout or panels. It runs on a private D-Bus from `dbus-run-session`, which has two consequences. KDE only launches apps in systemd scopes when systemd is on the session bus, so everything started in the desktop lands in its systemd unit, and stopping the unit would kill all of it; `session/keep-apps.sh` moves those programs out first. And tools that need the real user bus, like podman and `distrobox-host-exec`, have to be pointed at it explicitly. Its own config folder also hides SteamVR's path registry (`~/.config/openvr/openvrpaths.vrpath`) from everything started in it: OpenVR programs there fail with `VRInitError_Init_PathRegistryNotFound`, and `vrpathreg adddriver` writes a new registry under `~/.config/frametop/openvr` that has no SteamVR in it and that SteamVR never reads. So Frametop's scripts run SteamVR's tools with `XDG_CONFIG_HOME=~/.config`.
|
||||
|
||||
The VR launcher starts the session from the Steam client, and the client's environment came along: `LD_LIBRARY_PATH` pointing at Steam's own runtime, whose `libavcodec` has no H.264 decoder, so VLC in the desktop couldn't play most videos, plus the client's overlay and launch settings. The session script drops the client's variables before it starts anything. SteamOS's global Mesa settings (`/usr/share/deckard/mesavars.sh`) stay, and the gamescope session's Vulkan layer (`ENABLE_GAMESCOPE_WSI`) is only kept for the gamescope backend.
|
||||
|
||||
### Nested accessibility
|
||||
|
||||
The session drops an inherited `AT_SPI_BUS_ADDRESS`, so apps cannot accidentally use the host desktop's registry. It autostarts `session/ft-atspi` in Plasma phase 2, after KWin has set the nested display environment. The helper gets the live accessibility address from `org.a11y.Bus` on the private session bus, preserves any existing registry owner, updates the accessibility bus's activation environment, and tries `StartServiceByName` first.
|
||||
|
||||
On SteamOS 0.3.0 with at-spi2-core 2.52.0, the native launcher can choose dbus-broker because its process belongs to a systemd user unit. Registry activation then fails: this desktop's private session bus does not have a systemd activation manager. In that case the helper starts only `at-spi2-registryd` on the already-existing accessibility bus. The registry refuses duplicate ownership. Unlike native activation's `--use-gnome-session`, the fallback does not try to register with GNOME's session manager; that flag did not explain the observed native activation failure.
|
||||
|
||||
The fallback registry does not exit merely when its bus disconnects in the isolated SteamOS test. Its small watcher checks both private buses every 5 seconds, and terminates and reaps only the child it started when either bus disappears or the watcher is stopped. Each check runs `gdbus` twice; once a second, that cost about 1% of a core. `keep-apps.sh` keeps the watcher in the desktop unit when `desktops.sh start` runs the desktop as `frametop-desktop`. Started from the VR launcher, the desktop runs in steam.service, which doesn't stop with it, so there the watcher is the only thing that stops the registry. There is no second accessibility bus, global systemd environment update, process-name kill, or host registry replacement. Missing accessibility files or bus errors are nonfatal; the desktop still starts. Toolkit-specific accessibility opt-ins and pointer snapping are separate work.
|
||||
|
||||
Run the isolated checks on the host with `/usr/bin/python3 session/test/test_accessibility.py`. They use private D-Bus buses, Xvfb and a GTK3 app, never the production display or input. Native activation uses a small `org.a11y.Bus` test provider pointing to a real private dbus-daemon with the installed registry service; the SteamOS fallback uses the installed bus launcher and broker. The tests check real app-tree discovery, existing owners, concurrent starts, session stop/restart, and teardown. They require test-only PyGObject (Gio and GTK3), Xvfb, and at-spi2-core; the runtime helper uses Python's standard library and the existing host `gdbus`. Actual Plasma autostart and VR desktop restart still require an approved hardware test.
|
||||
|
||||
### Other session behavior
|
||||
|
||||
Steam, not systemd, suspends the Frame: after `system_idle_suspend_ac_sec` (an hour by default) without input on AC power, it logs `Switching to power state: k_ESystemPowerState_Sleep` and suspends, even while charging. It's a Steam setting (Settings → Power → When Plugged In and Idle → Sleep after), which the Stay awake while plugged in switch in Frametop Display Settings sets to Never. SteamVR's standby, which turns the displays off when the headset comes off, is separate; see below.
|
||||
|
||||
Flatpak apps need `XDG_DATA_DIRS` to include Flatpak's exports, or Plasma opens Discover instead of launching them, so the session sources `/etc/profile.d/flatpak.sh`.
|
||||
@@ -185,6 +202,8 @@ KWin renders with OpenGL through zink on Turnip, Vulkan on the same GPU vrcompos
|
||||
|
||||
The nested session also runs the system's XDG autostart entries, being a KDE session. Discover's update notifier started `plasma-discover --mode update` in it (520 to 620 MB resident and about 9% of a core, plus `flatpak-system-helper` and AppStream downloads), and IBus started a daemon, the kimpanel panel and its GTK extension that nothing can use: KWin hands text input to the one input method it starts (`ft-textinput`), and the session drops `QT_IM_MODULE`, `GTK_IM_MODULE` and `XMODIFIERS`. The session hides both for this desktop only, with `Hidden=true` copies in its own autostart folder. The geoclue demo agent stays: it's what answers apps' location requests to Geoclue outside GNOME, and it costs nothing while idle. Orca's entry only starts in GNOME-family desktops.
|
||||
|
||||
Plasma 6.2.5 keeps each panel on a screen number (`lastScreen` in `plasma-org.kde.plasma.desktop-appletsrc`), and the numbers rank the enabled outputs by priority, so 0 is the primary screen. A panel whose number is past the screen count gets no view, and Plasma never moves it: the remap it runs at every start only moves a panel whose number has no desktop, and this desktop keeps a desktop for every output it has seen, spares included. So the taskbar was lost when the number of screens went down, and once it was found saved on a spare output, number 8 of a desktop with three screens ([#18](https://github.com/DeeJanuz/frametop/issues/18)). Before Plasma starts, the session runs `session/fix-panels.py`, which moves any panel numbered past the screen count, with its system tray's containment, to screen 0, keeping its widgets and settings. A panel stays put when screen 0 already has one on that edge, and comes back by itself if the screens do. Before each repair the file is backed up to `<file>.ft-bak.last`. `<file>.ft-bak` keeps it as it was before the first repair and is never overwritten. The repair writes over a moved panel's old screen number, so the backups are the only record of it, and `.ft-bak.last` also keeps everything changed since the first repair. Plasma's scripting can't do this while it runs (`panel.screen` is read-only in 6.2.5), so a lost taskbar comes back at the desktop's next start. `scripts/doctor.sh` and `scripts/report.sh` list the panels and their screens.
|
||||
|
||||
Remote desktop is a chain (krdp, then FreeRDP inside Xvnc) because nothing on SteamOS serves KWin over VNC directly. Kept connected all the time, it cost about a core with nobody watching: krdpserver 55 to 78% (it encodes H.264 in software with openh264: VA-API finds no driver for the Frame's GPU in the container), FreeRDP 16 to 27%, Xvnc 6 to 11%, and the bridge's layout check every 5 seconds another 4%. krdp creates its screencast session per RDP connection and drops it when the connection closes (`SessionController::onNewConnection` in krdp 6.7), so an idle krdpserver costs nothing and can stay up; only the RDP connection has to go. The bridge connects FreeRDP when a VNC client appears and disconnects 45 seconds after the last one leaves. Xvnc has no hook for its clients, so the bridge counts established connections to its port with `ss`, woken early by Xvnc's log output; looking with `ss` once a second cost about 0.9% of a core in bash, against about 0.1% this way. `Xvnc -inetd` from a systemd socket would start a server per connection and lose sharing between viewers. The layout check (`ft-layout remote-view`, which scans `/proc` for plasmashell and runs `kscreen-doctor -j`) now runs only while FreeRDP runs, and then only after `kwinoutputconfig.json` or `frametop-layout.json` changes, with one check a minute in case a change touched neither.
|
||||
|
||||
Program names stay within 15 characters, because Linux truncates process names there and the scripts find programs with `pgrep -x` and `pkill -x`. That's why the prefix is `ft-`.
|
||||
@@ -212,7 +231,7 @@ Hiding the screens during a game kept them out of view, but Frametop kept using
|
||||
|
||||
On the Frame, SteamVR is part of the OS image (`/opt/steamvr`, the `deckard-steamvr-rel` package), next to KWin, gamescope, and the kernel, so every SteamOS update can bring a new SteamVR too. Frametop survives updates: it lives in the home folder and the `dev` container, the Bluetooth fixes are in `/etc`, which SteamOS keeps across updates, and nothing goes into `/usr`. What an update can break is what Frametop uses from the image. The public OpenVR API is versioned and stays put. The rest is less certain: `IVRIPCResourceManagerClient`, which is newer than the header SteamVR ships; the text `vrcmd --overlays` prints; the eye tracker's shared memory layout; XRService's camera buffers; KWin's nested backend; and behavior Frametop works around, such as the SteamVR Settings page that `ComputeOverlayIntersection` can't find or the scale KWin's nested backend doesn't undo.
|
||||
|
||||
`scripts/update-check.py`, which `scripts/doctor.sh` runs, checks what it can directly: that SteamVR still serves every OpenVR interface version the installed programs were built against (read from the binaries), that `vrcmd`'s format still parses, that the eye tracker's sample timestamp is still at the offset ft-gaze reads, and the host files, services, sockets, and driver registration. Behavior can't be checked without someone in the headset, so it records the versions of the packages that matter once things work (`--mark-good`), and after an update names what changed and what to try by hand.
|
||||
`scripts/update-check.py`, which `scripts/doctor.sh` runs, checks what it can directly: that SteamVR still serves every OpenVR interface version the installed programs were built against (read from the binaries), that `vrcmd`'s format still parses, that the eye tracker's shared memory still has a layout ft-gaze knows (stable's, or the 0.4.x beta's, with every field from the timestamp on 5 bytes later), by the same test ft-gaze uses to pick one, and the host files, services, sockets, and driver registration. Behavior can't be checked without someone in the headset, so it records the versions of the packages that matter once things work (`--mark-good`), and after an update names what changed and what to try by hand.
|
||||
|
||||
## Approaches we dropped
|
||||
|
||||
@@ -224,6 +243,5 @@ On the Frame, SteamVR is part of the OS image (`/opt/steamvr`, the `deckard-stea
|
||||
|
||||
- A controller button that shows the screens during a game. Games own the controllers, so this needs SteamVR input actions for ft-screens.
|
||||
- Drawing KWin's cursor on the screens.
|
||||
- Plasma can lose its panels when the number of screens goes down, because they're saved against a screen that no longer exists. Removing `plasma-org.kde.plasma.desktop-appletsrc` and `plasmashellrc` from `~/.config/frametop` brings the default panels back.
|
||||
- Frame pacing and GPU cost with several busy screens haven't been measured.
|
||||
- Real standby on a stand, with rendering and tracking paused, not just the backlight off. SteamVR has no call for it, and its activity level follows the proximity sensor.
|
||||
@@ -85,7 +85,7 @@ ft-screens creates a `screen` for each toplevel in the order they appear, and in
|
||||
runs commands ◀─ long poll ─ spare outputs (kscreen-doctor) ◀─ @frametop_float ─ lasers, catcher
|
||||
```
|
||||
|
||||
- **KWin script `frametop-float`** (`float/frametop-float.js`). ft-floatd loads it into the desktop's KWin over D-Bus (`org.kde.kwin.Scripting`). A script keeps working across KWin updates. A C++ effect would have to match the host's exact KWin build, and the build container is Fedora, not SteamOS. The script watches windows (`windowAdded`/`windowRemoved`, `frameGeometryChanged`, `outputChanged`, `interactiveMoveResizeStarted`/`Finished`, `fullScreenChanged`, `maximizedChanged`, `minimizedChanged`, `keepBelowChanged`, `windowActivated`) and the outputs (`screensChanged`). It runs commands: move a window to an output, set its geometry, put it on all virtual desktops, and restore it. It adds "Float in VR" ("Back to Desktop" on a floating window) to the window menu (`registerUserActionsMenu`). It registers no shortcut: the float key belongs to the input relay. KWin scripts can call D-Bus but can't serve it, so commands come back through a long poll. The script calls ft-floatd's `NextCommand`, which answers when a command is ready, and then the script calls it again. It also keeps KWin's placement memory from moving windows (see design.md).
|
||||
- **KWin script `frametop-float`** (`float/frametop-float.js`). ft-floatd loads it into the desktop's KWin over D-Bus (`org.kde.kwin.Scripting`). A script keeps working across KWin updates. A C++ effect would have to match the host's exact KWin build, and the build container is Fedora, not SteamOS. The script watches windows (`windowAdded`/`windowRemoved`, `frameGeometryChanged`, `outputChanged`, `interactiveMoveResizeStarted`/`Finished`, `fullScreenChanged`, `maximizedChanged`, `minimizedChanged`, `keepBelowChanged`, `windowActivated`) and the outputs (`screensChanged`). It runs commands: move a window to an output, set its geometry, put it on all virtual desktops, and restore it. It adds "Float in VR" ("Back to Desktop" on a floating window) to the window menu (`registerUserActionsMenu`). It registers no shortcut: the float key belongs to the input relay. KWin scripts can call D-Bus but can't serve it, so commands come back through a long poll. The script calls ft-floatd's `NextCommand`, which answers when a command is ready, and then the script calls it again, or after 30 seconds without an answer. It also keeps KWin's placement memory from moving windows (see design.md).
|
||||
- **ft-floatd** (`float/ft-floatd`, Python). The host has dbus-python and PyGObject. It runs inside the desktop's Plasma session, started from its autostart. It owns `org.frametop.Float` on the session's private bus, and it keeps the table of which window is on which output and panel. It enables and disables spare outputs and sets their scale and position with `kscreen-doctor`, and their size through ft-screens. It tells ft-screens where each floating window goes and tells the script which window goes where. It launches apps floating, opens profiles' apps, and remembers each app's placement and scale, keyed by desktop file name. Commands come in on `@frametop_float`, from `ft-float`, the input relay, and ft-screens.
|
||||
- **ft-screens.** A spare output's panel is a floating window's. Floating panels get the same bar, curve, roll, resize tab, and wrist and head pins as screens, plus dock and close buttons left of the bar. Other parts: the catcher, popup and dialog overlays, and carrying a panel during a KWin move. `MAX_SCREENS` (screens and spares together) is 24. Commands arrive on `@ft_screens`. Events go out to `@frametop_float` from an unbound socket, the same way ft-screens talks to the input relay.
|
||||
- **Session script.** Adds `FLOAT_SLOTS` to KWin's output count, starts ft-floatd from the desktop's autostart, installs Frametop's window decoration and chooses it in the session's `kwinrc`, and writes the Launch as Standalone copies of the apps' desktop files.
|
||||
|
||||
+3
-1
@@ -17,9 +17,11 @@ The input relay takes the volume keys from every device that has them, so gamesc
|
||||
ft-screens drops keys while no screen has focus or the SteamVR dashboard is open, but always lets through the release of a key the desktop saw pressed, so a modifier held as the dashboard opens doesn't stay down.
|
||||
|
||||
- **A key whose release never arrives stays held in the desktop until the relay clears it, within about a second.** KWin repeats held keys itself, so a stuck letter repeats and a stuck modifier changes every later key (Ctrl+Alt held turns T into Konsole). The relay remembers which keys it told the desktop went down, and once a second it releases any that no keyboard holds (`reconcile_desktop_keys`, which asks the kernel with `EVIOCGKEY`). Pressing and releasing the key again also clears it.
|
||||
- **A keyboard that disconnects mid-press, or a relay restart with a key down, is how it happens.** The once-a-second check catches the first. A relay that starts doesn't know what an earlier one left down, so it releases the modifiers on the desktop; another key left down that way stays until it's pressed and released again.
|
||||
- **A keyboard that disconnects mid-press, or a relay restart with a key down, is how it happens.** The once-a-second check catches the first. A relay that starts doesn't know what an earlier one left down, so it releases the modifiers and mouse buttons on the desktop; another key left down that way stays until it's pressed and released again.
|
||||
- **To see where a key went,** run `scripts/keys-report.py` and reproduce the problem while it records. It logs the modifiers, Tab, and Esc (no other keys) as the relay reads them and as its virtual keyboard sends them on, with the device roles and grabs, which programs have each keyboard open, and the relay's and desktop's logs.
|
||||
- **Switching where typing goes waits for keys to come up.** The relay changes a keyboard's grab only while none of its keys are down, so a press and its release go to the same side. A key held for a long time delays the switch until it's let go.
|
||||
- **Mouse buttons passed through as keys follow the pointer, not typing.** In pointer mode, a mouse button set to Pass through as key (a side button for Back) goes to the screen under the pointer, only while there is one and Frametop isn't paused. Its release always goes through, and ft-screens releases it itself when the pointer leaves the screens, its screen hides or closes, or Frametop pauses; the real release that comes later is dropped. So a drag held with it ends as the laser leaves a screen, unlike a laser's own click, which KWin keeps until it comes up. wlroots counts presses per button, so a release lost on the way would leave that button dead in the desktop, the lasers' clicks included, until ft-screens restarts. The button also goes to the virtual mouse, which gamescope reads, so the app Steam has focused may see it too.
|
||||
- **Media keys reach both sides.** A keyboard's media keys come from its Consumer Control node, which has volume keys too, so the relay remaps that node rather than grabbing it. Its other keys reach gamescope as well as the desktop, even while typing goes to the desktop and the keyboard itself is grabbed: Play/Pause on the desktop can pause something in Steam too. The headset's own buttons don't go to the desktop.
|
||||
|
||||
## Typing and grabbed keyboards
|
||||
|
||||
|
||||
+3
-1
@@ -6,7 +6,8 @@ A profile is a named layout that also opens apps. It holds:
|
||||
|
||||
- where each screen goes, with its size in metres, curve, roll, and pin (what a named layout held before profiles);
|
||||
- which screens show and which are hidden;
|
||||
- the apps, one entry per window: on a screen at a place and size, or floating at a pose, size, and scale.
|
||||
- the apps, one entry per window: on a screen at a place and size, or floating at a pose, size, and scale;
|
||||
- the remote displays connected when it was saved, each with its place and whether it's hidden ([remote-displays.md](remote-displays.md)). Opening the profile connects them, if their computer answers, and puts them back. Like its apps, it leaves other displays connected.
|
||||
|
||||
So a "Work" profile can put three screens around you with a browser, two terminals, and an editor on them, and a "Couch" profile can hide every screen and float one video player in front of you.
|
||||
|
||||
@@ -50,5 +51,6 @@ So a "Work" profile can put three screens around you with a browser, two termina
|
||||
- **Apply** (`ft-layout use NAME`, Open profile in Display Settings). ft-layout makes the profile's hidden screens the screens' own setting, arranges the screens (which hides and shows them: ft-screens' `conceal` and `reveal`), then has ft-floatd open the apps (`profile NAME`; ft-floatd reads the windows from the layout file). If the screens can't be arranged, for example with the headset off and no head pose, the apps still open: the screens stay where they are, the profile's hidden screens still hide, and floating windows go relative to the screens wherever they are. ft-floatd goes through the entries app by app. It claims windows of that app already open (oldest first, each claimed once), and moves each to its entry's place: onto its screen at its rect (or maximized), or floating at its pose. For the entries left over, it launches the app once (`ft-float launch`, the same path as Launch as Standalone) and waits up to 30 seconds for its first window. Each window that shows up goes to the next entry's place. Once the first window has been up for 3 seconds (time for an app that restores its own windows to show them), ft-floatd launches the app again for each entry still waiting, and waits up to 30 seconds more. New windows are matched to the launch by process (or a child of it), or by desktop file name (found the same way as at capture): single-instance and D-Bus-activated apps open their windows from a process that was already running.
|
||||
- **Default at start.** The session script runs `ft-layout start --wait 90`. That opens the profile in `FT_PROFILE` or `default_profile` (screens, then the apps once ft-floatd is up), or runs `apply --wait` if there's none. Start in profile on the Layout & profiles page sets `default_profile` (`ft-layout default NAME|none`). Plasma's session restore is turned off in the session (`ksmserverrc`: `loginMode=emptySession`).
|
||||
- **Launcher entries.** Each profile gets `~/.local/share/applications/frametop-profile-<name>.desktop` ("Frametop: Work"), written when it's saved and removed when it's deleted. They show in SteamVR's Launch a program list, the Application Launcher, and KRunner. Running one (`ft-layout open NAME`) switches to that profile if the desktop runs. Otherwise it starts the desktop with `FT_PROFILE` set (`systemd-run`, as `desktops.sh start` does), which overrides `default_profile` for that start. That needs SteamVR to be running.
|
||||
- **The quick reset.** Meta+Shift+R, the reset button on a screen's bar, the Reset Screen Layout menu entry, and a button mapped to Reset desktop screen layout run `ft-layout reset`: with a profile in use (`active`, and the custom arrangement), that's `use` on it again, so everything goes back as the profile has it. Without one, it's `apply`.
|
||||
- **The action.** `profile:NAME` in the input relay (it runs `ft-layout use NAME`) for key combinations, mouse buttons, and controller buttons, with or without pointer mode. Input Settings lists one "Open profile NAME" action per profile.
|
||||
- **Display Settings.** On the Layout & profiles page, the arrangement list has the profiles, which can be renamed and deleted. Open profile and Save as profile… are the page's actions. A profile's apps are listed with where each goes and a button to leave one out, plus which screens it hides. Start in profile picks the one the desktop starts with. The Visibility tab's Screens shown switches hide screens one at a time.
|
||||
+28
-11
@@ -6,18 +6,22 @@ How each part of Frametop works, where its settings live, and the commands for r
|
||||
|
||||
From the headset, open Launch a program → Desktop. The installer replaces that launcher entry with Frametop's (`~/.local/share/applications/deckard-nested-desktop.desktop`), and `desktops.sh uninstall` gives the stock single-screen desktop back.
|
||||
|
||||
The installer also adds Native Desktop to the same list: a copy of SteamOS's entry for its own desktop (`~/.local/share/applications/native-deckard-nested-desktop.desktop`), skipped when SteamOS has no such entry. The copy is made at install time, so `scripts/update-check.py` warns when SteamOS's entry changes, and `desktops.sh install` refreshes it. In Native Desktop, typing goes to Steam's side, so the input relay doesn't grab keyboards there, and a Meta tap runs Frametop's Meta action (by default, the Steam menu) as well as opening Plasma's launcher.
|
||||
|
||||
From a terminal, on the Frame or from a PC over SSH:
|
||||
|
||||
```
|
||||
desktops.sh install # the launcher's Desktop entry starts Frametop
|
||||
desktops.sh uninstall # back to the stock SteamOS desktop
|
||||
desktops.sh install # the launcher's Desktop entry starts Frametop; Native Desktop is the stock one
|
||||
desktops.sh uninstall # back to the stock SteamOS desktop, as Desktop
|
||||
desktops.sh start | stop | restart | status | log [lines]
|
||||
```
|
||||
|
||||
`session/frametop-session.sh` runs the desktop. It starts ft-screens in the `dev` container (log: `/tmp/frametop-screens.log`), then KWin and Plasma on the host inside it. Only one desktop runs at a time. `desktops.sh start` runs it in its own systemd unit, `frametop-desktop`. It keeps its Plasma config in `~/.config/frametop`, separate from the stock desktop's.
|
||||
`session/frametop-session.sh` runs the desktop. It starts ft-screens in the `dev` container (log: `/tmp/frametop-screens.log`), then KWin and Plasma on the host inside it. Only one Frametop desktop runs at a time. Its check doesn't look for Native Desktop, and running both at once is untested. `desktops.sh start` runs it in its own systemd unit, `frametop-desktop`. It keeps its Plasma config in `~/.config/frametop`, separate from the stock desktop's.
|
||||
|
||||
When the VR launcher starts the desktop, it inherits the Steam client's environment. The session script drops the client's runtime from it (`LD_LIBRARY_PATH`, the `STEAM_*` settings, and the Steam overlay's Vulkan layer), so apps in the desktop use the system's libraries, including its video codecs, just as they would after a normal login.
|
||||
|
||||
The nested session also starts an AT-SPI accessibility registry through `session/ft-atspi` in Plasma's autostart. It discovers the bus from this session, ignores an inherited host accessibility address, and leaves an existing registry alone. Accessibility errors do not stop the desktop. This supplies the registry infrastructure for apps that expose AT-SPI trees; it does not enable gaze snapping or force Chromium/Electron accessibility. After an approved desktop restart, an AT-SPI-aware app should be visible on the nested bus. See [design.md](design.md#nested-accessibility) for native activation, fallback lifecycle, and the isolated test command.
|
||||
|
||||
KWin's blur and background contrast effects and its animations are off in this desktop, because KWin draws on the headset's GPU, which SteamVR needs. The session script turns them off once, the first time it starts (it leaves a setting you already have alone, and marks it done in `~/.config/frametop/frametoprc`), so turning them back on sticks. In the Frametop desktop, System Settings → Window Management → Desktop Effects has Blur and Background Contrast, and General Behavior has Animation speed. Or from a terminal, then restart the desktop:
|
||||
|
||||
```
|
||||
@@ -44,7 +48,7 @@ Every screen is an overlay named `frametop.screen.N` with five controls:
|
||||
- `.curve` bends the screen into a cylinder around you, using your current distance as the radius, or makes it flat again.
|
||||
- `.roll` rolls the screen when you drag it sideways, like a knob. It snaps level within 2.5°, and scrolling on it turns 5° per notch.
|
||||
- `.resize`, the tab on the bottom right corner, sets the width. Screens go down to 15 cm wide.
|
||||
- `.reset`, left of the bar, puts every screen back in its layout around where you are now, like Meta+Shift+R (`ft-layout apply`).
|
||||
- `.reset`, left of the bar, is the quick reset, like Meta+Shift+R (`ft-layout reset`): with a profile in use it opens that profile again, as Open profile does, and otherwise it puts every screen back in its layout around where you are now.
|
||||
|
||||
The controls are sized from both the screen's width and its distance from you, follow the surface of a curved screen, and stay invisible until a laser or the 3D mouse's cursor lands on one or comes within about 1.5 times a button's size of it. While invisible they're still there, fully transparent, so SteamVR's laser can find them. They're translucent until a laser is on them, like SteamVR's own window controls.
|
||||
|
||||
@@ -64,7 +68,7 @@ In the last three modes the hotkey shows the screens anyway. A screen can also b
|
||||
- During VR games, the Always mode hides the screens unless the dashboard is open (the default), or leaves them up.
|
||||
- Controllers on the screens. Visible screens can keep SteamVR's laser mouse on, so controllers work them with the dashboard closed, but that also takes the controllers away from a game. By default this is off while a VR game runs, and the 3D mouse or the dashboard works the screens. Pointing a controller at a screen, a floating window, or the keyboard still turns its laser on, like SteamVR's own floating windows, and pointing away gives the game the controllers back. The other choices are always on, or only with the dashboard open, which also suits flatscreen games since they aren't scene apps.
|
||||
|
||||
Input from the lasers reaches KWin through ft-screens' own seat. Keys come from the input relay, from pass-through keyboards and any key a pointer device passes through. Typing follows your last click: after a click on a screen it goes to the desktop, even with the SteamVR dashboard open, and after a mouse click on any other panel (the dashboard, Steam, an app like Spotify) it goes there instead. While it goes to the desktop, the relay grabs pass-through keyboards so gamescope, which reads every keyboard itself, doesn't type them into the Steam app too. A program that watches every keyboard for a hotkey loses a grabbed one; with `SHARE_KEYS=1` in `~/.config/frametop.conf`, their keys also go to `@frametop_keys` for it. That's off by default, since any local process that binds the name first would get everything typed into the desktop. Hidden screens don't take typing.
|
||||
Input from the lasers reaches KWin through ft-screens' own seat. Keys come from the input relay: from pass-through keyboards, a keyboard's media keys (its Consumer Control node), and any key a pointer device passes through. A mouse button passed through as a key in pointer mode (a side button for Back) goes to the screen the pointer is on instead, wherever typing goes, and only while the pointer is on a screen that shows and Frametop isn't paused. Its release always goes through, and ft-screens releases it itself when the pointer leaves the screens, its screen hides, or Frametop pauses (`scripts/test-relay-buttons.sh` checks this offline). Typing follows your last click: after a click on a screen it goes to the desktop, even with the SteamVR dashboard open, and after a mouse click on any other panel (the dashboard, Steam, an app like Spotify) it goes there instead. While it goes to the desktop, the relay grabs pass-through keyboards so gamescope, which reads every keyboard itself, doesn't type them into the Steam app too. A program that watches every keyboard for a hotkey loses a grabbed one; with `SHARE_KEYS=1` in `~/.config/frametop.conf`, their keys also go to `@frametop_keys` for it. That's off by default, since any local process that binds the name first would get everything typed into the desktop. Hidden screens don't take typing.
|
||||
|
||||
Frametop's keyboard opens by itself when a text field on the desktop gets focus, and stays open until its Close key, a layout reset, or a mapped button closes it (or, with Keep it open off in Frametop Input Settings, until the text field loses focus). While the Steam menu (the dashboard) or Steam's own keyboard is up, it steps aside, and it comes back where it was when they're gone; one asked for meanwhile appears then. In the "only with the dashboard" visibility mode, the dashboard doesn't count. It doesn't open without a head pose (the headset in standby). It's a panel of keys (a US laptop layout, with Esc where Caps Lock would be, arrows, and a Close key) that ft-screens shows 0.7 m in front of you and below your eyes, facing you. It stays where it opened, and its grab bar (the pill along the top) moves it like a screen's. Type on it with a controller's laser or the 3D mouse. Shift, Ctrl and Alt latch for the next key, and a held key repeats. KWin starts `input/ft-textinput` as the desktop's input method, and KWin activates it whenever the focused app turns on text input for a field. It tells the relay (`textfield 1` or `0`), the relay decides by the Keyboard setting in Frametop Input Settings, and ft-screens opens the keyboard for the screen that has keyboard focus (`vrkeyboard show`, `hide`, or `toggle` from a mapped button). Its keys reach the focused screen as key presses, so it works in every app, but only apps that use Wayland text input (Qt, GTK, Firefox) open it by themselves; Chromium, Electron and X11 apps need the button. The session drops the `QT_IM_MODULE=xim` and `GTK_IM_MODULE=xim` that the gamescope session sets, or Qt and GTK apps wouldn't use Wayland text input either.
|
||||
|
||||
@@ -99,7 +103,7 @@ The relay also owns the volume keys, on every device that has them, the headset'
|
||||
desktops.sh relay install # enable it (starts with the next reboot or SteamVR start)
|
||||
desktops.sh relay status | log | uninstall
|
||||
input/input-relay.py --no-grab # try it without taking devices from SteamVR
|
||||
input/test/keys-test.py # key combinations and modifier taps, against fake devices (safe next to the live relay)
|
||||
input/test/keys-test.py # key combinations, modifier taps, and what reaches the desktop, against fake devices (safe next to the live relay)
|
||||
steam/ft-steam menu # what Open Steam menu does; ft-steam check: Steam's UI still has the calls
|
||||
```
|
||||
|
||||
@@ -144,7 +148,7 @@ Device rules are saved in `~/.config/frametop-input.json`. `input-settings/insta
|
||||
|
||||
## Frametop Display Settings and ft-layout
|
||||
|
||||
When the desktop starts, its screens arrange themselves around where you're facing. You can move them by hand at any time and put them back with Meta+Shift+R, the reset button left of any screen's bar, the Reset Screen Layout menu entry, Arrange now in the app, or a mouse button mapped to Reset desktop screen layout.
|
||||
When the desktop starts, its screens arrange themselves around where you're facing. You can move them by hand at any time and put them back with Meta+Shift+R, the reset button left of any screen's bar, the Reset Screen Layout menu entry, or a button mapped to Reset desktop screen layout. With a profile in use, these open it again, the same as Open profile: its screens, hidden screens, remote displays and apps. Arrange now in the app arranges the screens (and the profile's remote displays) without reopening its apps or hiding its hidden screens again.
|
||||
|
||||
The desktop's own screen arrangement follows where the screens are around you, whatever their numbers: a screen you see to the left of another is to its left in Plasma too, so the pointer and dragged windows cross straight to it. Screens one above the other stack, and screens pinned to a wrist or your head come last. It's updated at startup, after arranging or saving the layout, and half a second after you let go of a screen you moved. With the headset off there's no head pose to go by, and the arrangement stays as it was.
|
||||
|
||||
@@ -173,7 +177,19 @@ layout/ft-layout hide N|all # hide a screen on its own, whatever the visibility
|
||||
display-settings/install.sh # menu entries and the Meta+Shift+R and Meta+Shift+H shortcuts
|
||||
```
|
||||
|
||||
The layout is stored relative to your head when it's applied. `/tmp/frametop-layout.log` has the run from the last desktop start.
|
||||
The layout is stored relative to your head when it's applied. `/run/user/<uid>/frametop-layout.log`, in the host's runtime directory (not the nested desktop's `/run/user/<uid>/frametop`), has the run from the last desktop start and ft-screens' layout runs after it.
|
||||
|
||||
## Frametop Remote Displays
|
||||
|
||||
Other computers' monitors as Frametop screens (ft-screens only), streamed from Vibepollo with Moonlight's protocol. Frametop Remote Displays (`remote-displays/`, also opened by the Remote displays button on Display Settings' Screens page) finds Vibepollo computers on the network, signs in to one with its Web UI login (it keeps a narrow API token, not the password, and pins the host's certificate), shows whether it answers, and adds its displays: its monitors, or a virtual one at any size. A computer with a Steam Link dongle on the Frame's hotspot streams over it (Connection: auto, network or dongle only); the others use the network. Each display has a Connected switch (and the host one for all of its displays), Shown, its stream's resolution, frame rate and bitrate, and its width in VR. A disconnected display keeps its settings, and within the same desktop run, its place. In VR each one is a panel with a screen's controls, and profiles keep where they are. See [remote-displays.md](remote-displays.md).
|
||||
|
||||
```
|
||||
layout/ft-layout remote list # the remote displays and their streams' state
|
||||
layout/ft-layout remote connect|disconnect ID...
|
||||
remote-displays/install.sh # its menu entry
|
||||
```
|
||||
|
||||
On the PC, `host/windows/Setup Frametop host.cmd` sets it up for Frametop: Vibepollo 2.0.0 (installed if missing), Frametop's build of its `sunshine.exe`, the settings Frametop needs, the Web UI login you sign in with from the Frame, and a firewall check (`-Check` to see what it would change, `-Undo` to put things back).
|
||||
|
||||
## Floating windows
|
||||
|
||||
@@ -221,7 +237,7 @@ Paused, Frametop leaves the headset's CPU and GPU to a VR game. The input relay
|
||||
- The gaze service stops (`frametop-gaze`: ft-gazed, ft-gaze, our own eye tracker, the gaze panel), so nothing reads SteamVR's eye tracking. Our frame grabber, the root service `ft-eyegrab`, goes idle by itself 3 seconds after our eye tracker stops asking it for frames.
|
||||
- Hand tracking stops if it runs (`frametop-camd`, `frametop-hands`).
|
||||
- The desktop, as the Game optimization page of Frametop Input Settings says (`pause_desktop`): hidden (the default) or closed. Hidden, ft-screens hides every screen and floating window whatever the visibility mode, the hotkey, or the dashboard says, and gives KWin a frame callback once a second instead of every display frame. KWin draws a screen only after its frame callback, and its apps wait for theirs, so the desktop hardly draws, but its windows stay open. Remote desktop stops if it runs (`session/remote-ctl.sh`). Closed, `desktops.sh stop` closes the desktop and its windows, and resuming starts it again (about 12 seconds), in its start profile if it has one.
|
||||
- The relay lets go of the 3D mouse and feeds pointer devices to its virtual mouse and keyboard, as with `POINTER=0`. Typing goes to Steam. Mapped buttons and key combinations do nothing but pausing, the Steam menu, and commands; a key combination that does nothing is typed as usual.
|
||||
- The relay lets go of the 3D mouse, releasing any click still held on it (a mouse button, a mapped controller button, or a key combination), and feeds pointer devices to its virtual mouse and keyboard, as with `POINTER=0`. Typing goes to Steam. Mapped buttons and key combinations do nothing but pausing, the Steam menu, and commands; a key combination that does nothing is typed as usual.
|
||||
|
||||
Resuming starts again only what pausing stopped, and plays a second sound. The pointer helper and ft-powerd keep running: they cost little, the helper is what says a game started, and stopping it would leave its virtual controller connected with its last pose.
|
||||
|
||||
@@ -237,6 +253,7 @@ input/ft-pause on | off | toggle # pause or resume
|
||||
input/ft-pause status # the state as JSON (the relay's "pause ?")
|
||||
input/vrws.py 10 # the controllers' buttons from vrserver's web socket, for 10 s
|
||||
input/test/pause-test.py # the gesture and the automatic pause, offline
|
||||
input/test/pause-buttons-test.py # a click held into a pause comes up, offline
|
||||
```
|
||||
|
||||
The state outlives a relay restart, in `/run/user/UID/frametop-pause.json`. A SteamVR restart while paused starts the gaze service with it, and the relay stops it again when the pointer helper comes back.
|
||||
@@ -258,10 +275,10 @@ Deferred: it costs a lot of the headset's CPU and needs more work, so `install.s
|
||||
|
||||
Your hands show over the screens: where a tracked hand is between an eye and a screen, ft-screens lets that eye see the room through the screen. The same tracker detects pinches and grips, and with `POINTER_HANDS=1` in `~/.config/frametop.conf` they work the pointer. In gaze mode a pinch clicks where you look when it opens; hold it and move the hand to correct the pointer first. Without gaze mode a pinch is a press like the mouse's button, so a held pinch drags. A grip (closing the hand) presses and drags. To install it: `hands/run.sh install`.
|
||||
|
||||
- `ft-camd` borrows XRService's camera buffers and publishes the four IR tracking cameras to `/run/user/UID/frametop-hands/cam-ring`. It runs on the host as `frametop-camd.service`, with file capabilities that `hands/run.sh install` sets through sudo, and it drops them once set up. A rebuild clears them: `hands/run.sh caps`.
|
||||
- `ft-camd` borrows XRService's camera buffers and publishes the four IR tracking cameras to `/run/user/UID/frametop-hands/cam-ring`. It runs on the host as `frametop-camd.service`, with file capabilities that `hands/run.sh install` sets through sudo, and it drops them once set up. A rebuild clears them: `hands/run.sh caps`. The Hand Recorder's installer (`hands/rec/install.sh`) sets them the same way. `hands/run.sh uncaps` takes them back while neither the services nor the Hand Recorder is installed. `hands/run.sh uninstall` and `hands/rec/install.sh uninstall` run it after removing their own part, and `uninstall.sh` always takes them back.
|
||||
- `ft-hands` runs in the `dev` container as `frametop-hands.service`. It finds and triangulates the hands, and publishes `hands` (read by ft-screens' cutouts) and `gestures` (pinches and grips, read by the pointer helper) next to the ring.
|
||||
- The install leaves both off, and they don't start with SteamVR. `ft-handsctl on` starts them while SteamVR runs, and `ft-handsctl off` stops them; they also stop with SteamVR. The install links `ft-handsctl` into `~/.local/bin`. `ft-handsctl status` and `ft-handsctl log` (or `hands/run.sh status` and `log`) show how they're doing, `ft-handsctl cutouts on|off` turns just the cutouts off, and `ft-handsctl gestures` shows pinches and grips live.
|
||||
- Settings in `~/.config/frametop.conf`: `HANDS_SWAP_SIDES` (after some SteamVR restarts the side cameras' names come out swapped, and hands land beside the holes; `hands/tools/check_sides.py --ring` tells), `HANDS_CPUS`, the cameras it tracks with (`HANDS_CAMERAS`, `HANDS_BRIGHT`, `HANDS_BRIGHT_ON`, `HANDS_BRIGHT_OFF`, `HANDS_COLOR_LEFT`, `HANDS_COLOR_CROP`), and the pointer helper's `POINTER_HANDS`, `POINTER_PINCH_GAIN`, `POINTER_PINCH_DEADZONE`, `POINTER_GRIP_GAIN`, `POINTER_GRIP_BELOW`, and `POINTER_PINCH_TYPING`. The example config explains each.
|
||||
- Settings in `~/.config/frametop.conf`: `HANDS_SWAP_SIDES` (`auto`, the default: ft-hands tells from the hands when some SteamVR restart has swapped the side cameras' names, and fixes them; `0` or `1` force them, and `hands/tools/check_sides.py --ring` tells which is right), `HANDS_CPUS`, the cameras it tracks with (`HANDS_CAMERAS`, `HANDS_BRIGHT`, `HANDS_BRIGHT_ON`, `HANDS_BRIGHT_OFF`, `HANDS_COLOR_LEFT`, `HANDS_COLOR_CROP`), and the pointer helper's `POINTER_HANDS`, `POINTER_PINCH_GAIN`, `POINTER_PINCH_DEADZONE`, `POINTER_GRIP_GAIN`, `POINTER_GRIP_BELOW`, and `POINTER_PINCH_TYPING`. The example config explains each.
|
||||
|
||||
Details, options, and the recording and replay tools are in [hands/README.md](../hands/README.md).
|
||||
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
# Remote displays (exploration)
|
||||
|
||||
Status: exploration. Spike S1 is done; nothing in Frametop has changed yet. Branch `remote-displays`, written 2026-10-06.
|
||||
|
||||
The idea: show desktops streamed from other machines as Frametop panels that behave like the native screens. They get placement, curve, pinning, layouts, lasers, gaze, and attention-based frame rates. The target is up to 5 remote displays from 2 hosts, with no more headset overhead than 5 native screens.
|
||||
|
||||
## What the hardware gives us
|
||||
|
||||
These were checked on the Frame (SteamOS kernel 6.18, SM8650 / Snapdragon 8 Gen 3, Mesa 26.3 Turnip on Adreno 750).
|
||||
|
||||
- **Decoder.** `/dev/video22` (`/dev/video-dec0`) is `qcom-iris-decoder`, Qualcomm's downstream iris driver (`drivers/media/platform/qcom/vcodec/iris`). It takes H.264, HEVC, and VP9 up to 8192x8192. AV1 isn't supported. The capture queue can produce `Q08C` (NV12 in Qualcomm's UBWC compressed layout), linear NV12, and NV21. It also lists RGBA (`AB24`, `QC24`), but those write a 10-bit UBWC YUV picture (S1), so the decoder has no usable RGB output.
|
||||
- **10-bit works on Valve's kernel.** Steam Link VR's client (`vrlink.txt`, `SVLCodecV4L2`) decodes into `Q10C`, 10-bit UBWC, at 1152x4608. That is both eyes stacked in one frame, so it uses one decode session. Valve drives the decoder through the raw V4L2 stateful API, as `vr-recorder` does for the encoder.
|
||||
- **Decoder budget.** The driver carries `MAX_SESSION_COUNT`, `MAX_MBPF`, and `MAX_MBPS` capability tables. For SM8650 the upstream values are 16 sessions, 278,528 macroblocks per frame, and 7,776,000 macroblocks per second (one 8K stream at 60 fps). A session that would go over the budget is refused when it starts. The downstream numbers on Valve's kernel are still to be confirmed by spike S2.
|
||||
- **SteamVR imports YUV but doesn't convert it.** `IVRIPCResourceManagerClient::GetDmabufModifiers(VRApplication_Overlay, …)` returns LINEAR and `0x0500000000000001` (`DRM_FORMAT_MOD_QCOM_COMPRESSED`) for NV12, P010, and the RGB formats. The probe is in `~/.cache/remote-displays-spike/modprobe.cpp`. `DmabufAttributes_t` takes multiple planes. This is the call ft-screens already makes for KWin's buffers (`ft_vr_screen_present`, `vr.cpp:2080`). The import works, but vrcompositor samples the planes as they are: red shows Cr, green Y, blue Cb (S1). `DmabufAttributes_t` has no colour-space fields to change that.
|
||||
|
||||
So a decoded frame needs one GPU pass before SteamVR sees it: NV12 to RGBA, about 0.13 ms of GPU time for a 3440x1440 frame at full clock. It is the same kind of pass as the hand cutouts' (`screens/handcut.cpp`), and native screens pay for one of the same size: KWin's composite of each output.
|
||||
|
||||
## Is the decoder the bottleneck?
|
||||
|
||||
Mostly no. Macroblocks per second against the 7,776,000 budget, at 60 fps:
|
||||
|
||||
| Displays | Share of the decoder |
|
||||
|---|---|
|
||||
| 5x 1920x1080 | 31% |
|
||||
| 5x 2560x1440 | 56% |
|
||||
| The current layout (3440x1440 + 2x 1440x1920) | 32% |
|
||||
| 5x 2560x1440 at 90 fps | 83% |
|
||||
| 4x 3840x2160 | 100% |
|
||||
| 5x 3840x2160 | 125%, refused |
|
||||
| Steam Link VR's own stream (1152x4608 at 120 Hz) | about 32% |
|
||||
|
||||
Five 1440p displays fit at full rate with room left. Five 4K displays only fit at about 48 fps or less. That number is what gets declared at session start; actual load is lower, because hosts send frames only when the screen changes (see "Keeping the decoder under budget").
|
||||
|
||||
### The planned setup
|
||||
|
||||
Two hosts that share the same two desk monitors: a Mac (14-inch MacBook Pro) with those two plus its built-in display, and the test PC (Windows, RTX 5090) with the two. Windows and Linux hosts run Vibepollo, which streams real displays and creates virtual ones on demand. The Mac runs Sunshine and is limited to one display for now (see "Host side"). At 60 fps:
|
||||
|
||||
| Display | Share of the decoder |
|
||||
|---|---|
|
||||
| Super ultrawide 5120x1440 (5160x1440 as given; same load within 0.2%) | 22.2% |
|
||||
| Built-in 3024x1964 (native pixels) | 17.9% |
|
||||
| Ultrawide 3440x1440 | 14.9% |
|
||||
| Mac, one display | 14.9% to 22.2% |
|
||||
| The test PC, ultrawide + super ultrawide | 37.1% |
|
||||
| Both hosts today (three displays) | at most 59.4% |
|
||||
| Five: the three above plus two 3440x1440 virtual displays on the test PC | at most 89.2% |
|
||||
| The original five (all three Mac displays + the test PC's two) | 92.2% |
|
||||
|
||||
Every combination fits under the driver's limit at 60 fps. The original five stay as the worst case for spike S2, so the Mac can grow past one display later without a new budget. These are declared numbers, with every display changing every frame at once. With damage-driven hosts the real load is far lower.
|
||||
|
||||
Notes:
|
||||
|
||||
- The 5120-wide display needs HEVC. H.264 hardware encoders stop at 4096 pixels wide, and the iris decoder takes HEVC up to 8192. HEVC for every stream keeps it simple.
|
||||
- Streams don't need more than 60 fps. The Frame's display runs at 90 Hz, and the MacBook's 120 Hz ProMotion would double the built-in display's load for nothing.
|
||||
- A stream can be smaller than its display, because the host scales before encoding. A panel in VR covers far fewer headset pixels than the display has; a 1 m wide panel at 1 m spans about 53°, which is roughly 1,000 headset pixels across (estimate; the Frame's pixels per degree haven't been measured here). The built-in display at 2268x1473 instead of 3024x1964 would cost 10% instead of 18%. That's the lever when Steam Link VR also needs the decoder, or when a virtual display is made larger.
|
||||
- The desk monitors are shared through input switching. A monitor switched to the other machine may disappear from the first one, depending on the monitor and the cable. Real-display streams only work for monitors that host currently sees. In the headset, virtual displays avoid the question.
|
||||
|
||||
A remote display should cost less than a native one everywhere else:
|
||||
|
||||
| Per display | Native screen | Remote display |
|
||||
|---|---|---|
|
||||
| Apps | Run on the Frame's CPU | Run on the host |
|
||||
| Composition | KWin draws each output on the Adreno GPU | One GPU pass per new frame turns the decoder's NV12 into RGBA |
|
||||
| Hand-off to SteamVR | XRGB dmabuf, zero copy | RGBA UBWC dmabuf, zero copy |
|
||||
| Sampled by vrcompositor | 4 bytes per pixel | The same |
|
||||
| New work | — | Network receive and reassembly, V4L2 queueing |
|
||||
|
||||
The new costs are CPU for receiving packets, plus the decoder's and Wi-Fi radio's power and heat. Heat matters because the SoC slows down when it gets hot. Those are what the spikes need to measure.
|
||||
|
||||
## Keeping the decoder under budget
|
||||
|
||||
1. **Damage-driven hosts.** Sunshine and its forks send a new frame only when the captured screen changes, plus duplicates down to `minimum_fps_target` (default half the stream rate, settable to 1). With `minimum_fps_target = 1`, a static display costs about one decoded frame a second, the same idea as a native screen with no damage. A display playing video costs full rate, as a native video screen does.
|
||||
2. **Out of sight means 1 frame per second, whatever the host sends.** A display outside your view updates once a second, even when the host is sending 60 fps from a game. See "Displays out of sight" below for how. Concealed panels, and every panel while the desktop is paused for a game, disconnect fully after a grace period. That frees the host's encoder and the network.
|
||||
3. **An admission budget.** Before connecting, Frametop adds up the declared load of every stream: width/16 × height/16 × fps. If a new stream would go past the driver's limit, minus whatever else is decoding, Frametop lowers settings before connecting: first fps, then resolution, starting with the displays that get the least attention. Today's three displays come to at most 59%, which fits next to Steam Link VR's stream (about 32%). Five displays (89-92%) fit alone but not next to it, so a streamed PC game with five remote panels up would push them down to about 45 fps (or some lower and others higher).
|
||||
4. **No B-frames, low-latency decode.** Sunshine doesn't send B-frames, so every decoded frame can be shown at once. The decoder gets a small capture pool (6-8 buffers). Valve's client found the standard `DISPLAY_DELAY` controls unsupported on this driver (`EINVAL` in `vrlink.txt`). They aren't needed: S1 showed that with `Q08C` each frame comes out as soon as it's decoded (linear NV12 holds two more).
|
||||
5. **Tiling only as a fallback.** Valve tiles both eyes into one frame because they always change together. Desktops don't: one busy display would make the whole canvas decode at full rate, and a hidden tile can't be skipped. Per-display streams keep each display's cost proportional to its own changes. Tiling makes sense only for a host with many small, mostly static displays, and Sunshine can't capture a spanning desktop on Windows anyway.
|
||||
|
||||
The Moonlight protocol can't change resolution, fps, or bitrate mid-stream; that takes a reconnect. With stock hosts, attention changes therefore act on the client: in what gets decoded (2), not in what the host sends.
|
||||
|
||||
## Displays out of sight
|
||||
|
||||
Remote panels follow the native screens' attention rules (`UpdateAttention`, `vr.cpp:855`):
|
||||
|
||||
| Attention | Native screen | Remote display |
|
||||
|---|---|---|
|
||||
| Focused (within 12° of where you look) | Full rate | Full rate |
|
||||
| In view (within 60°) | 15 Hz, or full rate while it plays video | Full rate. Every frame has to be decoded anyway (below), and the host sends only what changed |
|
||||
| Hidden (out of view) | 1 Hz | 1 Hz, even when the host sends 60 |
|
||||
|
||||
The "video" signal comes for free: a damage-driven host sends frames only when its screen changes, so frames arriving faster than 10 a second for 8 frames in a row mean video, matching `VIDEO_COMMITS` and `VIDEO_HZ` in `compositor.c`.
|
||||
|
||||
The stream's fps is ours to choose. A game running at 144 Hz on the host is captured and sent at the fps the stream asked for (60 at most), so 144 never reaches the decoder.
|
||||
|
||||
**Why 1 Hz can't just skip frames.** Each frame in a stream is coded as changes to the frame before. Decoding frame 60 needs frames 1 to 59. Throwing away 59 of every 60 frames breaks the chain, and the next frame decodes as garbage. Valve's client works the same way: on a stall it asks the host for a new full frame (an "IFrame" in `vrlink.txt`).
|
||||
|
||||
**With stock hosts (Moonlight protocol): keyframe sampling.** While a panel is hidden, ft-stream:
|
||||
|
||||
1. drops every incoming frame before the decoder (moonlight-common-c's decode callback gets a `DECODE_UNIT` with `frameType`; return `DR_OK` without queueing);
|
||||
2. calls `LiRequestIdrFrame()` once a second, and decodes and shows only the IDR frame that comes back, which needs no earlier frames;
|
||||
3. when the panel comes back into view, requests one more IDR and goes back to decoding everything. The last frame, at most a second old, stays up until it arrives, about one round trip plus one frame later.
|
||||
|
||||
What that saves and what it doesn't, for a hidden display whose host sends 60 fps:
|
||||
|
||||
| Cost | Saved? |
|
||||
|---|---|
|
||||
| Decoder work | About 59 of 60 frames. One IDR costs a bit more to decode than one change frame |
|
||||
| Panel updates in vrcompositor | 59 of 60 |
|
||||
| Network traffic | No. The host keeps sending 60 fps, plus one larger IDR a second. A 5120x1440 IDR is a few hundred KB (estimate), so a few Mbit/s extra |
|
||||
| Frame CPU to receive, reassemble, and repair packets | No. moonlight-common-c still handles every packet. Patching it (it's GPL, and so is ft-stream) to discard hidden video packets early would cut most of this |
|
||||
| Host encoder | No |
|
||||
|
||||
**With a host that takes an fps change mid-stream: real 1 Hz.** If the host can be told "this display is out of sight, send 1 fps", the hidden display costs almost nothing anywhere. That's network, Frame CPU, decoder, and host encoder alike, and no IDRs are needed. The 1 Hz frames are ordinary change frames against the one a second earlier. This needs our own streamer, or a Sunshine fork with a control message that changes the capture rate. Owning a host streamer was rejected for its maintenance cost (see "Alternatives considered"), so this stays a possible upstream contribution. Vibepollo is the likeliest place to propose it: it already extends the protocol, since its own Moonlight fork sends a VRR pacing request that changes how the host captures.
|
||||
|
||||
Spike S3 checks the stock-host version: whether Sunshine honours an IDR request every second, how big and how late those IDRs are, and what receiving a hidden 60 fps stream costs the Frame's CPU.
|
||||
|
||||
## Proposed architecture
|
||||
|
||||
Frametop maintains only the headset side. The hosts run existing, separately maintained streamers that speak the Moonlight protocol: Vibepollo on Windows and Linux, Sunshine on macOS (see "Host side").
|
||||
|
||||
```
|
||||
host machine: Vibepollo (Windows, Linux) / Sunshine (macOS), one stream per display
|
||||
│ RTSP + RTP over UDP, ENet control
|
||||
▼
|
||||
ft-stream (one process per remote display, on the Frame, GPLv3)
|
||||
moonlight-common-c (protocol) + pairing (moonlight-embedded's libgamestream)
|
||||
iris decoder session: OUTPUT = HEVC, CAPTURE = Q08C, VIDIOC_EXPBUF
|
||||
GLES pass: Q08C → ring of 3 RGBA buffers (EGL YUV hints = the stream's colour space)
|
||||
│ unix socket, SCM_RIGHTS
|
||||
▼
|
||||
ft-screens (existing, MIT)
|
||||
remote screen type: ImportDmabuf once per ring buffer,
|
||||
SetOverlayTexture per frame, buffer returned to ft-stream when replaced
|
||||
panel input → ft-stream → LiSendMousePositionEvent / LiSendKeyboardEvent / …
|
||||
```
|
||||
|
||||
**Why one process per display.** moonlight-common-c keeps global state, so it runs one stream per process. It and libgamestream are GPLv3 while Frametop is MIT, and a helper that talks over a socket keeps the licences apart (this isn't legal advice). ft-stream lives in its own directory with its own licence file. Separate processes also mean a stalled stream or a decoder reset can't freeze the native screens. Each process pairs as its own client, which Vibepollo needs anyway: it ties each Remote Monitor to the client that opened it, one role per client, so every display needs its own client certificate and pairing.
|
||||
|
||||
**Pairing with a host token (chosen 2026-10-07).** Since every display is its own client, PIN pairing would mean one PIN and one round of permission clicks per display. Vibepollo has neither a multi-use PIN (its one-time PINs pair one client each) nor a way to pair several certificates at once. It does have scoped API tokens for its Web UI API (`POST /api/token`, sent as `Authorization: Bearer`). So the user creates one token on the host, limited to submitting pairing PINs (`POST /api/pin`), listing clients and setting their permissions (`GET /api/clients/list`, `POST /api/clients/update`), listing the host's monitors (`GET /api/display-devices`), and placing Remote Monitors (`GET`/`PUT /api/clients/display-layout`). A script on the host makes it from the Web UI login (`make-frametop-token`), and the user enters it once in Frametop's "add host" dialog. From then on, Frametop makes a client for each new display, pairs it by sending its own PIN, and grants it launch, mouse, and keyboard (Vibepollo gives a new client only list and view). It sizes Remote Monitors like the host's real monitors, and unpairs a display's client with that client's own certificate when the display is removed. The token can't change the host's settings and can be revoked in the Web UI. It can change any client's permissions, though, so Frametop keeps it like a password (a file only the user can read). Vibepollo's client update replaces the whole client record, so Frametop always sends every field.
|
||||
|
||||
**Why ft-stream converts.** ft-screens then gets RGBA dmabufs, as it does from KWin. Each decoder buffer goes back to the decoder right after the pass, so the decoder's pool doesn't depend on what SteamVR still holds. A GPU fault in one stream stays in its own process. ft-stream asks the host for BT.709 limited range (Moonlight's colour space and range settings) and gives the converter the same as EGL hints. S1 showed all four matrix and range combinations come out right when the hints match the stream.
|
||||
|
||||
**Opening a display.** For a host's main session, ft-stream launches the host's desktop app as Moonlight does. For a Vibepollo virtual display it launches the synthetic "Remote Monitor" app (id `2147483505` in `remote_session.h`), and the requested stream size and fps become the virtual display's mode. When the stream drops, Vibepollo keeps that display and its windows by default (`remote_monitor_disconnect_on_stream_end = false`), and ft-stream reconnects with "Resume" (id `2147483501`). So a concealed panel can disconnect fully without the host rearranging its windows.
|
||||
|
||||
**The socket protocol**, roughly:
|
||||
|
||||
- ft-stream → ft-screens: `buffers` (count, width, height, DRM format, modifier, offset and pitch, with the dmabuf fds of the RGBA ring) after each (re)configuration; `frame i` when ring buffer i holds a new picture (sent once the GPU is done, so no fence is needed); `title`, `state` (connecting, live, waiting for a keyframe, lost).
|
||||
- ft-screens → ft-stream: `release i` when buffer i is no longer on screen; `attention focused|view|hidden|concealed`; input events.
|
||||
|
||||
**In ft-screens**, the explorer's map gives the seams:
|
||||
|
||||
- Remote screens get `g_screens` entries through a `MakePanel` variant, with indices out of KWin's first-free-slot range (`compositor.c:279`) and the overlay keys `frametop.remote.N`. That brings visibility, attention, lasers, controls, spin, pinning, and hand cutouts.
|
||||
- Frames enter through an eventfd on the wl event loop and go through the same `ft_vr_screen_present` path, keyed by ring buffer, so the `g_imports` cache hits: 3 imports per display.
|
||||
- The pointer helper needs the new prefix in `FramePanel` (`ft-pointer.cpp:485`). The `screens` reply and `get N` need to list remote screens so gaze hit-testing sees them (`ft-gaze.cpp:302`).
|
||||
- `frametop-layout.json` gets a separate `hosts` list, since the `screens` array also sets KWin's output count. The user adds a host once, and its displays come as a bundle: Frametop pairs a client per display, starts their streams when the desktop starts, and stops them with it. Each host entry has the address, the token's file, and its displays. Each display has which one it is (the main session or a Remote Monitor), its own stream settings (size, fps, bitrate, and later codec and HDR), and the usual place, width, curve, and pin. ft-layout, profiles, and Display Settings learn the new list; Display Settings shows a host with its displays under it, each with its own settings (user decision, 2026-10-07).
|
||||
|
||||
**Input.** `handle_vr_event` (`compositor.c:333`) branches on the screen type. A remote screen sends:
|
||||
|
||||
- pointer motion as `LiSendMousePositionEvent(x, y, w, h)` in stream pixels;
|
||||
- buttons as `LiSendMouseButtonEvent`;
|
||||
- scroll as `LiSendHighResScrollEvent`, which matches the existing notches × 120.
|
||||
|
||||
A drag can cross panels, for example a window dragged from one of the host's displays to another. SteamVR sends a held button's moves only to the panel where the press began, with coordinates off that panel once the laser leaves it. The panel under the laser gets nothing (S3 measured this). So while a button is held on a remote screen, ft-screens hit-tests the laser (`ComputeOverlayIntersection`) against the host's other remote screens and sends the position to the ft-stream of the screen it hits. Moves that land on no panel are dropped, never clamped: clamping pins the host's cursor to the first display's edge. The host's input is shared across its sessions, so Windows sees one drag. But the release goes through the stream the press went through: Vibepollo takes a mouse button's release only from the client that pressed it (`mouse_press_owner` in its `src/input.cpp`). Sent through the display under the laser, it was dropped, and the window stayed on the pointer (found in the headset, 2026-10-07; `g_pressed_on` in `remote.c`).
|
||||
|
||||
Keyboard focus follows the last panel clicked. When it's a remote one, `send_key` and `relay_button` send evdev codes to its ft-stream, which maps them to Windows virtual-key codes (moonlight-qt has the table). Sunshine on macOS maps the Windows key to Cmd and Alt to Option. The host draws its own cursor into the video, so it lags the laser by one round trip.
|
||||
|
||||
**Won't work across the boundary:** drag and drop, the clipboard (the Moonlight protocol has none), floating windows, and KWin window rules. A remote display is a picture of another machine's monitor.
|
||||
|
||||
## Host side
|
||||
|
||||
**Decision for v1 (2026-10-07): real monitors only.** Vibepollo 2.0.0's Remote Monitors broke the test PC's monitor layout every time one was removed (see "S3 results so far"), while streaming a real monitor changes nothing on the host. Frametop can make extra screens in the headset itself, and the feature is mostly for controlling other machines, so v1 streams only a host's real monitors; virtual displays wait until the Vibepollo bugs are fixed. One host program streams one real monitor (a second client launching the main app joins the display already streaming), so each extra real monitor needs its own host instance: its own config file and ports (`port` 100 apart) and `output_name` set to that monitor. On the test PC that's Vibepollo for the primary (it also serves the Steam Deck and the Mac) and a plain Sunshine instance for the LC34G55T. Plain Sunshine has no scoped API tokens, so a Sunshine instance is paired once by PIN; it has a single Frametop client anyway. Frametop's host bundle becomes a list of instances, one per real monitor.
|
||||
|
||||
The requirement: each host streams its real displays, and virtual displays it creates on demand. Frametop doesn't maintain a host streamer; it uses existing ones and ships setup notes or scripts for them. State checked 2026-10-06.
|
||||
|
||||
| Host OS | Streamer | Displays |
|
||||
|---|---|---|
|
||||
| Windows | Vibepollo | 1 main session (real or virtual) + up to 4 virtual Remote Monitors |
|
||||
| Linux (Arch, CachyOS; beta) | Vibepollo | The same, with at most 4 virtual displays at once |
|
||||
| macOS | Sunshine | 1, the Mac's main display, for now |
|
||||
|
||||
### Windows and Linux: Vibepollo
|
||||
|
||||
Vibepollo 2.0.0 (2026-09-30) is a Sunshine fork. One install gives:
|
||||
|
||||
- **A main session.** As on any Sunshine host, this is a real display (`virtual_display_mode = disabled`, picked by `output_name`) or a virtual display created for the client (`per_client`, the default on Windows 11 and Linux).
|
||||
- **Up to four Remote Monitors** (`max_client_vdds = 4` in `remote_session.h`). Each is a virtual display at the size and rate its client asks for, streamed and captured on its own (`capture_plan` in `remote_session.cpp`). They don't need a main session: with nothing running, the app list still offers Remote Monitor. They are always virtual and can't stream a real display.
|
||||
|
||||
That covers virtual displays completely, up to five from one host. Real displays are the limit: only the main session shows one, so one Vibepollo install streams one real display. A second real display at the same time needs a second host instance with its own config file, port, and `output_name`. On Windows that could be a plain Sunshine instance next to Vibepollo (untested). On Linux, Vibepollo expects to be the only host install on the machine. For the test PC this means one desk monitor streams as the main session and further displays are virtual, unless S3 shows a second instance works. Virtual displays also avoid the input-switching question (see "The planned setup").
|
||||
|
||||
Settings Frametop's setup notes change from the defaults:
|
||||
|
||||
- `virtual_display_layout`: the default `exclusive` turns off the host's other monitors while a virtual main session streams. `extended` keeps them on; `extended_isolated` also stops the host's own mouse from wandering onto the virtual display. Remote Monitors don't use it: Vibepollo always adds them next to the monitors already on (`apply_remote_monitor_composition` in `nvhttp.cpp`).
|
||||
- `minimum_fps_target = 1`, so a static display costs about one frame a second.
|
||||
- `remote_monitor_mute_audio = true`, unless that display should carry audio.
|
||||
|
||||
Both platforms send frames only when the screen changes. On Linux the virtual displays use presentation-driven capture: sparse changes are captured at once, and faster ones are coalesced to the stream's fps. The virtual outputs have no cursor plane, so the cursor is in the video, as with every Moonlight host.
|
||||
|
||||
PyroWave, Vibepollo's wavelet codec, isn't usable here. It decodes on the client's GPU and needs hundreds of Mbit/s over wired LAN. Frametop uses HEVC.
|
||||
|
||||
**Windows (the test PC).** The chosen setup (2026-10-06): one desk monitor streams as the main session, a real display (`virtual_display_mode = disabled`, `output_name` set to it). The other desk monitor is replaced by a Remote Monitor, a virtual display at that monitor's size, which Vibepollo releases when Frametop ends the connection (`remote_monitor_disconnect_on_client_disconnect = true`). A dropped connection keeps it (`remote_monitor_disconnect_on_stream_end = false`), so a Wi-Fi drop or a panel that disconnects while out of sight doesn't move its windows; ft-stream releases it explicitly with "Disconnect Monitor" (id `2147483502`) when the remote display is removed or Frametop exits. Each Remote Monitor's place next to the real monitors is set per client in the Web UI. GeForce allows 8 concurrent NVENC sessions per system. Installing Vibepollo and its display driver needs an admin; the test PC's `maptrainer` account isn't one.
|
||||
|
||||
How it's set up on the test PC (2026-10-06). Vibepollo installed over Apollo in `C:\Program Files\Apollo` (service `ApolloService`) and kept Apollo's settings, pairings, and Web UI login. The existing clients (a Steam Deck and a Mac) launch "Desktop" or "Steam Big Picture", which stream a virtual display with the other monitors turned off (`dd_configuration_option = ensure_only_display`). Frametop leaves them alone and gets its own app instead:
|
||||
|
||||
- "Frametop primary display": `"display-output": ""` streams the real primary monitor, and `"dd-configuration-option": "disabled"` leaves the other monitors as they are. ft-stream launches this app for the main session. These are the fields the Web UI writes for "use my own display" (`process.cpp` reads them).
|
||||
- `minimum_fps_target = 1` globally, which Remote Monitors use. "Desktop" and "Steam Big Picture" keep 120 as per-app `config-overrides`, so the Deck and the Mac stream as before.
|
||||
- `remote_monitor_mute_audio`, `remote_monitor_disconnect_on_client_disconnect` on, `remote_monitor_disconnect_on_stream_end` off.
|
||||
|
||||
The config folder is admin-only, so the change is a script for the user to run (`D:\remote-displays\vibepollo-setup\run-setup.cmd`, with a backup and an optional Web UI password reset). It ran on 2026-10-07. Vibepollo rewrites apps.json each time it starts and adds its built-in "Remote Input" and "Remote Monitor" apps, so any later change must start from the live file.
|
||||
|
||||
**Linux (beta).** x86_64 only: Arch Linux or CachyOS, KDE Plasma 6 on Wayland started by SDDM or Plasma Login Manager, Linux 6.16 or newer with matching headers, and a GPU with hardware H.264 encode (tested on NVIDIA and modern AMD). Virtual displays come from Vibepollo's own DKMS module, `vibeshine_drm`, which has four connectors, so at most four virtual displays exist at once. Streaming before login needs NVIDIA. Other distributions aren't covered; plain Sunshine still streams real displays there, without virtual ones.
|
||||
|
||||
**Risk.** Vibepollo is new, moves fast, and has one maintainer, and its README says about 99% of its code is AI-generated. Frametop depends only on its Moonlight-protocol behaviour and the Remote Monitor app ids, so on Windows Apollo (virtual displays through the SudoVDA driver) or plain Sunshine (real displays) stay as fallbacks.
|
||||
|
||||
### macOS: Sunshine, one display for now
|
||||
|
||||
Vibepollo has no macOS build. Sunshine (v2026.914, labelled experimental on macOS) is the only Moonlight-protocol host for the Mac, so the Mac streams one display: its main display, from one Sunshine instance. The reasons:
|
||||
|
||||
- Since v2026.906, absolute mouse input only reaches the main display (Sunshine #5733). Any other display would show but couldn't be clicked.
|
||||
- Nobody reports running several Sunshine instances on one Mac yet.
|
||||
- Sunshine can't create virtual displays on macOS.
|
||||
|
||||
The main display is the one with the menu bar (System Settings → Displays). Capture is at backing pixel size, so the built-in display streams at 3024x1964. Closing the lid removes the built-in display, so a stream of it ends. Capture stalls if the display sleeps mid-stream (#5509).
|
||||
|
||||
One display can still reach the encoder's limit. Sunshine forces VideoToolbox's low-latency mode, which roughly halves throughput (#5814); an M4 Pro managed 0.36-0.46 Gpix/s in that mode. The super ultrawide at 60 fps is 0.44 Gpix/s, so full-motion video on it may need a lower fps or a smaller stream. The other two displays need 0.30 and 0.36 Gpix/s.
|
||||
|
||||
**What would lift the limit.** The mouse fix is libvirtualhid PR #145 ("target configured mouse viewport", open), which Sunshine's draft PR #5739 pulls in. Once it ships, spike S4 tries one Sunshine instance per display (own config file, `port` about 100 apart, `output_name`) and BetterDisplay virtual displays switched on by `global_prep_cmd`. Open PR #5817 (ScreenCaptureKit + OBS's VideoToolbox encoder) may raise the encoder limit. Helping #145 along upstream is the one Mac contribution worth making.
|
||||
|
||||
### What stock hosts cost us
|
||||
|
||||
| Limit | Effect | What can be done |
|
||||
|---|---|---|
|
||||
| A stream's fps is fixed at connect | Out-of-sight panels save decoder work through keyframe sampling, but the host keeps encoding and sending 60 fps | Patch ft-stream's copy of moonlight-common-c to drop hidden video packets early (saves Frame CPU). A "change rate" control message needs a host upstream; Vibepollo is the likeliest |
|
||||
| One stream per display | Five ft-stream processes, connections, and client pairings on the Frame | Measure the CPU (S3) |
|
||||
| One real display per Vibepollo install | A second real display needs a second host instance | Use virtual displays; S3 tries a second instance on Windows |
|
||||
| Cursor drawn into the video | Lags the laser by a round trip; each mouse move over a still page becomes a video frame | Accept |
|
||||
| Mac: one display | Sunshine's mouse reaches only the main display, and there are no virtual displays | Upstream fix in progress, then S4 |
|
||||
| Mac encoder | Full-motion video on the super ultrawide at 60 fps is at the limit | Lower fps or size for that stream |
|
||||
|
||||
What Frametop maintains: ft-stream (pairing, decoder, keyframe sampling, input mapping, Remote Monitor launch and resume; the protocol itself is moonlight-common-c's), the remote screen type in ft-screens, the layout and settings changes, and host setup notes or scripts.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Our own host streamer (ft-host)** on macOS, Windows, and Linux: capture and encode each display at a rate the headset can change mid-stream, real 1 Hz for hidden panels, a separate cursor, one connection per host, virtual displays built in. It solves every limit in the table above, but it means maintaining capture, encoding, transport, input, pairing, and virtual displays on three operating systems. Rejected for a free project (2026-10-06).
|
||||
- **Moonlight-qt in a window on a Frametop screen.** Nothing to build, and a quick way to check that a host and pairing work. But each frame goes decoder → Moonlight's GL renderer → KWin → ft-screens: two GPU passes per display where ft-stream needs one. Hidden panels can't drop to 1 Hz, and FFmpeg's v4l2m2m path on this device hasn't been tried (its encoder segfaults). Useful as a baseline, not as the feature.
|
||||
- **RDP (FreeRDP client, RDPGFX).** The best multi-monitor design on paper: one session, up to 16 monitors as separate surfaces, damage rectangles. But Windows' RDP host takes over the login session (the PC's own monitors lock) and runs at 30 fps by default, macOS has no RDP host, and FreeRDP decodes H.264 on the CPU or through VA-API, which the Frame lacks.
|
||||
- **Parsec, Steam Remote Play, RustDesk, NoMachine, Selkies, Apple Screen Sharing.** No aarch64 Linux client with hardware decode, no per-monitor streams, a closed protocol, or a Mac-only client.
|
||||
|
||||
## Spikes before any Frametop change
|
||||
|
||||
Spikes on the Frame run through `frame-job --local` as a standalone test overlay, with the headset on or with frame-testbench holding its worn state and pose. None of them touches the live desktop. S3 and S4 also need the hosts set up.
|
||||
|
||||
| # | Question | Pass |
|
||||
|---|---|---|
|
||||
| S1 (done) | Does a decoded `Q08C` buffer show in a SteamVR overlay with correct colours? Test pattern clip, BT.709 limited range, then full range | Correct colours; under 3% of a core; no extra missed frames (`~/.cache/frametop-perf/drops`); the decoder's real output delay |
|
||||
| S2 | Concurrency: the original five, the worst case (2x 5120x1440, 2x 3440x1440, 3024x1964), fed from files in real time at 60 fps | Admitted (92% declared); decode time per frame; SoC temperature and clocks over 10 minutes |
|
||||
| S3 | Live streams from the test PC (Vibepollo): the main session on a real desk monitor plus two Remote Monitors, through moonlight-common-c + the S1 decoder; then keyframe sampling on a hidden 60 fps stream | CPU per stream at real bitrates; glass-to-glass latency; time to picture after `LiRequestIdrFrame()`; the host honours one IDR request a second; IDR size; Frame CPU for a hidden stream; each Remote Monitor's mouse lands on its own display; Resume after a dropped stream keeps the windows; whether a second host instance can stream the other real monitor |
|
||||
| S4 | The Mac: one Sunshine instance on the main display. Once the mouse fix ships: one instance per display, then a BetterDisplay virtual display | Mouse and keyboard work; encode rate with full-motion video on the super ultrawide; `minimum_fps_target = 1` keeps a static display near 1 fps; later, all three stream at once with the mouse on the right display |
|
||||
| S5 | The baseline: 5 native screens, one playing video, four static | The CPU, GPU time (DRM fdinfo), temperature, and missed-frame numbers that the remote version must match |
|
||||
|
||||
### S1 results (2026-10-06)
|
||||
|
||||
S1 passes, with one change to the plan: a GPU pass between the decoder and SteamVR.
|
||||
|
||||
`stream/spike/ft-dectest` (built by `stream/build.sh`) decodes a clip with the V4L2 stateful API and hands each picture to SteamVR with `ImportDmabuf`, as the remote screen type would. Clips: a colour test pattern (`stream/spike/pattern.py`) with a moving box, 10 s at 60 fps, encoded by NVENC HEVC on the test PC (`-preset p1 -tune ull -bf 0`, as Sunshine-style streaming), in BT.709 and BT.601, limited and full range. The in-headset runs used frame-testbench to hold the worn state and a still pose, with nobody wearing the headset.
|
||||
|
||||
**Decoding.**
|
||||
|
||||
| | 3440x1440, Q08C | 3024x1964, Q08C | 3440x1440, linear NV12 |
|
||||
|---|---|---|---|
|
||||
| Decoder's buffer (coded size) | 3456x1440, 7,557,120 bytes | 3072x1984, 9,191,424 bytes | 3456x1440, 7,467,008 bytes |
|
||||
| SteamVR import | Works (NV12 + `QCOM_COMPRESSED`) | Works | Works (NV12, linear) |
|
||||
| Decode time, steady | 2.1 ms avg, 2.7 ms worst | 2.6 ms avg, 3.3 ms worst | 2.2 ms avg, 2.8 ms worst |
|
||||
| First picture after | 1 input frame | 1 input frame | 3 input frames |
|
||||
| CPU at 60 fps, no conversion | 1.2% of a core | 1.5% of a core | 1.4% of a core |
|
||||
|
||||
- The buffer layout from `msm_media_info.h` (per plane: UBWC metadata, then pixels, each 4 KiB aligned; Y stride aligned to 128, heights to 32) matches the driver's size to the byte, so the import offsets are right. The visible size comes from `G_SELECTION`; the decoder pads the coded size.
|
||||
- Q08C has no extra delay: each frame comes out as soon as it's decoded. Linear NV12 holds two more frames, so the remote screen type uses Q08C.
|
||||
- The CPU figure is ft-dectest's own process (file reading, V4L2 queueing, the overlay call). Network receive is S3's.
|
||||
|
||||
**Colours.** `stream/spike/s1-colours.py` shows the clip head-locked, switches the panel between the decoded video and an RGB copy of the pattern every 2.5 s, grabs the headset view (`/dev/video99`) in both states, and compares each colour patch.
|
||||
|
||||
- Decoder buffers straight into SteamVR come out wrong. vrcompositor doesn't convert NV12: red carries Cr, green Y, blue Cb. 100% red (Y 63, Cb 102, Cr 240 in BT.709 limited range) showed as 239/62/102, and greys as a dull magenta.
|
||||
- The decoder's RGBA formats don't help. It accepts `AB24` and `QC24` but writes a 10-bit UBWC YUV picture into them: 10,027,008 bytes used, exactly TP10 UBWC at 3456x1440, starting with UBWC metadata. It also reports their `bytesperline` in pixels.
|
||||
- A GPU pass gets them right (`ft-dectest --convert 709|601 --range tv|pc`). GLES samples the decoder's buffer through an external texture with the matrix and range as EGL hints (`EGL_YUV_COLOR_SPACE_HINT_EXT`, `EGL_SAMPLE_RANGE_HINT_EXT`). It draws into a ring of 3 RGBA buffers (UBWC, `QCOM_COMPRESSED`) that SteamVR imported once, set up as in `screens/handcut.cpp`.
|
||||
|
||||
| Clip, converted with its own matrix and range | Mean error | Worst patch |
|
||||
|---|---|---|
|
||||
| 3440x1440, BT.709 limited | 1.4 | 4.0 |
|
||||
| 3440x1440, BT.709 full | 1.2 | 4.0 |
|
||||
| 3440x1440, BT.601 limited | 0.2 | 2.4 |
|
||||
| 3440x1440, BT.601 full | 0.3 | 2.9 |
|
||||
| 3024x1964, BT.709 limited | 1.3 | 4.0 |
|
||||
|
||||
Errors are on the 0-255 scale, over 39-40 patches (27 for 3024x1964, where less of the panel is in view). The near-black steps (0-30) and near-white steps (225-255) all stay apart, so nothing is crushed or clipped. BT.709 sits about 3 low in red throughout, probably a rounding difference between ffmpeg's and Mesa's coefficients. That isn't visible.
|
||||
|
||||
**Cost of the pass**, 3440x1440 at 60 fps:
|
||||
|
||||
| Measure | Result |
|
||||
|---|---|
|
||||
| GPU work | About 118,000 cycles a frame: 0.13 ms at the 903 MHz top clock. With the headset idle, the GPU ran at about 230 MHz and was busy 0.51 ms a frame (DRM fdinfo) |
|
||||
| Time until the GPU is done | 1.1-1.4 ms avg, 2-6 ms worst |
|
||||
| CPU, whole ft-dectest process | 2.2-3.5% of a core, up from 1.2% |
|
||||
| Missed compositor frames, 30 s at 90 Hz | 0 of 2,701 with the stream shown, 0 of 2,700 idle |
|
||||
|
||||
- The CPU is at the 3% pass line. The increase is the GL driver plus the `glFinish` wait; ft-stream should try waiting on a sync file in its poll loop instead.
|
||||
- The five-display worst case (89% of the decoder) is about 360 converted 3440x1440-sized frames a second. That's about 5% of the GPU at top clock, and S2 measures it.
|
||||
- frame-testbench held the pose still and nobody wore the headset, so tracking and eye-tracking load may differ from a real session. S5 rechecks missed frames with the headset worn.
|
||||
|
||||
The benchmark is S5 against the same scene on remote displays: total Frame CPU (Frametop + ft-stream), GPU time, missed frames, and SoC temperature all at or below native.
|
||||
|
||||
### S3 results so far (2026-10-07)
|
||||
|
||||
`stream/spike/ft-streamtest.cpp` is a Moonlight client for one display: moonlight-common-c and libgamestream from moonlight-embedded (pinned in `stream/build.sh`), S1's decoder and GPU pass (`spike/iris.h`), and a SteamVR overlay. It pairs through the host's API token, launches the primary app or a Remote Monitor, and reports every 2 s. Tests ran from the test PC over the LAN, with the headset held by frame-testbench.
|
||||
|
||||
**The primary display (5120x1440 at 60 fps, HEVC, 50 Mbit/s asked).** It works end to end. The picture in the headset is right, colours included. The test PC's primary is an HDR monitor, and Vibepollo sends it as SDR Rec. 709, as asked.
|
||||
|
||||
| | Shown | Hidden (keyframe sampling) |
|
||||
|---|---|---|
|
||||
| Frames received | 60 fps | 60 fps |
|
||||
| Frames decoded and shown | 60 fps | 1 fps |
|
||||
| Network | 12-14 Mbit/s | the same |
|
||||
| ft-streamtest CPU | 4.2-4.6% of a core | 1.5-1.7% of a core |
|
||||
|
||||
- Time from a frame's first packet to the panel: 5.1-5.4 ms on average (worst about 12 ms). Of that, decoding takes 3.7 ms, and receiving the whole frame 0.1 ms. The host reports 3.2 ms from capture to encoded, and half the round trip is 1.5-2.5 ms. So a frame reaches the panel about 10 ms after the host captures it, before vrcompositor shows it. Glass to glass isn't measured yet.
|
||||
- Connecting took 225 ms, and the first picture showed 0.5 s after the launch.
|
||||
- Keyframe sampling: the host answered all 14 IDR requests, one a second. Each IDR arrived 18 ms after the request (worst 25 ms), reached the panel at 25 ms (worst 32 ms), and was about 81 KB for this desktop. Going back to full rate took 17 ms.
|
||||
- No packets were lost.
|
||||
- The test PC's desktop has an animated wallpaper, so the stream ran at 60 fps throughout, though Vibepollo applied a 0.5 fps floor. A still wallpaper would let an idle display drop to about one frame a second.
|
||||
|
||||
**Vibepollo bug: a stale display slot blocks Remote Monitors.** The first Remote Monitor launch failed with 503, "The composed display topology did not apply", for 40 s of retries. Vibepollo still held a display slot for the Mac's earlier "Desktop" session (`/api/clients/display-layout`: the Mac as a client node, its runtime "retryable" with the lease held), though that session's virtual display was gone. Composing the layout includes every held slot, and the Mac's can't be resolved to a device, so every composition fails. The Mac's session had been cut by a Vibepollo restart and reconnected before it ended. The failed launch still created frametop-2's virtual display, and "Disconnect Monitor" then reported success without removing it, because the launch never finished. A Vibepollo restart clears both. ft-stream must detect this state, a 503 that persists, and report it rather than retry forever. It's worth reporting upstream with the steps that cause it.
|
||||
|
||||
**Remote Monitor (frametop-2, 3440x1440 at 60 fps), after a Vibepollo restart.** The launch took 2.3 s (Vibepollo makes the virtual display, then answers), and the first picture came 0.9 s after connecting. The picture was right. An empty, still monitor dropped to 1 fps at once: 0.1 Mbit/s, 0.6% of a core, about 4-5 ms from first packet to panel. Windows moved a window it remembered for that spot onto the new monitor, so a Remote Monitor can take windows off the real screens without being asked.
|
||||
|
||||
**Vibepollo bug: a dropped Remote Monitor reset the host's real monitor layout.** The test killed ft-streamtest to imitate a dropped connection. Vibepollo kept the Remote Monitor for Resume, as configured, and recomposed the display layout. Windows refused it (`SetDisplayConfig`: ERROR_INVALID_PARAMETER, "failed to move device ... to new origin" for the monitor above the primary). Vibepollo's recovery (`SDC_USE_DATABASE_CURRENT`, a topology jog, `CDS_RESET`) then left Windows with a different layout: the monitor above became the primary, beside the old primary. "Disconnect Monitor" again reported success and did nothing. A Vibepollo restart was needed again.
|
||||
|
||||
One more defect that may have contributed: `GET /api/clients/display-layout` rebuilds Vibepollo's record of the real monitors without their positions or modes (every monitor at 0,0, 1920x1080; `refresh_remote_display_physical_baseline` in `confighttp.cpp`). A layout composed from that record stacks the monitors on one another. Launching a Remote Monitor reads the real positions again (`refresh_remote_monitor_baseline` in `nvhttp.cpp`), and the last such call came before the launch here, so this isn't proven to be the cause. Frametop must not call that endpoint until it's fixed.
|
||||
|
||||
So far, Vibepollo 2.0.0's Remote Monitors aren't reliable on the test PC: a stale slot blocks them, a drop can rearrange the host's real monitors, and "Disconnect Monitor" can't release a monitor whose ownership was lost. These go upstream with logs before Frametop depends on them. Further Remote Monitor tests on the test PC need the user's go-ahead, since they can move the user's own screens.
|
||||
|
||||
**The cause, and a fix (frametop-vibepollo, 2026-10-07).** All three Remote Monitor failures come from one call. When a display role ends, Vibepollo's coordinator (`src/remote_display_topology.cpp`) first recomposes the topology without the departing display, and only then removes it. On the test PC, Windows refuses that composed `SetDisplayConfig`. Then:
|
||||
- libdisplaydevice's automatic recovery (`SDC_USE_DATABASE_CURRENT`, a topology jog, `CDS_RESET`) rearranges the real monitors;
|
||||
- the release rolls back, so the virtual display stays attached and "Disconnect Monitor" does nothing;
|
||||
- for a normal game (the Mac's session), the per-client identity stays held, and every later composition fails on it.
|
||||
|
||||
Removing the virtual display alone, as a Vibepollo restart does, left the layout intact. The fork [Frametop/frametop-vibepollo](https://github.com/Frametop/frametop-vibepollo) (GPL-3.0, like Vibepollo), on branch `fix/windows-remote-monitor-release`, changes two things on Windows:
|
||||
- The coordinator removes the departing display first and recomposes only if another owned display remains (`retire_before_recompose`, set by the Windows runtime). A normal game's identity is released even when its display is already gone. Linux keeps the old order, which exists so KWin never has zero outputs.
|
||||
- Composed layout applies run with display recovery off, so a refused layout can't reset the host's arrangement.
|
||||
|
||||
Four new unit tests cover it, and the coordinator's 40 tests pass. The plan is to offer the fix upstream once it's proven on the test PC. Frametop stays MIT: it only talks to the host over the network.
|
||||
|
||||
**The fix works on the test PC (2026-10-07).** The dev build, `2.0.0` plus the fix, was built on the test PC with MSYS2 (UCRT64, as the CI does, without WebRTC, drivers or packaging; `D:\vp-build`). It links only Windows system DLLs, so it drops into the install as a plain exe swap (`D:\vp-build\deploy.ps1`; the original is kept as `sunshine.exe.2.0.0-original`, and `-Restore` puts it back). Three Remote Monitor cycles left the real layout exactly as it was, with no layout re-apply and no `SetDisplayConfig` errors in Vibepollo's log: a clean end, a killed client, and a relaunch after the kill. Before the fix, the clean end reset the layout twice in a row. Restarting Vibepollo for the deploy also ended a leftover Mac session cleanly and brought the real monitors back.
|
||||
|
||||
A dropped connection removes the Remote Monitor at once (Vibepollo saw the disconnect within 3 s), even with `remote_monitor_disconnect_on_stream_end = disabled`. With `remote_monitor_disconnect_on_client_disconnect = enabled`, a lost connection counts as a client disconnect. Keeping a monitor and its windows across a Wi-Fi drop would need that setting off, and Frametop releasing monitors explicitly with "Disconnect Monitor". That's still to test.
|
||||
|
||||
The development loop is an incremental Ninja build on the test PC (only changed files recompile), the exe swap through the admin SSH login, and `ft-streamtest`: a few minutes per change. Pure logic gets unit tests on the Frame.
|
||||
|
||||
**Real displays, several at once (frametop-vibepollo, 2026-10-07).** A new hidden control, "Frametop display" (id 2147483521), streams one of the host's existing displays, named by the launch argument `frametopDisplay` (a device id from `/api/display-devices`). The session captures that output exactly, the way a Remote Monitor captures its virtual display, but nothing is created and the layout is never touched. Each client holds one such stream, and the main app stays free for the Deck and the Mac. On the test PC:
|
||||
- both real monitors streamed at once (OLED at 5120x1440, LC34G55T at 3440x1440), each to its own client, at about 0.6% of a core each while idle;
|
||||
- a real display and a Remote Monitor streamed side by side, and the layout was unchanged afterwards.
|
||||
|
||||
With that, Frametop no longer needs the "Frametop primary display" app; every real display is captured the same way.
|
||||
|
||||
**Vibepollo bug: one idle HTTPS client froze the API for everyone.** While any stream ran, every other client's HTTPS requests (serverinfo, pairing, launch) waited until it ended, so a second display couldn't start. The stock 2.0.0 build did the same. A stack dump (MSYS2 gdb attached to the service) showed the HTTPS server's only I/O thread blocked in a synchronous TLS shutdown: `SunshineHTTPS`'s destructor (`nvhttp.h`) sends close_notify and then waits for the client's. libgamestream leaves its connection idle in curl's cache (it forbids reuse only on FreeBSD), so the wait lasted as long as the streaming client process. Fixed on both sides:
|
||||
- the fork marks the client's close_notify as received before shutting down, so nothing waits (a deliberately idle client no longer delays another client's request: 0.13 s);
|
||||
- `ft-streamtest` drops libgamestream's curl handle after every request, which ft-stream must do too.
|
||||
|
||||
**The 3D mouse, and dragging windows between displays (2026-10-07).** The setup had three panels from the test PC: the OLED and the LC34G55T as Frametop displays, and a 2560x1440 Remote Monitor. frame-testbench held the headset, and the 3D mouse was driven through `@ft_pointer_helper`. The 3D mouse moved and clicked the test PC's cursor on every panel.
|
||||
|
||||
The first version clamped every move to its own panel. A drag from the Remote Monitor to the OLED left the window below the Remote Monitor's bottom edge, mostly off screen. Removing the Remote Monitor brought it back to the OLED. Drags between monitors that touch in Windows' layout seemed to work, but only because the cursor, pinned at the shared edge, left the window hanging across it. `--input-log` showed why. With the button held, SteamVR kept sending the moves to the panel where the press began (for example -213,2093 on the 2560x1440 panel), and sent the panel under the laser no events at all. The release also went to the first panel, off its edge.
|
||||
|
||||
The spike's fix is the routing described under "Input", with one difference: each panel is its own process. The panel that got the press publishes the laser's device in a small shared file (`/dev/shm/frametop-streamtest-drag`). Every other panel hit-tests that device's ray against itself and moves the host's cursor while the ray hits it. When hovering, the panel's own hit test matched SteamVR's coordinates exactly, so the ray origin and the UV orientation (bottom-left origin, like the mouse events) are right. With the fix, three drags worked:
|
||||
- Remote Monitor to OLED;
|
||||
- OLED to Remote Monitor;
|
||||
- Remote Monitor to LC34G55T.
|
||||
|
||||
In each, the window followed the laser across panels and stayed where it was dropped. The release still went through the first panel's connection and landed right every time.
|
||||
|
||||
Windows' "Remember window locations based on monitor connection" moves windows on its own. When a Remote Monitor appears, Windows puts back windows it remembers there, and when it goes, they move to a real monitor.
|
||||
|
||||
## In ft-screens (2026-10-07)
|
||||
|
||||
The user decided remote displays are Frametop displays, with the same controls as the desktop's screens and their places saved in profiles. The spike's standalone overlays are replaced.
|
||||
|
||||
**What's built (branch `remote-displays`, uncommitted):**
|
||||
- `stream/ft-stream.cpp` is the per-display helper. It pairs, launches and decodes, and converts into a ring of three RGBA buffers. It hands their dmabufs to ft-screens once, then says which buffer holds each new picture. The protocol is in its header comment: frames one way, then release, attention, pointer, keys and blur the other way. `stream/host.cpp` holds the pairing and launch code ft-streamtest had.
|
||||
- `screens/remote.c` starts one ft-stream per remote screen with a socket pair, restarts a stream that ends (2 s, then backing off to 30 s), shows its frames through `ft_vr_screen_present`, and sends it the panel's input and attention. Commands: `remote <N> start <client> <host> <app> <W>x<H> <fps> <kbit/s> <metres> [label]` (`ok restored` when its panel is back where it was, below), `remote <N> stop`, `remote <N> info` (the stream's whole state, such as `lost can't connect`) and `remotes`. Remote screens are numbered from 101, so every other command (`place`, `width`, `curve`, `pin`, `get`, `conceal`) works on them as it is. `screens` leaves them out, because ft-layout, Display Settings and ft-floatd count KWin's outputs with it.
|
||||
- `vr.cpp` gives a remote screen a screen's panel (`frametop.remote.N`) with its controls. A press on a remote screen dragged onto another one is routed there (`UpdateRemoteDrag`). SteamVR keeps sending the moves to the panel the press began on, as if its surface went on past its edges. So every tick the pressing laser is hit-tested against all the remote screens, and the nearest one it meets takes the moves. On the panel the press began on, SteamVR's own moves count only while that panel is the nearest. Before this (2026-10-07), that panel's carried-on surface passed in front of or behind the other panel, and its moves pulled the host's pointer back: drags across worked only some of the time. The release comes up where the host's pointer is, through the stream the press went down on (Vibepollo takes a release only from the client that pressed).
|
||||
- `compositor.c` sends a remote screen's pointer events to its stream. A click on a remote screen takes the typing there (input relay keys and our VR keyboard), and leaving releases the keys it held (`blur`).
|
||||
- `--beside` runs a second ft-screens next to the desktop for tests. It shows remote screens only and sends nothing to the input relay, ft-floatd or ft-layout.
|
||||
- ft-pointer counts `frametop.remote.` panels as screens, and ft-gaze hit-tests them too (`remotes`, then `get N`). Without that, ft-gazed dropped looks below the keyboard pitch (-20°) on a low remote panel, as if you were looking at the keyboard.
|
||||
|
||||
**Tested through frame-testbench, with a `--beside` instance:**
|
||||
- the OLED and a Remote Monitor as panels;
|
||||
- Win+R, `notepad` and Enter typed through ft-screens' key path started Notepad on the test PC;
|
||||
- Notepad dragged from the OLED panel to the Remote Monitor's stayed where it was dropped;
|
||||
- the grab bar moves a remote panel;
|
||||
- stopping the instance released the Remote Monitor.
|
||||
|
||||
**SteamVR's overlay limit.** SteamVR allows 128 overlays in the whole system (`k_unMaxOverlayCount`), its own included. Each Frametop panel takes six or seven with its controls. The desktop's floating-window slots made all of theirs at start: eight empty slots held 56. With three screens, a third remote screen got `VROverlayError_OverlayLimitExceeded`. A floating window's panel and controls are now made when a window floats on it, and destroyed when it docks (`EnsureFloatPanel`, `DropFloatPanel`). This needs a test on the live desktop. The controls of the other panels stay up, invisible until a laser comes near, because SteamVR's laser hover is what brings them in.
|
||||
|
||||
**Layouts, profiles and settings (2026-10-07, same branch):**
|
||||
- `frametop-layout.json` has a `hosts` list. Each host has its displays: id, paired client, what it streams, label, stream size, fps, bitrate, and a screen's place, width, curve, pin and hidden flag. Each display keeps its screen number (101 and up), so the numbers don't shift when one is removed.
|
||||
- ft-layout starts the remote displays' streams on every apply and at desktop start, even without a head pose, and places them when there is one. Displays without a place go in a row above the screens.
|
||||
- `capture` and `save` record their places.
|
||||
- A profile keeps the displays connected when it's saved, like the apps open then: their places and hidden state by id (`profiles[NAME]["remote"]`). Opening it (`use`, `open`, desktop start), or arranging (`apply`, Reset Screen Layout) while it's the profile in use, connects each of them whose host answers on Vibepollo's Web UI port, at its address or its dongle's as its route allows, and puts it back where it was saved. A host that doesn't answer is skipped, and its displays stay disconnected. Displays the profile doesn't have stay as they are: a profile connects, but never disconnects (user decision, 2026-10-08).
|
||||
- `hide`/`show N` take their numbers.
|
||||
- `ft-layout remote list|monitors|add|set|connect|disconnect|remove`: `add` pairs the display's client with the host's token, and `remove` unpairs it (the host stays). `disconnect` sets the display's `off` flag and stops its stream; apply, profiles and desktop start skip it until `connect`.
|
||||
- ft-screens starts a host's streams one after another. Three started at once made Vibepollo refuse some ("Another stream operation is still running"), and the captures that started while the Remote Monitor appeared got no picture.
|
||||
- Frametop Remote Displays (`remote-displays/`) is the app for them. It was a Display Settings tab at first; the user wanted an app of its own for the connections. Display Settings' Screens page has a button that opens it.
|
||||
- Add a host with its token (written to `~/.local/share/frametop-stream/hosts/ADDRESS.token`, mode 0600). Whether it answers on its Web UI port (47990) is checked every 15 s.
|
||||
- Add a display: one of the host's monitors from its API, or a virtual one.
|
||||
- Per display: Connected, Shown, stream size, fps and bitrate (the stream starts over), width in VR, remove. Each host has a Connected switch for all of its displays. A lost stream shows why (`remote N info`).
|
||||
- A disconnected display comes back where it was. ft-screens keeps a stopped remote panel's place, width, curve, pin and hidden state for the rest of its run (`Parked` in `vr.cpp`), and the next start of that screen restores them and replies `ok restored`. Otherwise ft-layout places it from the head, or with the headset off, from where the last arrangement was made, worked out from screen 1's place (`remote_anchor`).
|
||||
- A vrcompositor crash: ft-screens freed a remote panel's texture imports while the panel still showed one. That happened at quit, at `remote stop`, and when a stream started over. vrcompositor crashed drawing it as the headset left standby. `ft_vr_forget` now clears a panel's texture before its import goes, and remote panels are destroyed before their buffers.
|
||||
|
||||
**vrcompositor crashed again at ft-screens quit (15:42, SIGBUS), and at 14:34.** Both times remote screens were up and the headset was in standby. SteamVR logs "leaving standby" within 2 ms of ft-screens disconnecting, so the compositor wakes in the middle of our cleanup, and it drew a texture that was already freed. The crash took the gamescope session and Steam down with it. The restart at 15:39 under the same conditions didn't crash, so it's a race. Hardened, not yet verified: ft-screens now tears SteamVR down first, while every buffer the panels show still exists (before KWin and the streams go). It clears the panels' textures, destroys the overlays, waits 200 ms, and only then releases the imports; nothing calls SteamVR after `VR_Shutdown`. Until that's checked, restart the desktop only with the compositor awake.
|
||||
|
||||
**Frozen panels, out-of-sight rate, sound (2026-10-07, after the user's test):**
|
||||
- The OLED froze while a video played on it: frames came in at 59 fps and none were shown. ft-stream's message loop asked ft-screens' socket once more after the queue ran dry, to see whether it had closed, and lost whatever came in between. A lost `release` kept one of the three ring buffers from ft-stream for good, and after three nothing could be shown. Every pointer move is a message, so drags made it likely. (A lost button or key release would have stuck on the host the same way.) Fixed; ft-stream also takes the ring back if none of it comes back for 2 s.
|
||||
- Out of sight, a stream used to decode only keyframes, one asked for each second. The user found that too aggressive. Every frame is now decoded, and 10 a second shown (`kHiddenFps`); coming back into view is immediate, with no keyframe requests (those are big frames, and the link already loses some).
|
||||
- ft-stream ignored SIGTERM, ft-screens' death signal included: posix_spawn passed on ft-screens' blocked signals (its event loop takes SIGTERM, SIGINT and SIGCHLD through a signalfd). ft-screens now spawns it with none blocked, and ft-stream clears its mask too.
|
||||
- Sound: ft-stream plays the host's sound (Opus, then PipeWire's PulseAudio server through libpulse-simple, about 40 ms queued). One stream per host plays it, the one holding `$XDG_RUNTIME_DIR/frametop-audio-HOST.lock`; the others try every 3 s, so another takes over when it ends. The host keeps playing its sound too. The test PC sent none: `remote_monitor_mute_audio = enabled` (our setup script) mutes the monitor and display roles. Turning it off needs an admin change on the test PC and a Vibepollo restart.
|
||||
|
||||
**Where the lost frames went, and the dongle (2026-10-07, ~16:40):**
|
||||
- With the streams on the home network, about one frame every 2 s (all three together) was unrecoverable: bursts of 5 to 14 packets of one frame lost, beyond what FEC (Vibepollo's default 20%) repairs, and each loss then waited for a keyframe.
|
||||
- Not on the Frame: UDP `RcvbufErrors` and `InErrors` stayed 0, wlan0 rx drops 0, the driver's misc drops +6 in 90 s. Not on the test PC: I225-V outbound discards and errors 0. The Frame's link was fine (-28 dBm, 2.1 Gbit/s, power save off). So the router loses them, as `~/.cache/wifitest` found for Steam Link VR (2026-10-01).
|
||||
- Same burst test (vrsim, 50 Mbit/s at 60 fps, 20 s) from the test PC: through the router 0.33% of packets and 21 of 1200 frames lost (worst frame 21 ms); over the test PC's Steam Link dongle on the Frame's hotspot (the PC on the Frame's hotspot, the Frame at 10.35.78.1), none (worst 5.6 ms).
|
||||
- So a host can go over its dongle. Its entry in `frametop-layout.json` has `direct` (the dongle's address) and `route`: `auto` (the default: the dongle when its port 47989 answers within 300 ms, else the network), `network` or `dongle` (only: while it's down, the stream is "lost the dongle link is down" and ft-screens tries again). ft-stream reads them itself, so ft-screens passes the host's own address as before. Its state says which way it went (`live via dongle|network`, `remote N info`). Remote Displays has a Connection setting per host, the dongle's address, and Find, which looks for the host among the hotspot's clients (`/proc/net/arp`, device `wlanap`) with the same `<uniqueid>` in its serverinfo. `ft-layout remote host NAME route=... direct=...` saves it and starts the host's running streams over in place.
|
||||
- On the dongle, none of the three streams lost a frame in 2 to 3 minutes each, all at 60 fps.
|
||||
- Sound: `remote_monitor_mute_audio = disabled` on the test PC (backup `D:\remote-displays\vibepollo-setup\sunshine.conf.before-audio-20261007-163556`, ApolloService restarted). One stream plays it (`Remote display: ADDRESS` in PipeWire, through SteamOS's spatial filter chain to the headset's speakers). That stream uses about 5% of a core more than the others (240-sample `pa_simple_write`s; batching them would help).
|
||||
- Seen on the way: a stream that started while Vibepollo restarted chose the network (the dongle didn't answer yet) and then got no video, only sound; starting it over fixed it. Starting one display's stream once ended another's ("lost", back 5 s later). Vibepollo takes about 5 s to answer serverinfo, so every stream start waits that long.
|
||||
|
||||
**Adding a computer: sign in instead of carrying a token (2026-10-07, ~17:00):**
|
||||
- Before, a host's API token came from `make-frametop-token` run on the host (its Web UI login typed there), and the file had to reach the Frame by hand. Now Remote Displays does it. Add computer lists the Vibepollo and Sunshine computers that announce `_nvstream._tcp` over mDNS (`avahi-browse -rpt` on the host; a computer also seen on the hotspot, `wlanap`, has a dongle, and that address is kept as its `direct`), or takes an address. You sign in once with the host's Web UI user name and password. Frametop posts them (HTTP Basic) to `/api/token` for a token with only what it uses: `/api/pin` POST, `/api/clients/list` GET, `/api/clients/update` POST and `/api/display-devices` GET (not `/api/clients/display-layout`, which the old script allowed). It keeps the token, mode 0600, and forgets the password. The sign-in goes over the dongle when there is one. Then Add displays lists the host's monitors, all ticked but those already added, and a virtual display.
|
||||
- Hosts without a dongle use the network, and their card says so, with Find a dongle; the Connection choices only show once a dongle is known. A laptop without one was the test: it's on the network and not on the hotspot.
|
||||
- The Web UI's certificate is self-signed. Its public key is pinned at the first sign-in (`hosts/ADDRESS.pin`, `sha256//...` as curl takes it): ft-stream's API calls set `CURLOPT_PINNEDPUBLICKEY`, and a later sign-in refuses another key ("If Vibepollo was installed again, remove the host and add it again"). The test PC's key is the same on both paths. Checked: the right pin works, a wrong one fails. The test PC's existing token got its pin too.
|
||||
- Checked without the password: discovery found the test PC (its LAN address and its dongle on the hotspot, marked added) and a laptop; a wrong password says "Wrong user name or password". A real sign-in is the user's to try (the password never goes through chat).
|
||||
- Sign in again (on a host card) makes a new token; the old one stays in the host's Web UI under API Tokens until revoked there (Frametop's token can't revoke tokens).
|
||||
|
||||
**The PC side: Frametop host setup (2026-10-07, ~17:40):**
|
||||
- `host/windows/Setup Frametop host.cmd` (it runs `frametop-host-setup.ps1` and asks for admin) does what the test PC got by hand, nothing else of its settings:
|
||||
1. Vibepollo 2.0.0 with its own installer when it isn't there (downloaded from Nonary/Vibepollo's release, SHA-256 checked first; you click through it).
|
||||
2. Frametop's build of `sunshine.exe` over the original (kept as `sunshine.exe.2.0.0-original`), SHA-256 checked: from `-FrametopBuild PATH|URL`, the script's `$BuildUrl`, or `sunshine-frametop.exe` next to it. Only over Vibepollo 2.0.0's own exe; another version stops it with a message. Without the build, virtual displays still work, but not the PC's own monitors.
|
||||
3. `remote_monitor_mute_audio = disabled`, `remote_monitor_disconnect_on_client_disconnect = enabled`, `remote_monitor_disconnect_on_stream_end = disabled`. The rest of `sunshine.conf` stays as it is.
|
||||
4. The Web UI login: keep the one there is, or set one (`sunshine.exe --creds`, the password typed into a hidden prompt and passed to it quoted).
|
||||
5. Checks: an inbound firewall rule for `sunshine.exe` on every network type (added if missing; Windows puts a Steam Link dongle's network in Public), whether a Steam Link dongle (an adapter "For Valve") is connected, and that the Web UI answers. It ends with what to pick and sign in as on the Frame.
|
||||
`-Check` only says what it would change, `-SkipLogin` leaves the login, `-Undo` puts back the original exe and the oldest backup of the settings. Backups and a log go to `%ProgramData%\Frametop`.
|
||||
- Tested on the test PC (admin over SSH, `FRAMETOP_NO_PAUSE=1`): `-Check`; a run with nothing to change (no restart); `-Undo` (the original exe back) and a run with `-FrametopBuild D:\vp-build\src\build\sunshine.exe` (backed up, swapped, Vibepollo restarted, the streams came back on their own). Not tested: a PC without Vibepollo (the download and its installer), setting the login (needs the user at the PC), adding the firewall rule.
|
||||
- Still to decide: where other PCs get Frametop's build. `$BuildUrl` is empty; publishing it needs the fork's source published with it (GPL-3.0).
|
||||
- The Frame side: `install.sh` now builds ft-stream (`stream/build.sh`, step 7) with Remote Displays' menu entry, and `uninstall.sh` offers to delete `~/.local/share/frametop-stream` (the clients' keys, the hosts' tokens and pins) with the other settings.
|
||||
- Linux hosts (Vibepollo's Arch package): a host setup like this one, later.
|
||||
- After Vibepollo restarted, a stream checked the dongle in 300 ms, too soon, and went over the network; ft-stream now gives it a second.
|
||||
|
||||
**Tested in the live desktop (frame-testbench, the 3D mouse through `@ft_pointer_helper`, 2026-10-07):**
|
||||
- An Explorer window carried by its title bar from the Remote Monitor to the OLED stayed there, and carried back, stayed there too. The log showed one change of screen each way.
|
||||
- Disconnect, connect: the panel came back where it was, at its width. Connect in a new run with the headset off: placed from screen 1's anchor.
|
||||
- Windows rescales a window that moves between displays with different scaling, so the point you held moves on the window. A second drag from the same spot can land on the address bar instead of the title bar. That's Windows, not the routing.
|
||||
|
||||
**Still to do:**
|
||||
- A headset test of all of it in the live desktop:
|
||||
- the row above the screens;
|
||||
- moving a remote display and saving a profile, then `use` putting it back;
|
||||
- adding a display from Remote Displays;
|
||||
- drags across with a controller, and gaze on the remote panels.
|
||||
- The shutdown hardening above, verified: restart the desktop with remote screens up and the headset in standby (a crash takes the gamescope session down, so only with the user's OK).
|
||||
- The floating windows' panels made on demand, tested live.
|
||||
- Sound in the headset, by ear: one copy, in time with the picture.
|
||||
- Over the network, a lost frame still waits for a keyframe. Reference frame invalidation (moonlight-common-c's `CAPABILITY_REFERENCE_FRAME_INVALIDATION_HEVC`) would recover without one, if the decoder copes.
|
||||
- Find the dongle again if its address changes (the hotspot's DHCP), and a host whose own address changes (the token and pin files are named by it).
|
||||
- "Approve on the PC" instead of the password (our Vibepollo fork), later if wanted.
|
||||
- A per-display mouse mode (relative, for games).
|
||||
- A picture for a stream that's connecting or lost.
|
||||
- The installer doesn't build ft-stream yet (`stream/build.sh`), and `uninstall.sh` leaves `~/.local/share/frametop-stream` (the client keys and host tokens).
|
||||
- For the test, the pointer and gaze services run this branch's builds through drop-ins (`~/.config/systemd/user/frametop-{pointer,gaze}.service.d/remote-displays-worktree.conf`; `gaze/tracker/build` here links to the installed tracker's). Remove them when this is merged and installed.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Which of the test PC's two desk monitors is the real one (the main session)?
|
||||
- The Mac's chip (Max chips have two video encode engines) and BetterDisplay Pro matter only once the Mac goes past one display.
|
||||
- Audio: none, the focused display's host, or a fixed one?
|
||||
- Remote displays outside the desktop: should they also show over games, where the decoder is shared with Steam Link VR?
|
||||
+37
-2
@@ -411,11 +411,46 @@ function run(c) {
|
||||
}
|
||||
}
|
||||
|
||||
// The long poll has one failure mode: a reply that never comes. KWin's callDBus never calls
|
||||
// back when a call fails (an error reply, ft-floatd gone, or its 25 s D-Bus timeout, which
|
||||
// ft-floatd blocked that long hits); it only logs "Received D-Bus message is error". Then
|
||||
// polling stays true and the script stops hearing commands until KWin reloads it. A watchdog
|
||||
// re-arms the poll. Its period must stay above that 25 s timeout, whatever ft-floatd's
|
||||
// POLL_SECONDS is: by then the call it gives up on has ended. The serial is for a reply that
|
||||
// still comes later (KWin stalled with the reply queued): it runs its commands (ft-floatd
|
||||
// sends each one once) but doesn't poll again. Two polls would answer each other for good,
|
||||
// since ft-floatd answers a waiting poll empty when the next one comes.
|
||||
// When the watchdog fires again and again, ft-floatd is gone, and only starting it again
|
||||
// helps (it reloads the script then). Each failed call is a line in KWin's log, so the
|
||||
// script says so once and waits twice as long each time, up to 5 minutes. A reply starts
|
||||
// over at 30 s, so a lost reply is still polled again after 30 s.
|
||||
const POLL_WAIT = 30000, POLL_WAIT_MAX = 300000;
|
||||
const pollWatchdog = new QTimer();
|
||||
pollWatchdog.singleShot = true;
|
||||
pollWatchdog.interval = POLL_WAIT;
|
||||
let pollLost = false; // the watchdog fired, and no reply since
|
||||
pollWatchdog.timeout.connect(() => {
|
||||
if (!pollLost) print("frametop-float: NextCommand didn't answer, polling again");
|
||||
pollLost = true;
|
||||
pollWatchdog.interval = Math.min(pollWatchdog.interval * 2, POLL_WAIT_MAX);
|
||||
polling = false;
|
||||
poll();
|
||||
});
|
||||
let pollSerial = 0;
|
||||
|
||||
function poll() {
|
||||
if (polling) return;
|
||||
polling = true;
|
||||
const serial = ++pollSerial;
|
||||
pollWatchdog.start();
|
||||
callDBus(SERVICE, PATH, IFACE, "NextCommand", reply => {
|
||||
polling = false;
|
||||
const current = serial === pollSerial; // not a call the watchdog gave up on
|
||||
if (current) {
|
||||
pollWatchdog.stop();
|
||||
pollWatchdog.interval = POLL_WAIT;
|
||||
pollLost = false;
|
||||
polling = false;
|
||||
}
|
||||
if (reply) {
|
||||
try {
|
||||
JSON.parse(reply).forEach(run);
|
||||
@@ -423,7 +458,7 @@ function poll() {
|
||||
print("frametop-float: bad command " + reply + ": " + e);
|
||||
}
|
||||
}
|
||||
poll();
|
||||
if (current) poll();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# 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 installs user services, a SteamVR driver, and a container, and two optional parts need sudo. So FrameDrop installs a small installer instead. Playing "Frametop" from the library opens a window that asks what to install, and your password for the parts that need it, then installs and shows its progress. A release's Frametop.zip (about 1.1 GB, built by CI on a tag, see [pack/README.md](../pack/README.md), Releases) carries Frametop built, as an image, and installs it with `install-release.sh`: nothing compiles on the headset and nothing else downloads. The same zip works unpacked on the headset. A test zip (a few KB) clones Frametop with `get.sh` instead.
|
||||
|
||||
Nothing here is published yet: no release, 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, and opens `installer/progress.py` (GTK 4 and libadwaita).
|
||||
3. The window asks which optional parts to install: our own eye tracker (on by default) and the Bluetooth fixes. Both need sudo, so it asks for your SteamOS password and checks it with `sudo -v`. If your user has no password (SteamOS starts without one), it says how to set one and leaves both out.
|
||||
4. It runs the zip's `install-release.sh --yes` (a test zip: `get.sh --yes`) in a transient user service, `frametop-framedrop-install`. 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. Opened from a VR desktop (the zip unpacked by hand), it still uses the user's real bus and runtime folder for the service.
|
||||
5. It follows the service's log, shows the 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, and skips the SteamVR restart. The window says to restart SteamVR.
|
||||
|
||||
### The password
|
||||
|
||||
FrameDrop has no way to pass a password along, and the zip is the same file for everyone, so the password is typed on the headset, in the window. In VR that means the window's own keypad (shown when Steam starts the window; its Keypad button shows or hides it), whose keys you click with the controller's laser. SteamVR's keyboard doesn't come up for the field by itself, and when it's opened (`steam://open/keyboard`) its keys don't reach the window (tested with frame-testbench, 2026-10-07: the field stayed empty, while laser clicks on the window's checkboxes worked). A Bluetooth keyboard types as usual. The password stays in the window's memory until the install ends:
|
||||
|
||||
- The service gets `SUDO_ASKPASS=installer/askpass`. When install.sh's sudo asks, askpass connects to a socket the window keeps in `/run/user/UID/frametop-install` (mode 0700), and the window answers only a process in the install's own service (checked by its peer credentials and cgroup). If it doesn't have the password yet (the window was opened again), it asks you for it, or you skip that part.
|
||||
- The password is never written to a file, a log, the service's environment, or a command line. The window wipes its copy when the install ends or the window closes. Strings Python and GTK made from it along the way can't be wiped; they go with the process.
|
||||
- With the window closed there's nobody to answer: sudo fails, install.sh says which parts it skipped, and playing Frametop again finishes them.
|
||||
|
||||
`--dry-run` unpacks into `~/.cache/frametop-framedrop/dry-run` and stops there without installing (`install-release.sh --unpack-only`, or `get.sh --clone-only`).
|
||||
|
||||
## Try it on the Frame
|
||||
|
||||
```
|
||||
framedrop/build.sh # a test zip; or --image localhost/frametop:local --version 0.3.0-dev.1
|
||||
unzip -q framedrop/build/Frametop.zip -d /tmp/fd
|
||||
framedrop/devkit.sh add FrametopTest /tmp/fd/Frametop "./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 --image REF --version V [--commit SHA] [--channel C] [ZIP_URL] # a release
|
||||
framedrop/build.sh [ZIP_URL] # a test zip
|
||||
```
|
||||
|
||||
This writes `framedrop/build/Frametop.zip` (reproducible), `frametop.framedrop.json` (FrameDrop's manifest with the zip's sha256), and `SHA256SUMS`. A release's zip has the image REF (`podman save`) with its `frametop-release.json` (`pack/release-info.py`) and `install-release.sh`; CI builds it on a tag (`.github/workflows/release.yml`). A test zip has `get.sh` instead. By default, `ZIP_URL` is the release's asset (`releases/download/vV/Frametop.zip`; for a test zip, a `framedrop-installer` release's). Each release carries its manifest, so the button's link can point at the newest stable one: `https://framedropvr.com/install?manifest=https://github.com/DeeJanuz/frametop/releases/latest/download/frametop.framedrop.json` (the exact URL format is FrameDrop's to confirm).
|
||||
|
||||
## 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 `-`.
|
||||
- Typing the password with the window's keypad, launched with Play from the library (frame-testbench reached the window only when started with devkit.sh run, where the systemui overlay hides its lower half).
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build Frametop's download: framedrop/build/Frametop.zip, a "Frametop" folder that FrameDrop
|
||||
# installs from a PC, or that you unpack on the headset and run (frametop-install.sh). Also
|
||||
# framedrop/build/frametop.framedrop.json, the manifest an "Install with FrameDrop" button
|
||||
# points at, and SHA256SUMS. Same files in, same zip out.
|
||||
#
|
||||
# Usage: framedrop/build.sh --image REF --version V [--commit SHA] [--channel C] [ZIP_URL]
|
||||
# framedrop/build.sh [ZIP_URL]
|
||||
# --image REF a release: the image REF (built from this checkout, pack/Containerfile) goes
|
||||
# in the zip as frametop-image.tar with frametop-release.json, and the
|
||||
# installer installs it, built (pack/install-release.sh). About 1.1 GB.
|
||||
# --version V the release's version (0.3.0, or 0.3.0-exp.1 for an experimental one)
|
||||
# --commit SHA the commit it was built from (default: this checkout's HEAD)
|
||||
# --channel C stable or experimental (default: experimental if V has a "-")
|
||||
# Without --image, a few KB: the installer clones Frametop from GitHub (get.sh), for
|
||||
# testing the installer itself.
|
||||
# ZIP_URL where the zip will be downloaded from (default: the release's asset, or for
|
||||
# a test zip, the framedrop-installer release)
|
||||
set -euo pipefail
|
||||
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
repo=$(cd "$here/.." && pwd)
|
||||
image= version= commit= channel=
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
--image) image=${2:?--image needs an image}; shift ;;
|
||||
--version) version=${2:?--version needs a version}; shift ;;
|
||||
--commit) commit=${2:?--commit needs a commit}; shift ;;
|
||||
--channel) channel=${2:?--channel needs stable or experimental}; shift ;;
|
||||
-*) echo "unknown option: $1" >&2; exit 2 ;;
|
||||
*) break ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
out=$here/build
|
||||
rm -rf "$out"
|
||||
mkdir -p "$out"
|
||||
|
||||
files=("$here/installer/frametop-install.sh" "$here/installer/progress.py" "$here/installer/askpass")
|
||||
if [ -n "$image" ]; then
|
||||
[ -n "$version" ] || { echo "--image needs --version" >&2; exit 2; }
|
||||
commit=${commit:-$(git -C "$repo" rev-parse HEAD)}
|
||||
url=${1:-https://github.com/DeeJanuz/frametop/releases/download/v$version/Frametop.zip}
|
||||
echo "saving $image"
|
||||
podman save -q --format oci-archive -o "$out/frametop-image.tar" "$image"
|
||||
python3 "$repo/pack/release-info.py" --image-file "$out/frametop-image.tar" --version "$version" \
|
||||
--commit "$commit" ${channel:+--channel "$channel"} >"$out/frametop-release.json"
|
||||
files+=("$repo/pack/install-release.sh" "$out/frametop-release.json" "$out/frametop-image.tar")
|
||||
else
|
||||
url=${1:-https://github.com/DeeJanuz/frametop/releases/download/framedrop-installer/Frametop.zip}
|
||||
files+=("$repo/get.sh")
|
||||
fi
|
||||
|
||||
python3 - "$out/Frametop.zip" "${files[@]}" <<'EOF'
|
||||
import os, shutil, sys, zipfile
|
||||
dest, *files = sys.argv[1:]
|
||||
with zipfile.ZipFile(dest, "w", allowZip64=True) as z:
|
||||
for path in files:
|
||||
name = os.path.basename(path)
|
||||
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(path, os.X_OK) else 0o100644) << 16
|
||||
# The image's layers are compressed already.
|
||||
info.compress_type = zipfile.ZIP_STORED if name.endswith(".tar") else zipfile.ZIP_DEFLATED
|
||||
info.file_size = os.path.getsize(path)
|
||||
with open(path, "rb") as src, z.open(info, "w", force_zip64=True) as dst:
|
||||
shutil.copyfileobj(src, dst, 1 << 20)
|
||||
EOF
|
||||
rm -f "$out/frametop-image.tar"
|
||||
|
||||
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
|
||||
(cd "$out" && sha256sum Frametop.zip frametop.framedrop.json ${image:+frametop-release.json} >SHA256SUMS)
|
||||
echo "built $out/Frametop.zip ($(du -h "$out/Frametop.zip" | cut -f1), sha256 $sha)"
|
||||
echo " $out/frametop.framedrop.json, $out/SHA256SUMS"
|
||||
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
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/python3
|
||||
"""SUDO_ASKPASS for the FrameDrop install: sudo runs this for the password, and it asks the
|
||||
install window (progress.py) for it, over the window's socket in XDG_RUNTIME_DIR. The window
|
||||
answers only programs in the install's own service, and asks you on the headset if it doesn't
|
||||
have the password yet. The password goes to sudo on stdout and nowhere else.
|
||||
|
||||
With the window closed there's nobody to ask: this fails, so sudo does, and install.sh says
|
||||
which parts it skipped. Playing Frametop again reopens the window and the install.
|
||||
"""
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
path = os.environ.get("FRAMETOP_ASKPASS_SOCKET", f"/run/user/{os.getuid()}/frametop-install/askpass")
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(600) # time for you to type it, if the window has to ask
|
||||
try:
|
||||
s.connect(path) # connecting is the question: the window answers, then closes
|
||||
answer = bytearray()
|
||||
while chunk := s.recv(4096):
|
||||
answer += chunk
|
||||
except OSError as e:
|
||||
print(f"askpass: the install window isn't answering ({e.strerror or e}): play Frametop again",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not answer:
|
||||
sys.exit(1) # you cancelled
|
||||
sys.stdout.buffer.write(bytes(answer) + b"\n")
|
||||
answer[:] = bytes(len(answer))
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# Frametop's installer, in a release's Frametop.zip: the start command of the "Frametop" title
|
||||
# FrameDrop puts in the Steam library, or what you run after unpacking the zip on the headset.
|
||||
# It opens the install window (progress.py), which asks what to install, and your password
|
||||
# for the parts that need sudo, then installs Frametop (or updates it) in a service of its own
|
||||
# and shows its progress. A release's zip installs its own image, built
|
||||
# (install-release.sh); a test zip clones Frametop from GitHub (get.sh).
|
||||
#
|
||||
# Usage: frametop-install.sh [--dry-run]
|
||||
# --dry-run unpack into ~/.cache/frametop-framedrop/dry-run and stop there, without
|
||||
# installing, 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
|
||||
|
||||
exec /usr/bin/python3 "$here/progress.py" "$here" "$@"
|
||||
Executable
+543
@@ -0,0 +1,543 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The FrameDrop installer's window: what to install, your password for the parts that need it,
|
||||
then the install's progress.
|
||||
|
||||
Usage: progress.py DIR [--dry-run]
|
||||
DIR the installer's folder. A release's Frametop.zip has install-release.sh, the
|
||||
image (frametop-image.tar), and frametop-release.json: it installs that, built.
|
||||
A test zip has get.sh instead, which clones Frametop from GitHub.
|
||||
--dry-run unpack into ~/.cache/frametop-framedrop/dry-run and stop there, without
|
||||
installing, for testing this flow
|
||||
|
||||
The install runs in a user service of its own (UNIT): Steam ends the title's whole process
|
||||
tree when it's quit, and starts it with a high OOM score. Closing the window doesn't stop the
|
||||
install; playing the title again reattaches to it.
|
||||
|
||||
Our eye tracker and the Bluetooth fixes need sudo. The window asks for your password on the
|
||||
headset, checks it with sudo, and keeps it in this process's memory until the install ends.
|
||||
sudo in the install gets it through askpass (SUDO_ASKPASS), from a socket in a folder only you
|
||||
can open, and the window answers only programs in the install's service. It's never written to
|
||||
a file, a log, the service's environment, or a command line.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
from gi.repository import Adw, GLib, Gtk, Pango # noqa: E402
|
||||
|
||||
HERE = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path(__file__).resolve().parent
|
||||
DRY_RUN = "--dry-run" in sys.argv[2:]
|
||||
UNIT = "frametop-framedrop-install"
|
||||
HOME = Path.home()
|
||||
STATE = HOME / ".cache" / "frametop-framedrop"
|
||||
LOG = STATE / "install.log"
|
||||
RELEASE = (HERE / "install-release.sh").exists() and (HERE / "frametop-image.tar").exists()
|
||||
SOCK_DIR = Path(f"/run/user/{os.getuid()}/frametop-install")
|
||||
SOCK = SOCK_DIR / "askpass"
|
||||
ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
||||
# get.sh and install.sh mark 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 host_env():
|
||||
"""The user's real runtime folder and bus, for systemctl and systemd-run: a window opened
|
||||
from a VR desktop's Dolphin or Konsole has that session's own. GTK keeps the session's."""
|
||||
env = dict(os.environ)
|
||||
env["XDG_RUNTIME_DIR"] = f"/run/user/{os.getuid()}"
|
||||
env["DBUS_SESSION_BUS_ADDRESS"] = f"unix:path=/run/user/{os.getuid()}/bus"
|
||||
return env
|
||||
|
||||
|
||||
def unit_state():
|
||||
out = subprocess.run(["systemctl", "--user", "show", "-P", "ActiveState", UNIT],
|
||||
capture_output=True, text=True, env=host_env()).stdout.strip()
|
||||
return out or "inactive"
|
||||
|
||||
|
||||
def release_version():
|
||||
try:
|
||||
return json.loads((HERE / "frametop-release.json").read_text())["version"]
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
return ""
|
||||
|
||||
|
||||
def password_set():
|
||||
"""Does this user have a password sudo can take? SteamOS starts without one."""
|
||||
user = pwd.getpwuid(os.getuid()).pw_name
|
||||
out = subprocess.run(["passwd", "-S", user], capture_output=True, text=True).stdout.split()
|
||||
return len(out) < 2 or out[1] == "P" # P: usable; NP: none; L: locked
|
||||
|
||||
|
||||
def check_password(pw):
|
||||
"""Does sudo take it? Checked with cached credentials ignored, and forgotten after."""
|
||||
try:
|
||||
r = subprocess.run(["sudo", "-S", "-k", "-v", "-p", ""], input=bytes(pw) + b"\n",
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
subprocess.run(["sudo", "-k"], stdin=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return r.returncode == 0
|
||||
|
||||
|
||||
def in_unit(pid):
|
||||
"""Is this process in the install's service?"""
|
||||
try:
|
||||
with open(f"/proc/{pid}/cgroup") as f:
|
||||
return any(line.rstrip("\n").endswith(f"/{UNIT}.service") for line in f)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def install_command(eye_tracker, bluetooth):
|
||||
if RELEASE:
|
||||
args = [str(HERE / "install-release.sh"), "--yes"]
|
||||
if DRY_RUN:
|
||||
args += ["--unpack-only", "--dir", str(STATE / "dry-run")]
|
||||
else:
|
||||
args = [str(HERE / "get.sh"), "--yes"]
|
||||
if DRY_RUN:
|
||||
args += ["--clone-only", "--dir", str(STATE / "dry-run")]
|
||||
if not eye_tracker:
|
||||
args.append("--no-eye-tracker")
|
||||
if bluetooth:
|
||||
args.append("--bluetooth")
|
||||
return args
|
||||
|
||||
|
||||
def start_unit(args, askpass):
|
||||
env = host_env()
|
||||
subprocess.run(["systemctl", "--user", "stop", UNIT], stderr=subprocess.DEVNULL, env=env)
|
||||
subprocess.run(["systemctl", "--user", "reset-failed", UNIT], stderr=subprocess.DEVNULL, env=env)
|
||||
STATE.mkdir(parents=True, exist_ok=True)
|
||||
LOG.write_bytes(b"")
|
||||
cmd = ["systemd-run", "--user", f"--unit={UNIT}", "--description=Frametop install (FrameDrop)",
|
||||
"--property=Type=oneshot", "--property=RemainAfterExit=yes",
|
||||
f"--property=StandardOutput=truncate:{LOG}", "--property=StandardError=inherit",
|
||||
f"--setenv=HOME={HOME}", f"--setenv=PATH={os.environ.get('PATH', '/usr/bin:/bin')}",
|
||||
"--setenv=TERM=dumb", f"--working-directory={HOME}", "--quiet", "--no-block"]
|
||||
if askpass:
|
||||
cmd += [f"--setenv=SUDO_ASKPASS={HERE / 'askpass'}", f"--setenv=FRAMETOP_ASKPASS_SOCKET={SOCK}"]
|
||||
cmd += ["/usr/bin/bash"] + args
|
||||
return subprocess.run(cmd, env=env).returncode == 0
|
||||
|
||||
|
||||
class Askpass:
|
||||
"""The socket askpass asks: answers programs in the install's service with the password,
|
||||
or holds them while the window asks you for it."""
|
||||
|
||||
def __init__(self, on_ask):
|
||||
self.on_ask = on_ask
|
||||
self.password = None # bytearray, while the install runs
|
||||
self.waiting = []
|
||||
SOCK_DIR.mkdir(mode=0o700, exist_ok=True)
|
||||
st = SOCK_DIR.stat()
|
||||
if st.st_uid != os.getuid():
|
||||
raise OSError(f"{SOCK_DIR} isn't yours")
|
||||
os.chmod(SOCK_DIR, 0o700)
|
||||
SOCK.unlink(missing_ok=True)
|
||||
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self.sock.bind(str(SOCK))
|
||||
self.sock.listen(4)
|
||||
self.sock.setblocking(False)
|
||||
self.watch = GLib.io_add_watch(GLib.IOChannel.unix_new(self.sock.fileno()), GLib.PRIORITY_DEFAULT,
|
||||
GLib.IO_IN, self.accept)
|
||||
|
||||
def accept(self, *_):
|
||||
try:
|
||||
conn, _ = self.sock.accept()
|
||||
except OSError:
|
||||
return True
|
||||
pid, uid, _ = struct.unpack("3i", conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED,
|
||||
struct.calcsize("3i")))
|
||||
if uid != os.getuid() or not in_unit(pid):
|
||||
conn.close()
|
||||
return True
|
||||
if self.password:
|
||||
self.answer(conn)
|
||||
else:
|
||||
self.waiting.append(conn)
|
||||
self.on_ask()
|
||||
return True
|
||||
|
||||
def answer(self, conn):
|
||||
try:
|
||||
conn.setblocking(True)
|
||||
conn.sendall(bytes(self.password) if self.password else b"")
|
||||
except OSError:
|
||||
pass
|
||||
conn.close()
|
||||
|
||||
def give(self, pw):
|
||||
self.password = pw
|
||||
while self.waiting:
|
||||
self.answer(self.waiting.pop())
|
||||
|
||||
def refuse(self):
|
||||
while self.waiting:
|
||||
self.waiting.pop().close()
|
||||
|
||||
def close(self):
|
||||
if self.password:
|
||||
self.password[:] = bytes(len(self.password))
|
||||
self.password = None
|
||||
self.refuse()
|
||||
GLib.source_remove(self.watch)
|
||||
self.sock.close()
|
||||
SOCK.unlink(missing_ok=True)
|
||||
|
||||
|
||||
class Keypad(Gtk.Box):
|
||||
"""Keys to click with the controller's laser, for a password field: in VR, SteamVR's own
|
||||
keyboard comes up for a Steam title's window but its keys don't reach it (tested
|
||||
2026-10-07), while clicks on the window's buttons do. A physical keyboard types as usual."""
|
||||
|
||||
LETTERS = ("1234567890", "qwertyuiop", "asdfghjkl", "zxcvbnm")
|
||||
SYMBOLS = ("!@#$%^&*()", "-_=+[]{}\\|", ";:'\",.<>/?", "`~")
|
||||
|
||||
def __init__(self, entry):
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||
self.entry = entry
|
||||
self.shift = self.symbols = False
|
||||
self.rows = []
|
||||
for _ in range(4):
|
||||
row = Gtk.Box(spacing=4, halign=Gtk.Align.CENTER, homogeneous=True)
|
||||
self.rows.append(row)
|
||||
self.append(row)
|
||||
bottom = Gtk.Box(spacing=4, halign=Gtk.Align.CENTER)
|
||||
for label, action in (("Shift", self.toggle_shift), ("!#1", self.toggle_symbols),
|
||||
("Space", lambda *_: self.type(" ")), ("Delete", self.backspace)):
|
||||
b = Gtk.Button(label=label)
|
||||
b.set_size_request(110 if label != "Space" else 260, 48)
|
||||
b.connect("clicked", action)
|
||||
bottom.append(b)
|
||||
if label == "!#1":
|
||||
self.symbols_key = b
|
||||
self.append(bottom)
|
||||
self.fill()
|
||||
|
||||
def fill(self):
|
||||
for row, keys in zip(self.rows, self.SYMBOLS if self.symbols else self.LETTERS):
|
||||
while (child := row.get_first_child()) is not None:
|
||||
row.remove(child)
|
||||
for k in keys:
|
||||
k = k.upper() if self.shift and not self.symbols else k
|
||||
b = Gtk.Button(label=k)
|
||||
b.set_size_request(56, 48)
|
||||
b.connect("clicked", lambda _b, k=k: self.type(k))
|
||||
row.append(b)
|
||||
|
||||
def type(self, text):
|
||||
self.entry.set_text(self.entry.get_text() + text)
|
||||
self.entry.set_position(-1)
|
||||
|
||||
def backspace(self, *_):
|
||||
self.entry.set_text(self.entry.get_text()[:-1])
|
||||
self.entry.set_position(-1)
|
||||
|
||||
def toggle_shift(self, *_):
|
||||
self.shift = not self.shift
|
||||
self.fill()
|
||||
|
||||
def toggle_symbols(self, *_):
|
||||
self.symbols = not self.symbols
|
||||
self.symbols_key.set_label("abc" if self.symbols else "!#1")
|
||||
self.fill()
|
||||
|
||||
|
||||
def keypad_row(entry, *extra):
|
||||
"""The password field, a button that shows or hides the keypad, and the keypad: shown by
|
||||
itself when Steam started this (FrameDrop's title, so most likely in VR)."""
|
||||
keypad = Keypad(entry)
|
||||
keypad.set_visible("SteamAppId" in os.environ)
|
||||
toggle = Gtk.Button(label="Keypad")
|
||||
toggle.connect("clicked", lambda *_: keypad.set_visible(not keypad.get_visible()))
|
||||
row = Gtk.Box(spacing=8)
|
||||
entry.set_hexpand(True)
|
||||
for w in (entry, toggle, *extra):
|
||||
row.append(w)
|
||||
return row, keypad
|
||||
|
||||
|
||||
class Window(Adw.ApplicationWindow):
|
||||
def __init__(self, app):
|
||||
super().__init__(application=app, title="Frametop")
|
||||
self.set_default_size(900, 860 if "SteamAppId" in os.environ else 640)
|
||||
self.offset = 0
|
||||
self.partial = ""
|
||||
self.askpass = None
|
||||
self.checking = False
|
||||
|
||||
self.status = Gtk.Label(label="Install Frametop", xalign=0, wrap=True)
|
||||
self.status.add_css_class("title-2")
|
||||
self.detail = Gtk.Label(xalign=0, wrap=True)
|
||||
self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12,
|
||||
margin_top=18, margin_bottom=18, margin_start=18, margin_end=18)
|
||||
self.box.append(self.status)
|
||||
self.box.append(self.detail)
|
||||
view = Adw.ToolbarView(content=self.box)
|
||||
view.add_top_bar(Adw.HeaderBar())
|
||||
self.set_content(view)
|
||||
self.connect("close-request", self.closing)
|
||||
|
||||
if unit_state() == "activating":
|
||||
self.show_progress() # playing the title again: the install is still going
|
||||
else:
|
||||
self.show_choices()
|
||||
|
||||
# --- what to install, and the password
|
||||
|
||||
def show_choices(self):
|
||||
what = f"Frametop {release_version()}" if RELEASE else "Frametop from GitHub"
|
||||
self.detail.set_label(f"This installs {what}: the multi-screen desktop, the 3D mouse, gaze "
|
||||
"mode, and Frametop's settings apps. Two optional parts need your "
|
||||
"SteamOS password (sudo):")
|
||||
self.eye = Gtk.CheckButton(label="Our own eye tracker for gaze mode (more accurate than SteamVR's)",
|
||||
active=True)
|
||||
self.bt = Gtk.CheckButton(label="Bluetooth fixes (LE mice and keyboards, like the Swiftpoint Z3, "
|
||||
"reconnect after they sleep)")
|
||||
self.pw = Gtk.PasswordEntry(show_peek_icon=True, placeholder_text="Your SteamOS password")
|
||||
pw_note = Gtk.Label(label="It's used only for this install, and isn't saved anywhere.", xalign=0,
|
||||
wrap=True)
|
||||
pw_note.add_css_class("dim-label")
|
||||
self.error = Gtk.Label(xalign=0, wrap=True, visible=False)
|
||||
self.error.add_css_class("error")
|
||||
self.go = Gtk.Button(label="Install", halign=Gtk.Align.END)
|
||||
self.go.add_css_class("suggested-action")
|
||||
self.go.connect("clicked", self.install)
|
||||
self.pw.connect("activate", self.install)
|
||||
pw_row, keypad = keypad_row(self.pw)
|
||||
self.choices = [self.eye, self.bt, pw_row, keypad, pw_note, self.error, self.go]
|
||||
if not password_set():
|
||||
for c in (self.eye, self.bt):
|
||||
c.set_active(False)
|
||||
c.set_sensitive(False)
|
||||
pw_row.set_visible(False)
|
||||
keypad.set_visible(False)
|
||||
pw_note.set_label("Your user has no password, so sudo can't run and these two can't be "
|
||||
"installed from here. To set one, run passwd in Konsole; then play "
|
||||
"Frametop again, or install them later from a terminal "
|
||||
"(gaze/tracker/install.sh, setup/bluetooth/install.sh).")
|
||||
for c in (self.eye, self.bt):
|
||||
c.connect("toggled", lambda *_: pw_row.set_sensitive(self.eye.get_active() or self.bt.get_active()))
|
||||
for w in self.choices:
|
||||
self.box.append(w)
|
||||
|
||||
def install(self, *_):
|
||||
if self.checking:
|
||||
return
|
||||
needs = self.eye.get_active() or self.bt.get_active()
|
||||
if not needs:
|
||||
return self.begin(None)
|
||||
text = self.pw.get_text()
|
||||
if not text:
|
||||
return self.say("Type your password, or untick the parts that need it.")
|
||||
pw = bytearray(text.encode())
|
||||
self.pw.set_text("")
|
||||
self.checking = True
|
||||
self.go.set_sensitive(False)
|
||||
self.say("Checking the password...")
|
||||
|
||||
def check():
|
||||
ok = check_password(pw)
|
||||
GLib.idle_add(checked, ok)
|
||||
|
||||
def checked(ok):
|
||||
self.checking = False
|
||||
self.go.set_sensitive(True)
|
||||
if ok:
|
||||
self.begin(pw)
|
||||
else:
|
||||
pw[:] = bytes(len(pw))
|
||||
self.say("sudo didn't take that password. Try again, or untick the parts that need it.")
|
||||
return False
|
||||
|
||||
threading.Thread(target=check, daemon=True).start()
|
||||
|
||||
def say(self, text):
|
||||
self.error.set_label(text)
|
||||
self.error.set_visible(True)
|
||||
|
||||
def begin(self, pw):
|
||||
args = install_command(self.eye.get_active(), self.bt.get_active())
|
||||
if pw is not None:
|
||||
try:
|
||||
self.askpass = Askpass(self.ask_again)
|
||||
except OSError as e:
|
||||
pw[:] = bytes(len(pw))
|
||||
return self.say(f"Couldn't make the password's socket: {e}")
|
||||
self.askpass.give(pw)
|
||||
if not start_unit(args, pw is not None):
|
||||
if self.askpass:
|
||||
self.askpass.close()
|
||||
self.askpass = None
|
||||
return self.say("Couldn't start the install service (systemd-run).")
|
||||
for w in self.choices:
|
||||
self.box.remove(w)
|
||||
self.show_progress()
|
||||
|
||||
# --- progress
|
||||
|
||||
def show_progress(self):
|
||||
self.status.set_label("Installing Frametop")
|
||||
self.detail.set_label("Starting...")
|
||||
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)
|
||||
|
||||
# The install wants the password and the window doesn't have it (played again).
|
||||
self.ask_pw = Gtk.PasswordEntry(show_peek_icon=True,
|
||||
placeholder_text="The next step needs your SteamOS password")
|
||||
ok = Gtk.Button(label="OK")
|
||||
skip = Gtk.Button(label="Skip that part")
|
||||
ok.connect("clicked", self.answer_ask)
|
||||
self.ask_pw.connect("activate", self.answer_ask)
|
||||
skip.connect("clicked", self.skip_ask)
|
||||
ask_row, ask_keypad = keypad_row(self.ask_pw, ok, skip)
|
||||
self.ask_bar = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8, visible=False)
|
||||
self.ask_bar.append(ask_row)
|
||||
self.ask_bar.append(ask_keypad)
|
||||
self.ask_error = Gtk.Label(xalign=0, wrap=True, visible=False)
|
||||
self.ask_error.add_css_class("error")
|
||||
|
||||
keep = " Keep it open until the install is done: it gives the steps that need it your password." \
|
||||
if self.askpass else ""
|
||||
self.note = Gtk.Label(label="You can close this window: the install keeps going. Play Frametop "
|
||||
"again to come back to it." + keep, xalign=0, wrap=True)
|
||||
self.note.add_css_class("dim-label")
|
||||
close = Gtk.Button(label="Close", halign=Gtk.Align.END)
|
||||
close.connect("clicked", lambda *_: self.close())
|
||||
for w in (self.bar, scroll, self.ask_bar, self.ask_error, self.note, close):
|
||||
self.box.append(w)
|
||||
|
||||
if self.askpass is None:
|
||||
try:
|
||||
self.askpass = Askpass(self.ask_again)
|
||||
except OSError:
|
||||
self.askpass = None # askpass then fails, and install.sh skips those parts
|
||||
GLib.timeout_add(500, self.tick)
|
||||
self.tick()
|
||||
|
||||
def ask_again(self):
|
||||
if hasattr(self, "ask_bar"):
|
||||
self.ask_bar.set_visible(True)
|
||||
self.ask_pw.grab_focus()
|
||||
|
||||
def answer_ask(self, *_):
|
||||
text = self.ask_pw.get_text()
|
||||
if not text or self.checking:
|
||||
return
|
||||
pw = bytearray(text.encode())
|
||||
self.ask_pw.set_text("")
|
||||
self.checking = True
|
||||
|
||||
def check():
|
||||
GLib.idle_add(checked, check_password(pw))
|
||||
|
||||
def checked(ok):
|
||||
self.checking = False
|
||||
if ok and self.askpass:
|
||||
self.askpass.give(pw)
|
||||
self.ask_bar.set_visible(False)
|
||||
self.ask_error.set_visible(False)
|
||||
else:
|
||||
pw[:] = bytes(len(pw))
|
||||
self.ask_error.set_label("sudo didn't take that password.")
|
||||
self.ask_error.set_visible(True)
|
||||
return False
|
||||
|
||||
threading.Thread(target=check, daemon=True).start()
|
||||
|
||||
def skip_ask(self, *_):
|
||||
if self.askpass:
|
||||
self.askpass.refuse()
|
||||
self.ask_bar.set_visible(False)
|
||||
self.ask_error.set_visible(False)
|
||||
|
||||
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(max(0, n - 1) / total)
|
||||
self.detail.set_label(f"Step {max(n, 1)} 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.forget()
|
||||
self.note.set_visible(False)
|
||||
self.ask_bar.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 forget(self):
|
||||
if self.askpass:
|
||||
self.askpass.close()
|
||||
self.askpass = None
|
||||
|
||||
def closing(self, *_):
|
||||
self.forget()
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
app = Adw.Application(application_id="io.github.deejanuz.FrametopInstall")
|
||||
|
||||
def activate(a):
|
||||
# Played again while the window is open: show that one.
|
||||
win = a.get_active_window() or Window(a)
|
||||
win.present()
|
||||
|
||||
app.connect("activate", activate)
|
||||
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
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env bash
|
||||
# ft — build and run Frametop out of its OCI image (pack/Containerfile).
|
||||
#
|
||||
# The image is the product: one container that holds the frozen toolchain, the
|
||||
# native binaries, and the locked Python environment. Everything Frametop runs
|
||||
# goes through this wrapper, so the integration points (mounts, IPC, devices)
|
||||
# live in exactly one place.
|
||||
#
|
||||
# The wrapper works from two homes:
|
||||
# Repo mode the script sits in a checkout (pack/Containerfile beside it):
|
||||
# development commands build from the sources, and runs mount
|
||||
# the repo at /src/frametop
|
||||
# Installed the script sits in ~/.local/bin (put there by install.sh):
|
||||
# programs run from the image's own copy, no repo needed
|
||||
#
|
||||
# Image reference, in order: $FT_IMAGE, then ~/.config/frametop/image — which
|
||||
# install.sh writes and `ft update` keeps digest-pinned. A moving :latest tag
|
||||
# is refused for what gets deployed (the pinned image and the update source):
|
||||
# what runs on a headset must be reproducible and rollback-able. Local
|
||||
# development is free to use any tag, :latest included.
|
||||
#
|
||||
# Usage:
|
||||
# ft <prog> [args] [both] run a program from /opt/frametop
|
||||
# ft update [both] pull the published image, pin its digest
|
||||
# ft clean [both] remove frametop's leftover containers and
|
||||
# unused frametop images (see below)
|
||||
# Development commands live behind "ft dev" (they need a repo checkout):
|
||||
# ft dev build [repo] build the image from the repo
|
||||
# ft dev shell [repo] interactive shell in the image
|
||||
# ft dev test [repo] run the Python test suites inside the image
|
||||
#
|
||||
# About `ft clean` and the shared podman store: on SteamOS, frametop's
|
||||
# containers share one rootless podman store with Valve's (named
|
||||
# lepton-<context>). `ft clean` touches ONLY objects that are provably
|
||||
# frametop's: containers named frametop-*, and images whose reference
|
||||
# contains a /frametop or frametop: component. Everything else — every
|
||||
# lepton-* container, every dangling image, every cache layer — is left
|
||||
# alone. Never run `podman rm -a`, `podman rmi -a`, or `podman system prune`
|
||||
# on a Frame: those are store-wide and would delete Valve's containers.
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
[ -f "$root/pack/Containerfile" ] && repo_mode=1 || repo_mode=0
|
||||
config_dir=${XDG_CONFIG_HOME:-$HOME/.config}/frametop
|
||||
ref_file=$config_dir/image # installed: the pinned image reference
|
||||
published_file=$config_dir/published # installed: where updates come from
|
||||
|
||||
reject_latest() { # reject_latest <what> <ref>
|
||||
# A reference with neither a tag nor a digest means :latest too. The tag is
|
||||
# after the last "/", so a registry port (host:5000/...) isn't one.
|
||||
local name=${2%%@*} latest=0
|
||||
case ${name##*/} in
|
||||
*:latest) latest=1 ;;
|
||||
*:*) ;;
|
||||
*) [[ $2 == *@sha256:* ]] || latest=1 ;;
|
||||
esac
|
||||
if [ "$latest" = 1 ]; then
|
||||
echo "ft: $2 uses the moving :latest tag — refused for $1." >&2
|
||||
echo " Pin a version tag or a digest (repo@sha256:...) instead," >&2
|
||||
echo " e.g. in $published_file or $ref_file." >&2
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
# The reference updates pull from: $FT_IMAGE_PUBLISHED, then the config file.
|
||||
# It must be configured and versioned — there is no silent :latest default,
|
||||
# and there is no default that appears when nothing is configured.
|
||||
# (Deferred: refreshing the installed wrapper from the image, so wrapper and
|
||||
# image ship as one artifact. The wiring broke CI under Docker; revisit after
|
||||
# the smoke job tells us exactly where.)
|
||||
default_published() {
|
||||
local ref
|
||||
if [ -n "${FT_IMAGE_PUBLISHED:-}" ]; then ref=$FT_IMAGE_PUBLISHED
|
||||
elif [ -r "$published_file" ]; then ref=$(<"$published_file")
|
||||
else
|
||||
echo "ft update: no published reference configured." >&2
|
||||
echo " Write a versioned reference (never :latest) to:" >&2
|
||||
echo " $published_file" >&2
|
||||
echo " e.g. ghcr.io/0x1f6/frametop:v0.2.1 — install.sh does this." >&2
|
||||
exit 2
|
||||
fi
|
||||
reject_latest "the update source" "$ref"
|
||||
echo "$ref"
|
||||
}
|
||||
|
||||
# The reference programs run: $FT_IMAGE, then the pinned file. Installed mode
|
||||
# refuses to run without a pin — a headset must never end up on :latest by
|
||||
# accident, and must always know exactly what image it is running.
|
||||
default_image() {
|
||||
if [ "$repo_mode" = 1 ]; then echo "frametop:local"; return; fi
|
||||
local ref
|
||||
if [ -r "$ref_file" ] && [ -n "$(<"$ref_file")" ]; then ref=$(<"$ref_file")
|
||||
elif [ -n "${FT_IMAGE:-}" ]; then echo "$FT_IMAGE"; return
|
||||
else
|
||||
echo "ft: no image pinned. Nothing has been installed yet." >&2
|
||||
echo " install.sh writes the pinned reference to:" >&2
|
||||
echo " $ref_file" >&2
|
||||
exit 2
|
||||
fi
|
||||
reject_latest "the pinned image" "$ref"
|
||||
echo "$ref"
|
||||
}
|
||||
image=${FT_IMAGE:-}
|
||||
published=${FT_IMAGE_PUBLISHED:-}
|
||||
|
||||
# FT_ENGINE picks one when both are installed (CI builds with docker, and
|
||||
# its runner has podman too).
|
||||
engine=${FT_ENGINE:-$(command -v podman || command -v docker || true)}
|
||||
[ -n "$engine" ] || { echo "ft: need podman or docker on PATH" >&2; exit 1; }
|
||||
|
||||
# Repo mode mounts the checkout; installed mode uses the image's own copy.
|
||||
mounts=()
|
||||
[ "$repo_mode" = 1 ] && mounts+=(-v "$root":/src/frametop)
|
||||
# A container's own network namespace starts with the kernel's datagram queue
|
||||
# of 10; systemd sets 512 on the host. The input relay sends without blocking
|
||||
# and drops what a full queue refuses, so at 10 its burst of key and button
|
||||
# releases loses the last ones (keys-test.py caught this).
|
||||
mounts+=(--sysctl net.unix.max_dgram_qlen=512)
|
||||
|
||||
# How the programs run on the Frame (mounts, devices, groups, namespaces) is
|
||||
# not decided yet: see "The runtime on the Frame" in pack/design.md. Runs here
|
||||
# get no access to the host beyond the repo mount.
|
||||
|
||||
run_in_image() {
|
||||
[ -n "$image" ] || image=$(default_image)
|
||||
# frametop-<program>-<pid>: podman ps names the program, two runs of one
|
||||
# program (python3 a.py, python3 b.py) don't replace each other, and
|
||||
# ft clean finds the leftovers of crashed runs by the prefix.
|
||||
local name
|
||||
name=frametop-$(printf '%s' "${1##*/}" | tr -c 'a-zA-Z0-9._-' '-')-$$
|
||||
exec "$engine" run --rm --name "$name" ${mounts[@]+"${mounts[@]}"} \
|
||||
--workdir /src/frametop "$image" "$@"
|
||||
}
|
||||
|
||||
need_repo() {
|
||||
[ "$repo_mode" = 1 ] || { echo "ft $1: only in a repo checkout (this copy runs installed)" >&2; exit 2; }
|
||||
}
|
||||
|
||||
update() {
|
||||
[ -n "$published" ] || published=$(default_published)
|
||||
reject_latest "the update source" "$published"
|
||||
"$engine" pull "$published"
|
||||
# Pin what was pulled, by digest, so every later run and any bug report
|
||||
# names exactly this image — and rollback is editing one file.
|
||||
local digest
|
||||
if digest=$("$engine" inspect --format '{{index .RepoDigests 0}}' "$published" 2>/dev/null) && [ -n "$digest" ]; then
|
||||
mkdir -p "$config_dir"
|
||||
# Written whole or not at all: an empty pin would stop every run.
|
||||
printf '%s\n' "$digest" > "$ref_file.new" && mv "$ref_file.new" "$ref_file"
|
||||
echo "ft update: pinned $digest"
|
||||
else
|
||||
echo "ft update: could not read a digest for $published — keeping $image." >&2
|
||||
fi
|
||||
}
|
||||
|
||||
clean() {
|
||||
# Frametop's leftovers, and nothing else. See the header: the podman store
|
||||
# is shared with Valve's lepton-* containers, so this only deletes objects
|
||||
# whose name proves they are ours.
|
||||
local found=0 obj
|
||||
while IFS= read -r obj; do
|
||||
[ -n "$obj" ] || continue
|
||||
found=1
|
||||
echo "removing container $obj"
|
||||
"$engine" rm -f "$obj" >/dev/null
|
||||
done < <("$engine" ps -a --format '{{.Names}}' 2>/dev/null | grep '^frametop-' || true)
|
||||
# Images: only references that name frametop, and only ones no container
|
||||
# uses (podman rmi refuses those anyway — the guard is belt and braces).
|
||||
while IFS= read -r obj; do
|
||||
[ -n "$obj" ] || continue
|
||||
case $obj in *frametop*) ;; *) continue ;; esac
|
||||
found=1
|
||||
echo "removing image $obj"
|
||||
"$engine" rmi "$obj" >/dev/null 2>&1 \
|
||||
|| echo " (in use or already gone — left in place)" >&2
|
||||
done < <("$engine" images --format '{{.Repository}}:{{.Tag}} {{.ID}}' 2>/dev/null \
|
||||
| grep -E '(^|[ /])frametop(:| |$)' | awk '{print $1}' || true)
|
||||
[ "$found" = 1 ] || echo "nothing of frametop's to clean"
|
||||
[ "$found" = 1 ] || exit 0
|
||||
}
|
||||
|
||||
case "${1:-help}" in
|
||||
dev)
|
||||
shift
|
||||
image=${FT_IMAGE:-frametop:local} # dev always works against the local build
|
||||
case "${1:-help}" in
|
||||
build) need_repo build
|
||||
exec "$engine" build -f "$root/pack/Containerfile" -t "$image" "$root" ;;
|
||||
shell) need_repo shell
|
||||
exec "$engine" run --rm -it ${mounts[@]+"${mounts[@]}"} --workdir /src/frametop "$image" bash ;;
|
||||
test) need_repo test; run_in_image just test ;;
|
||||
help|-h|--help|"") sed -n '2,40p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' ;;
|
||||
*) echo "ft dev: unknown command: $1 (build, shell, test)" >&2; exit 2 ;;
|
||||
esac ;;
|
||||
update) update ;;
|
||||
clean) clean ;;
|
||||
help|-h|--help)
|
||||
sed -n '2,40p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
[ -z "${1:-}" ] || exit 0 ;;
|
||||
*) [ -z "${1:-}" ] && { echo "ft: no command given" >&2; exit 2; }
|
||||
run_in_image "$@" ;;
|
||||
esac
|
||||
+2
-1
@@ -16,6 +16,7 @@ gaze/tracker/install.sh # our own eye tracker's frame grabber (asks for su
|
||||
gaze/build.sh # build ft-gaze and the panel by hand
|
||||
gaze/probe/install.sh # development: build, and add Frametop Gaze Probe to the app menu
|
||||
gaze/probe/ft-gazeprobe --screen 1
|
||||
scripts/gaze-report.py # why gaze or its calibration doesn't work, with what looks wrong first
|
||||
```
|
||||
|
||||
## Gaze pointer
|
||||
@@ -33,7 +34,7 @@ Gaze as an input method for the whole desktop, without replacing anything of Ste
|
||||
- **The mouse only corrects** (the default; the Gaze page's Mouse movement switch, `POINTER_GAZE_MOUSE_MOVE=held`): while the gaze has the pointer, moving the mouse does nothing. The buttons work like Meta+J and Meta+K: press and hold one and the pointer stops where you look; move the mouse onto what you meant and let go to click there (a left or a right click). Held still for half a second, a press is a real one (to drag). Once you've moved, the left button alone only clicks: press the right one while still holding the left to start a drag there; it lasts while either button is held. Press the right one again (a double right click, the left still held) to pan and tilt what you're dragging, as a right press does during any drag. A bumped or drifting mouse can't pull the pointer away, and every mouse move is a correction, so the tracker only learns from real ones. With the gaze stale for a second (the tracker stopped, eyes lost), in a game, or with the headset off, the mouse moves the pointer as usual. `free` (the switch off) lets the mouse take the pointer any time.
|
||||
- **Keyboard clicks** (Meta+J left, Meta+K right; other key combinations on the Keyboard page of Input Settings): tap to click where you look. A quick tap (let go within 0.25 s, `POINTER_KEY_TAP`) clicks where the dot was when you pressed, whatever your head did, and tells the gaze service it was right there. Hold instead, and the dot stays put in your view: turn your head until it sits on what you meant, and let go to click there (the correction is a lesson, as with the mouse, under the same limit: past `POINTER_GAZE_NUDGE_MAX` it opens the quick check instead). Hold still for half a second to press for real, then turn your head to drag. With Meta+J held, Meta+K presses where the dot is now, so you can correct first and then drag; the drag lasts while either key is held. Meta+K during a Meta+J drag (again, after starting it with Meta+K: a double Meta+K) pans and tilts what you're dragging while it's held: turn your head to turn it.
|
||||
- **Learning from nudges:** if the mouse took the pointer from the gaze and moved it (0.2 degrees or more, and the correction within `POINTER_GAZE_NUDGE_MAX`: 55 degrees by default, half of the 109 the headset shows across, and 1 to 110; the same limit for mouse, keyboard, and pinch clicks) before you clicked, or you dragged a held press that far, you were nudging it onto what you looked at. The helper sends that as a lesson, from the raw gaze when the mouse took over to where you clicked, and ft-gazed learns it. So using it is what calibrates it. The raw gaze is one ft-gazed sent, so it also finds when that look was, and what each eye read then. With SteamVR, each eye learns its own error. With our tracker, the look goes to it as a click, like the probe's, and it relearns how the headset sits on your face. After the headset was off, your first nudge and click there resets that (the quick check's dot does the same). A correction past `POINTER_GAZE_NUDGE_MAX` isn't learned: the helper asks ft-gazed for the quick check instead ("recheck", after its 2-minute cooldown). Tested on our tracker's 409 clicks since its Sep 29 calibration: a one-dot check set from any one of them put the next 2 minutes' clicks within 15 degrees (99% within 4.2) and the next 10 minutes' within 25 (the far ones after the headset moved), so the check gets back well under it. The limit used to be 8 degrees, and live on 2026-10-01 our tracker was 12 off after the headset went on, so every correction was dropped. One lesson moves the whole correction by only a third of what it measured (more near where it was taken), since in the first live test one 6 degree lesson moved everything and put the next target 7 degrees off. `ft-gazectl status` shows the lessons, and `ft-gazectl forget` drops them.
|
||||
- **Checks and calibration in the headset** (`gaze/gazecheck.py`, shown by `gaze/panel/ft-gazepanel`, a panel fixed to the headset that ft-gazed runs): a one-dot quick check opens when you put the headset on (SteamVR's tracker sees your eyes for 3 s after none for 3 s; its "HMD on" log line can't say, since it repeats every minute or so and can stay on for hours with nobody in the headset), when our tracker asks for a click (its "reseat", when the headset may sit differently), at most once every 2 minutes, and from Quick check on the Gaze page. Look at the dot: it takes your gaze once it has held still for 0.6 s (the steadiness counts, not where the tracker puts it, so it works however far off it is), or at once with a left click or Meta+J; a right click or Meta+K closes it, and ignoring it changes nothing. It also runs when a click's correction was past `POINTER_GAZE_NUDGE_MAX`. The dot is still and the ring fills in quarters, so the panel is drawn again only a few times per dot. If the first 3 lessons after it are still over 2 degrees off, five dots follow. The full calibration (Calibrate on the Gaze page, or by itself whenever gaze mode is on without one and your eyes are seen) is the probe's: three rounds, dark, medium and bright, of the middle and a ring around it, in a panel 64 degrees wide, with Frametop's screens hidden. Its dots (and the five-dot check's) wait for a click: look at the dot and left click or press Meta+J, and the gaze held still up to then is taken. A dot that isn't taken says why, on an orange line over the instructions: with SteamVR's tracker, what dropped most of that look's samples (an eye lost, a blink, the two eyes disagreeing); with ours, its reply (an eye seen in too few frames, or moving). A dot gets two tries, then it's skipped. A calibration left with under two thirds of its dots fails and names the most common reason, as the Gaze page does after it. A click that has taken nothing after 1.5 s says what it waits for: the gaze to hold still, or an eye tracker that isn't sending. Capturing whenever the gaze held still sometimes took a look that wasn't on the dot. The panel draws into three shared buffers SteamVR imported once, as Frametop's keyboard does: uploading each picture anew (SetOverlayRaw) flickered, and in one live test left the headset showing an old picture. Quitting it while there's still no calibration turns gaze mode off; turning it on again reopens it. One that closes otherwise unfinished (ignored for 2 minutes, too few dots) opens again after the headset comes off and on. Why gaze mode, on, can't work yet goes in the service's status as `checks.problem`, which the Gaze page shows under the Gaze pointer switch. For our tracker a check is a click and the calibration is its own (calib-point per dot); for SteamVR's, a check is a lesson for each eye and the calibration replaces calibration.json, and the lessons start over. Checks go to `checks.jsonl`.
|
||||
- **Checks and calibration in the headset** (`gaze/gazecheck.py`, shown by `gaze/panel/ft-gazepanel`, a panel fixed to the headset that ft-gazed runs): a one-dot quick check opens when you put the headset on (SteamVR's tracker sees your eyes for 3 s after none for 3 s; its "HMD on" log line can't say, since it repeats every minute or so and can stay on for hours with nobody in the headset), when our tracker asks for a click (its "reseat", when the headset may sit differently), at most once every 2 minutes, and from Quick check on the Gaze page. Look at the dot: it takes your gaze once it has held still for 0.6 s (the steadiness counts, not where the tracker puts it, so it works however far off it is), or at once with a left click or Meta+J; a right click or Meta+K closes it, and ignoring it changes nothing. It also runs when a click's correction was past `POINTER_GAZE_NUDGE_MAX`. The dot is still and the ring fills in quarters, so the panel is drawn again only a few times per dot. If the first 3 lessons after it are still over 2 degrees off, five dots follow: the middle, and 12 degrees left and right and 9 up and down, in a see-through panel 40 degrees wide (the quick check's 16 degree square left four of them off the panel, unseen). The full calibration (Calibrate on the Gaze page, or by itself whenever gaze mode is on without one and your eyes are seen) is the probe's: three rounds, dark, medium and bright, of the middle and a ring around it, in a panel 64 degrees wide, with Frametop's screens hidden. Its dots (and the five-dot check's) wait for a click: look at the dot and left click or press Meta+J, and the gaze held still up to then is taken. Our tracker's first calibration has no gaze to go on, since ft-eyes maps pupils to a gaze only once it has a calibration: it opens once SteamVR's tracker sees an eye and ft-eyes answers, and a click takes the 0.6 s up to it, as long as ft-eyes saw each pupil held still then (before 2026-10-05 it waited for a gaze, so a fresh install could never calibrate ours). A dot that isn't taken says why, on an orange line over the instructions: with SteamVR's tracker, what dropped most of that look's samples (an eye lost, a blink, the two eyes disagreeing); with ours, its reply (an eye seen in too few frames, or moving). A dot gets two tries, then it's skipped. A calibration left with under two thirds of its dots fails and names the most common reason, as the Gaze page does after it. A click that has taken nothing after 1.5 s says what it waits for: the gaze to hold still, or an eye tracker that isn't sending. Capturing whenever the gaze held still sometimes took a look that wasn't on the dot. The panel draws into three shared buffers SteamVR imported once, as Frametop's keyboard does: uploading each picture anew (SetOverlayRaw) flickered, and in one live test left the headset showing an old picture. Quitting it while there's still no calibration turns gaze mode off; turning it on again reopens it. One that closes otherwise unfinished (ignored for 2 minutes, too few dots) opens again after the headset comes off and on. Why gaze mode, on, can't work yet goes in the service's status as `checks.problem`, which the Gaze page shows under the Gaze pointer switch. For our tracker a check is a click and the calibration is its own (calib-point per dot); for SteamVR's, a check is a lesson for each eye and the calibration replaces calibration.json, and the lessons start over. Checks go to `checks.jsonl`.
|
||||
- Nothing writes to SteamVR, its eye tracker, or its files: ft-gaze maps the eye tracker's shared memory read-only. With no fresh gaze (a blink, the service stopped, the headset off), the pointer stays where it is, and the mouse works as always.
|
||||
- **Idle while the gaze isn't used:** ft-gaze and our own tracker run only while gaze mode is on and someone wears the headset (the pointer helper says both: SteamVR drops the headset's activity level as soon as it comes off), while a check or the calibration is open or asked for, or while the Gaze page of Frametop Input Settings is open (it renews a `wake` lease). 30 seconds after the last use they stop, and our frame grabber goes idle with our tracker. With our tracker, that saves over half a core: on 2026-10-02, with gaze mode off, ft-eyes took about 60% of a core, and ft-eyegrab, ft-gaze and ft-gazed 3 to 4% each. A check asked for while it idles starts the tracker and opens once it sends. When the gaze is used again, it takes a few seconds to come back, and our tracker's first click re-seats it, as after the headset was off: so the quick check opens when gaze mode comes on after the service idled, as it does when you put the headset on. `ft-gazectl status` says `"awake"`, and `"idle"` says why it isn't. A stand that covers the proximity sensor makes the headset seem worn, so with gaze mode on it doesn't idle there.
|
||||
|
||||
|
||||
+4
-5
@@ -3,18 +3,17 @@
|
||||
# (gaze/build/; they also run there). The panel draws its text with stb_truetype (public
|
||||
# domain, one header, pinned as in screens/build.sh).
|
||||
# The eye tracking API (IVRInput::GetEyeTrackingDataRelativeToNow) is newer than the header
|
||||
# shipped with SteamVR's samples, so this uses the pinned public header ft-screens fetches.
|
||||
# shipped with SteamVR's samples, so this uses the pinned public header (scripts/openvr.sh).
|
||||
set -euo pipefail
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
"$root/scripts/sync.sh" >/dev/null
|
||||
exec "$root/scripts/frame.sh" -C gaze 'set -e; mkdir -p build/include
|
||||
openvr=v2.15.6
|
||||
[ -f build/include/openvr-$openvr ] || { curl -fsSL "https://raw.githubusercontent.com/ValveSoftware/openvr/$openvr/headers/openvr.h" -o build/include/openvr.h && touch build/include/openvr-$openvr; }
|
||||
. ../scripts/openvr.sh
|
||||
g++ -std=c++17 -O2 -Wall -Wno-unused-parameter -Wno-missing-field-initializers -Ibuild/include -I../pointer/common \
|
||||
-o build/ft-gaze ft-gaze.cpp -L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64 -lpthread
|
||||
-o build/ft-gaze ft-gaze.cpp $OPENVR_LIBS -lpthread
|
||||
stb=2c980bb59875b0d32144a71867fbdebb2f77cd20
|
||||
[ -f build/include/stb-$stb ] || { curl -fsSL "https://raw.githubusercontent.com/nothings/stb/$stb/stb_truetype.h" -o build/include/stb_truetype.h && touch build/include/stb-$stb; }
|
||||
g++ -std=c++17 -O2 -Wall -Wno-unused-parameter -Wno-missing-field-initializers -Ibuild/include $(pkg-config --cflags gbm libdrm) \
|
||||
-o build/ft-gazepanel panel/ft-gazepanel.cpp -L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64 \
|
||||
-o build/ft-gazepanel panel/ft-gazepanel.cpp $OPENVR_LIBS \
|
||||
$(pkg-config --libs gbm libdrm) -lpthread
|
||||
echo "built build/ft-gaze build/ft-gazepanel"'
|
||||
@@ -8,8 +8,8 @@ PartOf=steamvr.service
|
||||
Requisite=steamvr.service
|
||||
|
||||
[Service]
|
||||
# Host Python; it runs ft-gaze in the dev container (distrobox enter), which quits when
|
||||
# the service's pipe to it closes.
|
||||
# Host Python; it runs ft-gaze in the container it was built in (scripts/in-box), which
|
||||
# quits when the service's pipe to it closes.
|
||||
ExecStart=/usr/bin/python3 @REPO@/gaze/ft-gazed
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
+153
-29
@@ -56,8 +56,8 @@
|
||||
// The mmap samples are in head space, 17 ms or so old when they appear, so each is turned
|
||||
// into the room with the head pose at its own timestamp, from a short pose history.
|
||||
//
|
||||
// Screens come from ft-screens (@ft_screens: "screens", "get N"), refreshed 4 times a
|
||||
// second in the background. A curved screen is a cylinder toward its front (see OnSurface
|
||||
// Screens come from ft-screens (@ft_screens: "screens", "remotes" for other machines'
|
||||
// displays, "get N"), refreshed 4 times a second in the background. A curved screen is a cylinder toward its front (see OnSurface
|
||||
// in screens/vr.cpp).
|
||||
#include <openvr.h>
|
||||
|
||||
@@ -98,6 +98,10 @@ double NowRaw() {
|
||||
}
|
||||
|
||||
// --- eye-server.mmap (packed, unaligned: read with memcpy) ---
|
||||
// The SteamOS 0.4.x beta (SteamVR 2.18.2) moved every field from the timestamp on by 5
|
||||
// bytes (measured 2026-10-04 with the ftdiag scan: timestamp 0x157 -> 0x15c, the vectors
|
||||
// moved with it; the counter at 0x38 kept its place). Which layout is live is detected at
|
||||
// runtime (EyeFile::Detect), so one binary serves both generations.
|
||||
constexpr size_t kCounter = 0x38; // u32, one per sample
|
||||
constexpr size_t kTime = 0x157; // f64, CLOCK_MONOTONIC_RAW seconds
|
||||
constexpr size_t kLeft1 = 0x15f, kRight1 = 0x16b; // set 1: unit vectors, head space
|
||||
@@ -110,11 +114,19 @@ constexpr size_t kVar1 = 0x177, kVar2 = 0x1b3;
|
||||
// The measurements the filter is fed: left x, y, right x, y, then the variance of each (left
|
||||
// x, y, right x, y). An eye's pair stops changing while the tracker can't see it.
|
||||
constexpr size_t kMeas = 0x1d3;
|
||||
constexpr size_t kNeed = 0x1f3;
|
||||
constexpr size_t kNeed = 0x1f3 + 5; // enough for either layout
|
||||
constexpr size_t kShifts[] = {0, 5}; // the layouts EyeFile::Detect knows: stable, 0.4.x beta
|
||||
|
||||
struct EyeFile {
|
||||
// Everything from the timestamp on is read at base + shift: 0 on stable, 5 on the
|
||||
// 0.4.x beta (see the constants above). known once Detect has seen that layout's
|
||||
// timestamp tick; before that, reading would yield garbage that still passes
|
||||
// ReadSample's check.
|
||||
size_t shift = 0;
|
||||
bool known = false;
|
||||
const uint8_t *p = nullptr;
|
||||
size_t size = 0;
|
||||
size_t At(size_t base) const { return base + shift; }
|
||||
bool Open() {
|
||||
const int fd = open("/dev/shm/eye-server.mmap", O_RDONLY | O_CLOEXEC);
|
||||
if (fd < 0) return false;
|
||||
@@ -130,6 +142,9 @@ struct EyeFile {
|
||||
size = st.st_size;
|
||||
return true;
|
||||
}
|
||||
// The sample counter, or 0 without the mapping: the one read the loop makes whether or
|
||||
// not the file was there (Get, V and ReadSample need it).
|
||||
uint32_t Counter() const { return p ? Get<uint32_t>(kCounter) : 0; }
|
||||
template <class T> T Get(size_t off) const {
|
||||
T v;
|
||||
std::memcpy(&v, p + off, sizeof v);
|
||||
@@ -140,6 +155,53 @@ struct EyeFile {
|
||||
std::memcpy(f, p + off, sizeof f);
|
||||
return {f[0], f[1], f[2]};
|
||||
}
|
||||
// A live timestamp is near the clock it comes from (its samples are 17 ms or so old).
|
||||
static bool TimePlausible(double t, double now) { return t > now - 2.0 && t <= now + 2.0; }
|
||||
// The eye directions are unit vectors, so their length is a second, independent check
|
||||
// next to the timestamp: a mere coincidence in one field does not confirm a layout.
|
||||
static bool UnitVec(Vec3 v) {
|
||||
const float n = v.x * v.x + v.y * v.y + v.z * v.z;
|
||||
return n > 0.81f && n < 1.21f; // |v| within 0.9 .. 1.1
|
||||
}
|
||||
bool Fits(size_t layout, double now) const {
|
||||
return TimePlausible(Get<double>(kTime + layout), now) && UnitVec(V(kLeft1 + layout)) &&
|
||||
UnitVec(V(kRight1 + layout));
|
||||
}
|
||||
|
||||
// Which layout is live, a step per pass of the loop, so it never stalls it. A layout
|
||||
// fits when its timestamp is near the clock and its two set-1 directions are unit
|
||||
// vectors; it's taken once its timestamp has moved on too, within kConfirm. That allows
|
||||
// for 2 samples a second: the eye server writes 72 or 90 (15 were seen on the beta).
|
||||
// kWaiting: call again on the next pass. kNone: no layout fits (a server that stopped,
|
||||
// SteamVR's tracker warming up, or a layout we don't know), so the mmap stays unused;
|
||||
// try again later. One detection per run is enough: the layout can't change under a
|
||||
// running ft-gaze, since SteamVR starts the eye server that writes the file, and the
|
||||
// gaze service stops and starts with SteamVR.
|
||||
enum Detection { kWaiting, kFound, kNone };
|
||||
static constexpr double kConfirm = 0.5;
|
||||
static constexpr size_t kLayouts = sizeof kShifts / sizeof kShifts[0];
|
||||
Detection Detect(double now) {
|
||||
if (!fit_) {
|
||||
for (size_t i = 0; i < kLayouts; ++i)
|
||||
if (Fits(kShifts[i], now)) fit_ |= 1u << i, t0_[i] = Get<double>(kTime + kShifts[i]);
|
||||
if (!fit_) return kNone;
|
||||
since_ = now;
|
||||
return kWaiting;
|
||||
}
|
||||
for (size_t i = 0; i < kLayouts; ++i)
|
||||
if ((fit_ >> i & 1) && Get<double>(kTime + kShifts[i]) > t0_[i] && Fits(kShifts[i], now)) {
|
||||
fit_ = 0, shift = kShifts[i], known = true;
|
||||
return kFound;
|
||||
}
|
||||
if (now - since_ <= kConfirm) return kWaiting;
|
||||
fit_ = 0;
|
||||
return kNone;
|
||||
}
|
||||
bool Detecting() const { return fit_ != 0; }
|
||||
|
||||
// Detect's state: the layouts that fit at since_ (a bit each), and their timestamps then.
|
||||
unsigned fit_ = 0;
|
||||
double t0_[kLayouts] = {}, since_ = 0;
|
||||
};
|
||||
|
||||
struct EyeSample {
|
||||
@@ -151,20 +213,22 @@ struct EyeSample {
|
||||
};
|
||||
|
||||
// A consistent copy: the writer has no seqlock we can use, so read until the counter and
|
||||
// timestamp are the same before and after.
|
||||
// timestamp are the same before and after. Only call this once the layout is known
|
||||
// (EyeFile::known): on an unknown layout these reads still pass the consistency check,
|
||||
// but yield garbage.
|
||||
bool ReadSample(const EyeFile &f, EyeSample &s) {
|
||||
for (int attempt = 0; attempt < 4; ++attempt) {
|
||||
const uint32_t n0 = f.Get<uint32_t>(kCounter);
|
||||
const double t0 = f.Get<double>(kTime);
|
||||
const double t0 = f.Get<double>(f.At(kTime));
|
||||
std::atomic_thread_fence(std::memory_order_acquire);
|
||||
s.left1 = f.V(kLeft1), s.right1 = f.V(kRight1), s.fix1 = f.V(kFix1);
|
||||
s.left2 = f.V(kLeft2), s.right2 = f.V(kRight2);
|
||||
std::memcpy(s.open, f.p + kOpen, sizeof s.open);
|
||||
std::memcpy(s.var1, f.p + kVar1, sizeof s.var1);
|
||||
std::memcpy(s.var2, f.p + kVar2, sizeof s.var2);
|
||||
std::memcpy(s.meas, f.p + kMeas, sizeof s.meas);
|
||||
s.left1 = f.V(f.At(kLeft1)), s.right1 = f.V(f.At(kRight1)), s.fix1 = f.V(f.At(kFix1));
|
||||
s.left2 = f.V(f.At(kLeft2)), s.right2 = f.V(f.At(kRight2));
|
||||
std::memcpy(s.open, f.p + f.At(kOpen), sizeof s.open);
|
||||
std::memcpy(s.var1, f.p + f.At(kVar1), sizeof s.var1);
|
||||
std::memcpy(s.var2, f.p + f.At(kVar2), sizeof s.var2);
|
||||
std::memcpy(s.meas, f.p + f.At(kMeas), sizeof s.meas);
|
||||
std::atomic_thread_fence(std::memory_order_acquire);
|
||||
if (f.Get<uint32_t>(kCounter) == n0 && f.Get<double>(kTime) == t0) {
|
||||
if (f.Get<uint32_t>(kCounter) == n0 && f.Get<double>(f.At(kTime)) == t0) {
|
||||
s.n = n0, s.t = t0;
|
||||
return true;
|
||||
}
|
||||
@@ -303,18 +367,32 @@ private:
|
||||
Screen s;
|
||||
if (std::sscanf(p, " %d:%dx%d:%lf%n", &s.index, &s.wpx, &s.hpx, &s.metres, &used) != 4) break;
|
||||
p += used;
|
||||
// "ok x y z xx xy xz yx yy yz zx zy zz width height curve hand"
|
||||
const std::string g = Ask(fd, "get " + std::to_string(s.index));
|
||||
double v[15];
|
||||
if (std::sscanf(g.c_str(), "ok %lf %lf %lf %lf %lf %lf %lf %lf %lf %lf %lf %lf %lf %lf %lf", &v[0], &v[1],
|
||||
&v[2], &v[3], &v[4], &v[5], &v[6], &v[7], &v[8], &v[9], &v[10], &v[11], &v[12], &v[13],
|
||||
&v[14]) != 15)
|
||||
continue;
|
||||
s.c = {v[0], v[1], v[2]};
|
||||
s.b = {{v[3], v[4], v[5]}, {v[6], v[7], v[8]}, {v[9], v[10], v[11]}};
|
||||
s.metres = v[12], s.height = v[13], s.curve = v[14];
|
||||
out.push_back(s);
|
||||
if (Place(fd, s)) out.push_back(s);
|
||||
}
|
||||
// Other machines' displays (remote.c): "ok <count> <index>:<client>:<state>:<w>x<h> ..."
|
||||
const std::string remotes = Ask(fd, "remotes");
|
||||
if (remotes.rfind("ok ", 0) != 0) return;
|
||||
p = remotes.c_str() + 3;
|
||||
if (std::sscanf(p, "%d%n", &count, &used) != 1) return;
|
||||
p += used;
|
||||
for (int k = 0; k < count; ++k) {
|
||||
Screen s;
|
||||
if (std::sscanf(p, " %d:%*[^:]:%*[^:]:%dx%d%n", &s.index, &s.wpx, &s.hpx, &used) != 3) break;
|
||||
p += used;
|
||||
if (s.wpx > 0 && s.hpx > 0 && Place(fd, s)) out.push_back(s);
|
||||
}
|
||||
}
|
||||
static bool Place(int fd, Screen &s) {
|
||||
// "ok x y z xx xy xz yx yy yz zx zy zz width height curve hand"
|
||||
const std::string g = Ask(fd, "get " + std::to_string(s.index));
|
||||
double v[15];
|
||||
if (std::sscanf(g.c_str(), "ok %lf %lf %lf %lf %lf %lf %lf %lf %lf %lf %lf %lf %lf %lf %lf", &v[0], &v[1], &v[2],
|
||||
&v[3], &v[4], &v[5], &v[6], &v[7], &v[8], &v[9], &v[10], &v[11], &v[12], &v[13], &v[14]) != 15)
|
||||
return false;
|
||||
s.c = {v[0], v[1], v[2]};
|
||||
s.b = {{v[3], v[4], v[5]}, {v[6], v[7], v[8]}, {v[9], v[10], v[11]}};
|
||||
s.metres = v[12], s.height = v[13], s.curve = v[14];
|
||||
return true;
|
||||
}
|
||||
|
||||
std::thread thread_;
|
||||
@@ -525,7 +603,7 @@ int main(int argc, char **argv) {
|
||||
std::fprintf(stderr, "ft-gaze: action manifest %s: error %d\n", manifest.c_str(), int(me));
|
||||
|
||||
EyeFile eyes;
|
||||
const bool haveMmap = eyes.Open();
|
||||
bool haveMmap = eyes.Open();
|
||||
std::fprintf(stderr, "ft-gaze: eye-server.mmap %s\n", haveMmap ? "open" : "not available");
|
||||
|
||||
OwnFile ownFile;
|
||||
@@ -544,6 +622,19 @@ int main(int argc, char **argv) {
|
||||
// over the dashboard. Games are told apart the way ft-screens does it, by the scene app.
|
||||
bool inGame = false;
|
||||
double nextGameCheck = 0;
|
||||
// The mmap's layout (EyeFile::Detect) is looked for while it isn't known and the eye
|
||||
// server writes: the counter (0x38 in both layouts) ticks once per sample, so a silent
|
||||
// server (headset off) costs nothing and logs nothing. Until a layout is known, SteamVR's
|
||||
// tracker counts as unavailable. "Not recognized" waits until no layout has fitted for
|
||||
// kUnknownAfter of writing, longer than SteamVR's tracker takes to warm up after the
|
||||
// headset goes on (about 20 s), then goes to the journal at most once a minute; ft-gazed
|
||||
// shows it on the Gaze page.
|
||||
constexpr double kUnknownAfter = 30;
|
||||
double nextLayoutCheck = 0, nextLayoutLog = 0, lastWrite = 0, missSince = 0;
|
||||
uint32_t lastCounterSeen = eyes.Counter();
|
||||
// SteamVR's eye tracker creates the mmap a second or two after SteamVR starts, so it can
|
||||
// be missing when we start: look for it again every 2 s until it's there.
|
||||
double nextOpen = NowRaw() + 2.0;
|
||||
|
||||
while (true) {
|
||||
const double now = NowRaw();
|
||||
@@ -559,11 +650,43 @@ int main(int argc, char **argv) {
|
||||
sys->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, &hp, 1);
|
||||
if (hp.bPoseIsValid) history.Add(now, hp.mDeviceToAbsoluteTracking);
|
||||
|
||||
// One line per new eye sample, or at 90 Hz without the mmap.
|
||||
if (!haveMmap && now >= nextOpen) {
|
||||
nextOpen = now + 2.0;
|
||||
if ((haveMmap = eyes.Open())) {
|
||||
std::fprintf(stderr, "ft-gaze: eye-server.mmap open\n");
|
||||
lastCounterSeen = eyes.Counter();
|
||||
}
|
||||
}
|
||||
|
||||
// One line per new eye sample, or at 90 Hz without a usable mmap: none, or one whose
|
||||
// layout isn't known (yet). Our own tracker needs nothing from the mmap, so it keeps
|
||||
// going then; the mmap's sources print as {"ok":0} and "eye" as null.
|
||||
EyeSample s;
|
||||
bool fresh = false;
|
||||
if (haveMmap && ReadSample(eyes, s) && s.n != lastN) fresh = true, lastN = s.n;
|
||||
if (!haveMmap && now - lastEmit >= 1.0 / 90) fresh = true, s.t = now;
|
||||
const uint32_t counter = eyes.Counter();
|
||||
const bool writing = haveMmap && counter != lastCounterSeen;
|
||||
lastCounterSeen = counter;
|
||||
if (writing) {
|
||||
if (now - lastWrite > 2.0) missSince = 0; // it was silent: start counting again
|
||||
lastWrite = now;
|
||||
}
|
||||
if (haveMmap && !eyes.known && (eyes.Detecting() || (writing && now >= nextLayoutCheck))) {
|
||||
const EyeFile::Detection d = eyes.Detect(now);
|
||||
if (d == EyeFile::kFound) {
|
||||
std::fprintf(stderr, "ft-gaze: eye-server.mmap layout: %s\n", eyes.shift ? "beta (+5)" : "stable");
|
||||
} else if (d == EyeFile::kNone) {
|
||||
nextLayoutCheck = now + 1.0;
|
||||
if (!missSince) missSince = now;
|
||||
if (now - missSince >= kUnknownAfter && now >= nextLayoutLog) {
|
||||
nextLayoutLog = now + 60.0;
|
||||
std::fprintf(stderr, "ft-gaze: eye-server.mmap has eye data, but its layout is not recognized: "
|
||||
"SteamVR's eye tracking is unavailable\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
const bool mmapOk = haveMmap && eyes.known;
|
||||
if (mmapOk && ReadSample(eyes, s) && s.n != lastN) fresh = true, lastN = s.n;
|
||||
if (!mmapOk && now - lastEmit >= 1.0 / 90) fresh = true, s.t = now;
|
||||
|
||||
if (fresh && hp.bPoseIsValid) {
|
||||
lastEmit = now;
|
||||
@@ -571,7 +694,7 @@ int main(int argc, char **argv) {
|
||||
const auto list = screens.Get();
|
||||
const vr::HmdMatrix34_t &headNow = hp.mDeviceToAbsoluteTracking;
|
||||
vr::HmdMatrix34_t headThen = headNow;
|
||||
if (haveMmap) history.At(s.t, headThen);
|
||||
if (mmapOk) history.At(s.t, headThen);
|
||||
|
||||
// SteamVR's action: a room-space origin and fixation point, turned into the head
|
||||
// frame so every source reports the same kind of angles.
|
||||
@@ -599,7 +722,8 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
|
||||
std::string m1 = "{\"ok\":0}", m2 = m1, left = m1, right = m1, eye = "null";
|
||||
if (haveMmap) {
|
||||
// Only from a known layout: the unread sample's zeros would print an "unc" of 0 (eyes seen).
|
||||
if (mmapOk) {
|
||||
// lr: the angle between the two eyes' directions. It's a fraction of a degree
|
||||
// normally; when the tracker loses one eye (or during a blink) it jumps.
|
||||
auto lr = [](Vec3 l, Vec3 r) {
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
ft-gazectl status the gaze service (ft-gazed): samples, lessons, the correction
|
||||
ft-gazectl forget drop what the pointer's lessons taught (the calibration stays)
|
||||
ft-gazectl reload the service reads calibration.json again
|
||||
ft-gazectl quickcal|calibrate|fitcheck|fivecheck
|
||||
open that check in the headset panel: the one-dot check, the
|
||||
full calibration, the headset fit check, the five-dot check
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -55,6 +58,11 @@ def main():
|
||||
print(json.dumps(json.loads(r), indent=1))
|
||||
except ValueError:
|
||||
print(r)
|
||||
elif cmd in ("quickcal", "calibrate", "fitcheck", "fivecheck"):
|
||||
r = ask("ft_gazed", cmd)
|
||||
if r is None:
|
||||
sys.exit("the gaze service (ft-gazed) isn't running")
|
||||
print(r)
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
|
||||
|
||||
+20
-12
@@ -103,6 +103,8 @@ Control socket: abstract unix datagram "@ft_gazed":
|
||||
quickcal the one-dot check now (the calibration if there's none)
|
||||
calibrate the full calibration in the panel
|
||||
fitcheck the headset fit check in the panel
|
||||
fivecheck the five-dot check now (it otherwise follows a quick check
|
||||
that didn't fix the tracker)
|
||||
calaccept | calquit from the pointer helper while the panel is up: take this dot
|
||||
now (a left click, Meta+J) | close it (a right click, Meta+K)
|
||||
|
||||
@@ -134,6 +136,7 @@ from gazecheck import Checks # noqa: E402
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
HELPER = REPO / "gaze" / "build" / "ft-gaze"
|
||||
IN_BOX = REPO / "scripts" / "in-box" # runs a program in the container it was built in
|
||||
ME = "\0ft_gazed"
|
||||
POINTER = "\0ft_pointer_helper"
|
||||
EYES_PROG = REPO / "gaze" / "tracker" / "ft-eyes" # our own tracker
|
||||
@@ -267,6 +270,7 @@ class Service:
|
||||
self.checks = Checks(self, self.sel)
|
||||
self.proc = None
|
||||
self.proc_sources = None # what ft-gaze was last told to print
|
||||
self.mmap_unknown = False # ft-gaze said SteamVR's eye-server.mmap has a layout it doesn't know
|
||||
self.buf = b""
|
||||
self.restart_at = 0.0
|
||||
self.running = True
|
||||
@@ -498,19 +502,18 @@ class Service:
|
||||
return
|
||||
env = dict(os.environ)
|
||||
env["XDG_RUNTIME_DIR"] = f"/run/user/{os.getuid()}" # podman needs the real one
|
||||
subprocess.run([str(REPO / "scripts" / "container-up.sh")], env=env, check=False)
|
||||
distrobox = Path.home() / ".local" / "bin" / "distrobox"
|
||||
# ft-gaze quits when its stdin closes: the one thing distrobox passes on.
|
||||
sources = self.wanted_sources()
|
||||
self.proc = subprocess.Popen([str(distrobox), "enter", "dev", "--", str(HELPER), "--watch-stdin",
|
||||
"--sources", sources], env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, start_new_session=True)
|
||||
self.proc = subprocess.Popen([str(IN_BOX), str(HELPER), "--watch-stdin", "--sources", sources], env=env,
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
start_new_session=True)
|
||||
self.proc_sources = sources
|
||||
os.set_blocking(self.proc.stdout.fileno(), False)
|
||||
os.set_blocking(self.proc.stderr.fileno(), False)
|
||||
self.sel.register(self.proc.stdout, selectors.EVENT_READ, "stdout")
|
||||
self.sel.register(self.proc.stderr, selectors.EVENT_READ, "stderr")
|
||||
self.buf = b""
|
||||
self.mmap_unknown = False
|
||||
log("ft-gaze started")
|
||||
|
||||
def stop_helper(self):
|
||||
@@ -531,12 +534,13 @@ class Service:
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
self.proc = None
|
||||
self.mmap_unknown = False
|
||||
|
||||
def eyes_wanted(self):
|
||||
return (self.tracker == "own" and not self.override and self.awake) or time.monotonic() < self.eyes_until
|
||||
|
||||
def start_eyes(self):
|
||||
"""Our own tracker, in the dev container, with build/venv's numpy and OpenCV. Like
|
||||
"""Our own tracker, in the container, with build/venv's numpy and OpenCV. Like
|
||||
ft-gaze, it quits when its stdin closes."""
|
||||
if not EYES_PYTHON.exists():
|
||||
log(f"ft-eyes isn't built: run {REPO}/gaze/tracker/build.sh")
|
||||
@@ -544,11 +548,9 @@ class Service:
|
||||
return
|
||||
env = dict(os.environ)
|
||||
env["XDG_RUNTIME_DIR"] = f"/run/user/{os.getuid()}"
|
||||
subprocess.run([str(REPO / "scripts" / "container-up.sh")], env=env, check=False)
|
||||
distrobox = Path.home() / ".local" / "bin" / "distrobox"
|
||||
self.eyes_proc = subprocess.Popen([str(distrobox), "enter", "dev", "--", str(EYES_PYTHON), str(EYES_PROG), "-v",
|
||||
"--watch-stdin"], env=env, stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, start_new_session=True)
|
||||
self.eyes_proc = subprocess.Popen([str(IN_BOX), str(EYES_PYTHON), str(EYES_PROG), "-v", "--watch-stdin"],
|
||||
env=env, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE, start_new_session=True)
|
||||
os.set_blocking(self.eyes_proc.stderr.fileno(), False)
|
||||
self.sel.register(self.eyes_proc.stderr, selectors.EVENT_READ, "eyes")
|
||||
log("ft-eyes started" + ("" if EYES_CAMS.exists() else
|
||||
@@ -613,6 +615,11 @@ class Service:
|
||||
for line in data.decode("utf-8", "replace").splitlines():
|
||||
if line.strip():
|
||||
log(line)
|
||||
# For the Gaze page (gazecheck's problem): ft-gaze can't read SteamVR's tracker.
|
||||
if "layout is not recognized" in line:
|
||||
self.mmap_unknown = True
|
||||
elif "eye-server.mmap layout:" in line:
|
||||
self.mmap_unknown = False
|
||||
|
||||
def judge_eyes(self, m1, down):
|
||||
"""Which eyes (left, right) are closed, from SteamVR's openness (set 1); updates
|
||||
@@ -783,7 +790,8 @@ class Service:
|
||||
elif words[:1] == ["forget"]:
|
||||
self.forget_lessons()
|
||||
reply = "ok"
|
||||
elif words[:1] and words[0] in ("quickcal", "calibrate", "fitcheck", "calaccept", "calquit", "recheck"):
|
||||
elif words[:1] and words[0] in ("quickcal", "calibrate", "fitcheck", "fivecheck", "calaccept", "calquit",
|
||||
"recheck"):
|
||||
reply = self.checks.command(words)
|
||||
elif words[:1] == ["eyes"] and len(words) == 2:
|
||||
try:
|
||||
|
||||
+81
-17
@@ -14,7 +14,8 @@ directions: look at each one.
|
||||
on is seen only while the gaze service is awake (gaze mode on, someone wearing it: see
|
||||
ft-gazed), so it also opens when gaze mode comes on after the service idled.
|
||||
five the middle and four around it, when the first FIVE_COUNT lessons after a quick check
|
||||
were all over FIVE_LIMIT degrees off: the quick check didn't fix it.
|
||||
were all over FIVE_LIMIT degrees off: the quick check didn't fix it. Its panel is
|
||||
wider than quick's, to hold them (PANEL_DEG).
|
||||
full the calibration, as the gaze probe's: three rounds, dark, medium and bright (pupil
|
||||
size, and the tracker's error with it, changes with brightness), each the middle and
|
||||
a ring of six (SteamVR's tracker) or eight (ours, whose fit goes wrong past its dots)
|
||||
@@ -48,6 +49,13 @@ click with nothing taken after ACCEPT_WAIT, and a failed calibration names its m
|
||||
reason there and in the status. A right click or Meta+K ("calquit") closes the panel. The pointer hides meanwhile ("calpanel 1",
|
||||
renewed every second; the helper shows it again by itself when that stops).
|
||||
|
||||
Our tracker's first calibration: before it has one, ft-eyes publishes no gaze (it maps pupils
|
||||
to a gaze only with a calibration), so there's no gaze to hold still. Its calibration runs
|
||||
anyway ("blind"): someone in the headset (SteamVR's tracker sees an eye) and ft-eyes answering
|
||||
are enough to start it, each dot stands in for the gaze, and a click takes the CHECK_WINDOW up
|
||||
to it. ft-eyes then checks that each pupil was seen and held still in that window (calib-point)
|
||||
and says why not. Without this, a fresh install could never calibrate our tracker.
|
||||
|
||||
What a capture teaches:
|
||||
our tracker quick and five: a click ("click T YAW PITCH", like a pointer lesson); full:
|
||||
calib-start, a calib-point for each dot, calib-fit (its calibration)
|
||||
@@ -165,6 +173,26 @@ def reject_reason(reply, why):
|
||||
return text[:24], f"our tracker said: {text}", False
|
||||
|
||||
|
||||
# The panel's sizes for each check (ft-gazepanel.cpp's kQuickDeg, kFiveDeg, kFullDeg): width in
|
||||
# degrees, and height / width. Every dot of a check must fit its panel (off_panel).
|
||||
PANEL_DEG = {"quick": (16.0, 1.0), "five": (40.0, 0.75), "full": (64.0, 0.75)}
|
||||
|
||||
|
||||
def off_panel(kind, own):
|
||||
"""The dots of a check that wouldn't show whole on its panel: the panel's projection
|
||||
(ft-gazepanel's ToPixel), with a degree to spare for the dot's ring."""
|
||||
wdeg, aspect = PANEL_DEG[kind]
|
||||
half = math.tan(math.radians(wdeg / 2))
|
||||
m = math.tan(math.radians(1.0)) / (2 * half)
|
||||
out = []
|
||||
for yaw, pitch, _ in check_dots(kind, own):
|
||||
x = 0.5 - math.tan(math.radians(yaw)) / (2 * half)
|
||||
y = 0.5 - math.tan(math.radians(pitch)) / math.cos(math.radians(yaw)) / (2 * half) / aspect
|
||||
if not (m <= x <= 1 - m and m / aspect <= y <= 1 - m / aspect):
|
||||
out.append((yaw, pitch))
|
||||
return out
|
||||
|
||||
|
||||
def spread(points):
|
||||
"""The median point and the spread around it (1.4826 x the median distance: a standard
|
||||
deviation that one stray sample can't move far)."""
|
||||
@@ -246,8 +274,7 @@ class Checks:
|
||||
return
|
||||
env = dict(os.environ)
|
||||
env["XDG_RUNTIME_DIR"] = f"/run/user/{os.getuid()}"
|
||||
distrobox = Path.home() / ".local" / "bin" / "distrobox"
|
||||
self.panel_proc = subprocess.Popen([str(distrobox), "enter", "dev", "--", str(PANEL_PROG), "--watch-stdin"],
|
||||
self.panel_proc = subprocess.Popen([str(REPO / "scripts" / "in-box"), str(PANEL_PROG), "--watch-stdin"],
|
||||
env=env, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE, start_new_session=True)
|
||||
os.set_blocking(self.panel_proc.stderr.fileno(), False)
|
||||
@@ -336,9 +363,17 @@ class Checks:
|
||||
return time.monotonic() - self.seen_at < within
|
||||
|
||||
def can_run(self):
|
||||
"""Someone's in the headset and the tracker is sending."""
|
||||
"""Someone's in the headset and the tracker is sending. Our tracker sends no gaze before
|
||||
its first calibration: then ft-eyes answering (calibrated() is False only once it has)
|
||||
is enough, since the calibration is what it needs (see "first calibration" at the top)."""
|
||||
if self.blind():
|
||||
return self.eyes_seen()
|
||||
return self.eyes_seen() and time.monotonic() - self.svc.last_sample < 2
|
||||
|
||||
def blind(self):
|
||||
"""Our tracker is in use, running, and not calibrated yet: it has no gaze to send."""
|
||||
return self.svc.kind == "own" and self.calibrated() is False
|
||||
|
||||
def on_gaze_on(self):
|
||||
self.full_armed = True
|
||||
self.need_full("gaze mode came on without a calibration")
|
||||
@@ -356,7 +391,7 @@ class Checks:
|
||||
if not self.can_run() and self.svc.waking():
|
||||
return # the tracker is still starting (the service idled)
|
||||
if not self.can_run():
|
||||
why = ("the eye tracker isn't sending" if now - self.svc.last_sample >= 2
|
||||
why = ("the eye tracker isn't sending" if now - self.svc.last_sample >= 2 and not self.blind()
|
||||
else "no eyes seen (is the headset on?)")
|
||||
elif now < self.full_retry_at:
|
||||
return
|
||||
@@ -374,7 +409,16 @@ class Checks:
|
||||
def problem(self):
|
||||
"""Why gaze mode, on, can't follow your eyes yet, or None. Our tracker not having said
|
||||
yet is None: Input Settings has its own line for our tracker."""
|
||||
if not self.gaze_on or self.calibrated() is not False:
|
||||
if not self.gaze_on:
|
||||
return None
|
||||
own = self.svc.kind == "own"
|
||||
if self.svc.mmap_unknown and (not own or self.calibrated() is False):
|
||||
# ft-gaze can't read SteamVR's tracker (EyeFile::Detect in ft-gaze.cpp): not its gaze,
|
||||
# and not the eyes it sees, which our tracker's calibration waits for.
|
||||
return ("SteamVR's eye data has a layout Frametop doesn't know (after a SteamOS update?), so "
|
||||
+ ("the calibration can't open" if own else "SteamVR's eye tracker can't be used")
|
||||
+ ". A newer Frametop may know it")
|
||||
if self.calibrated() is not False:
|
||||
return None
|
||||
if self.check and self.check["kind"] == "full":
|
||||
return "Not calibrated yet: the calibration is open in the headset"
|
||||
@@ -408,6 +452,9 @@ class Checks:
|
||||
if not self.can_run():
|
||||
return "error the headset is off or the tracker isn't sending"
|
||||
own = svc.kind == "own"
|
||||
blind = self.blind()
|
||||
if blind and kind != "full":
|
||||
return "error our tracker isn't calibrated yet: use Calibrate"
|
||||
if kind == "full" and own:
|
||||
reply = ask(EYES, "calib-start", 3.0)
|
||||
if not reply.startswith("ok"):
|
||||
@@ -416,15 +463,20 @@ class Checks:
|
||||
now = time.monotonic()
|
||||
self.check = {"kind": kind, "reason": reason, "own": own, "dots": check_dots(kind, own), "i": 0,
|
||||
"started": now, "shown": now, "run": [], "accept": False, "done_at": None, "tries": 0,
|
||||
"skipped": 0, "captured": 0, "points": {}, "reasons": {}, "fit_reasons": set(), "note": ""}
|
||||
log(f"{kind} check: {reason}")
|
||||
"skipped": 0, "captured": 0, "points": {}, "reasons": {}, "fit_reasons": set(), "note": "",
|
||||
"blind": blind}
|
||||
log(f"{kind} check: {reason}" + (" (our tracker's first: no gaze yet, so each click takes the look "
|
||||
"up to it)" if blind else ""))
|
||||
if kind == "full":
|
||||
st = ask(SCREENS, "state", 0.5).split()
|
||||
if len(st) >= 3 and st[0] == "ok":
|
||||
self.screens_shown = (st[2] == "0") if st[1] == "always" else (st[2] == "1")
|
||||
ask(SCREENS, "hide", 0.5)
|
||||
self.to_helper("calpanel 1")
|
||||
self.to_panel(f"show {'full' if kind == 'full' else 'quick'}")
|
||||
off = off_panel(kind, own)
|
||||
if off:
|
||||
log(f"{kind} check: {len(off)} dots off its panel: {off}")
|
||||
self.to_panel(f"show {kind}")
|
||||
self.show_dot()
|
||||
if kind == "quick":
|
||||
self.last_quick = now
|
||||
@@ -519,6 +571,12 @@ class Checks:
|
||||
return
|
||||
if c["own"]:
|
||||
src = s["src"].get("own") or {}
|
||||
if c["blind"]:
|
||||
# Our tracker's first calibration: no gaze yet, so the dot stands in for it (the
|
||||
# gaze can't seem to move) and a click takes the look up to it. ft-eyes checks
|
||||
# the pupils held still (see the top).
|
||||
yaw, pitch, _ = c["dots"][c["i"]]
|
||||
src = {"hy": yaw, "hp": pitch}
|
||||
else:
|
||||
src = s["src"].get("mmap1") or {}
|
||||
if "hy" not in src:
|
||||
@@ -636,9 +694,9 @@ class Checks:
|
||||
if not ok:
|
||||
c["tries"] += 1
|
||||
log(f"{c['kind']} check dot {c['i'] + 1}: not taken: {rec['reason']} ({rec.get('reply', '')})")
|
||||
full = c["kind"] == "full"
|
||||
full, big = c["kind"] == "full", c["kind"] != "quick" # quick's panel holds only short notes
|
||||
if c["tries"] >= 2 or not full:
|
||||
self.note(f"Dot skipped: {long}" + (f" ({FIT_HINT})" if fit else "") if full else f"Skipped: {short}")
|
||||
self.note(f"Dot skipped: {long}" + (f" ({FIT_HINT})" if fit else "") if big else f"Skipped: {short}")
|
||||
self.skip()
|
||||
else:
|
||||
self.note(f"Not taken: {long}. Look at the dot and click again")
|
||||
@@ -773,9 +831,9 @@ class Checks:
|
||||
log(f"{words[0]}, asked for while idle: {reply.removeprefix('error ')}")
|
||||
|
||||
def command(self, words, queue=True):
|
||||
"""quickcal, calibrate, calaccept, calquit -> a reply."""
|
||||
"""quickcal, calibrate, fitcheck, fivecheck, calaccept, calquit -> a reply."""
|
||||
cmd = words[0]
|
||||
if cmd in ("quickcal", "calibrate", "fitcheck") and queue and self.svc.waking() and not self.check:
|
||||
if cmd in ("quickcal", "calibrate", "fitcheck", "fivecheck") and queue and self.svc.waking() and not self.check:
|
||||
# The tracker isn't running (or only just started): wake it, and do this once it sends.
|
||||
self.pending = (words, time.monotonic())
|
||||
self.svc.update_awake()
|
||||
@@ -786,6 +844,8 @@ class Checks:
|
||||
return self.start("full", "asked for")
|
||||
if cmd == "fitcheck":
|
||||
return self.start("fit", "asked for")
|
||||
if cmd == "fivecheck":
|
||||
return self.start("five", "asked for")
|
||||
if cmd == "calaccept":
|
||||
if self.check and self.check["kind"] == "fit":
|
||||
self.check["fit"].toggle_guide(time.monotonic())
|
||||
@@ -840,11 +900,11 @@ class Checks:
|
||||
return
|
||||
if c["accept"] and c["accept_at"] and now - c["accept_at"] > ACCEPT_WAIT:
|
||||
# Clicked, but no capture yet (see on_sample): say what it's waiting for.
|
||||
full = c["kind"] == "full"
|
||||
big = c["kind"] != "quick"
|
||||
if now - c.get("gaze_at", 0.0) > 0.5:
|
||||
self.note("Waiting: the eye tracker isn't sending a gaze" if full else "Waiting: no gaze")
|
||||
self.note("Waiting: the eye tracker isn't sending a gaze" if big else "Waiting: no gaze")
|
||||
else:
|
||||
self.note("Waiting for your gaze to hold still on the dot" if full else "Hold your look still")
|
||||
self.note("Waiting for your gaze to hold still on the dot" if big else "Hold your look still")
|
||||
if c["kind"] == "quick" and now - c["started"] > QUICK_TIMEOUT:
|
||||
self.close("ignored")
|
||||
elif c["kind"] != "quick" and now - c["shown"] > CLICK_IDLE:
|
||||
@@ -868,7 +928,11 @@ class Checks:
|
||||
self.back_since = None # gone again before DON_DELAY
|
||||
elif self.back_since is not None and now - self.back_since >= DON_DELAY:
|
||||
self.away, self.back_since = False, None
|
||||
self.full_armed = True # a calibration that closed unfinished opens again
|
||||
if not self.check:
|
||||
# A calibration that closed unfinished opens again. Not one still open: gaze mode
|
||||
# coming on wakes our tracker, so its eyes come back just as the calibration
|
||||
# opens, and re-arming then opened a second one when the first ended.
|
||||
self.full_armed = True
|
||||
self.auto_quick("the headset went on")
|
||||
if svc.kind == "own" and now - svc.own_at < 5:
|
||||
reseat = any(e.get("reseat") for e in (svc.own.get("eyes") or {}).values())
|
||||
|
||||
@@ -7,14 +7,17 @@
|
||||
//
|
||||
// The panel sits POINTER-like at --distance (1.5 m, about where Frametop's screens are, so
|
||||
// the eyes converge as they do in use). "quick" is a small square, QUICK_DEG across, for the
|
||||
// one-dot check; "full" is FULL_DEG across (4:3), with a solid background whose brightness the
|
||||
// service sets per round (pupil size changes with it, and the tracker's error with it); "fit"
|
||||
// is FIT_DEG across (4:3), see-through like quick, for the headset fit check: a card per eye
|
||||
// (tracked or lost, the tracker's signal, how much of the last 10 s it was seen) and hints.
|
||||
// one-dot check; "five" is FIVE_DEG across (4:3), see-through like quick, for the five-dot
|
||||
// check, whose dots are 12 degrees left and right and 9 up and down; "full" is FULL_DEG
|
||||
// across (4:3), with a solid background whose brightness the service sets per round (pupil
|
||||
// size changes with it, and the tracker's error with it); "fit" is FIT_DEG across (4:3),
|
||||
// see-through like quick, for the headset fit check: a card per eye (tracked or lost, the
|
||||
// tracker's signal, how much of the last 10 s it was seen) and hints. Every dot a check shows
|
||||
// must fit its panel: gazecheck.py's PANEL_DEG mirrors these sizes and checks it.
|
||||
//
|
||||
// Control socket: abstract unix datagram "@ft_gazepanel" (--socket NAME); a sender with an
|
||||
// address gets "ok" or "error ...":
|
||||
// show quick|full|fit the panel, empty, in front of you
|
||||
// show quick|five|full|fit the panel, empty, in front of you
|
||||
// hide
|
||||
// bg <0..1> the background's brightness (full)
|
||||
// dot <yaw> <pitch> <state> [<progress 0..1>]
|
||||
@@ -71,7 +74,8 @@ using Clock = std::chrono::steady_clock;
|
||||
constexpr double kQuickDeg = 16; // QUICK_DEG: the one-dot check's square
|
||||
constexpr double kFullDeg = 64; // FULL_DEG: the full calibration's width (4:3)
|
||||
constexpr double kFitDeg = 40; // FIT_DEG: the headset fit check's width (4:3)
|
||||
constexpr int kQuickPx = 320, kFullW = 1024, kFullH = 768, kFitW = 800, kFitH = 600;
|
||||
constexpr double kFiveDeg = 40; // FIVE_DEG: the five-dot check's width (4:3), past its dots
|
||||
constexpr int kQuickPx = 320, kFullW = 1024, kFullH = 768, kFitW = 800, kFitH = 600, kFiveW = 800, kFiveH = 600;
|
||||
std::atomic<bool> g_stop{false};
|
||||
|
||||
// ---------------------------------------------------------------- text (as screens/keyboard.cpp)
|
||||
@@ -471,9 +475,10 @@ int main(int argc, char **argv) {
|
||||
if (!std::strncmp(buf, "show ", 5)) {
|
||||
p.full = !std::strcmp(buf + 5, "full");
|
||||
p.fit = !std::strcmp(buf + 5, "fit");
|
||||
p.w = p.full ? kFullW : p.fit ? kFitW : kQuickPx;
|
||||
p.h = p.full ? kFullH : p.fit ? kFitH : kQuickPx;
|
||||
p.wDeg = p.full ? kFullDeg : p.fit ? kFitDeg : kQuickDeg;
|
||||
const bool five = !std::strcmp(buf + 5, "five");
|
||||
p.w = p.full ? kFullW : p.fit ? kFitW : five ? kFiveW : kQuickPx;
|
||||
p.h = p.full ? kFullH : p.fit ? kFitH : five ? kFiveH : kQuickPx;
|
||||
p.wDeg = p.full ? kFullDeg : p.fit ? kFitDeg : five ? kFiveDeg : kQuickDeg;
|
||||
p.title.clear(), p.text.clear(), p.note.clear(), p.dotOn = false, p.state = "off";
|
||||
p.eyes[0] = p.eyes[1] = EyeCard{}, p.hints.clear();
|
||||
place();
|
||||
|
||||
@@ -203,7 +203,7 @@ class GazeReader:
|
||||
return
|
||||
print(line, file=sys.stderr)
|
||||
text = line.removeprefix("ft-gaze: ")
|
||||
if not text.startswith(("action manifest", "eye-server.mmap open")):
|
||||
if not text.startswith(("action manifest", "eye-server.mmap open", "eye-server.mmap layout:")):
|
||||
self.last_err = text # worth repeating if ft-gaze stops (not the startup lines)
|
||||
self.on_status(text)
|
||||
stream.read_line_async(GLib.PRIORITY_DEFAULT, self.cancel, got)
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ frame="$root/scripts/frame.sh"
|
||||
unit=frametop-gaze.service
|
||||
case ${1:-status} in
|
||||
install)
|
||||
"$root/gaze/build.sh"
|
||||
[ "$FRAME_RELEASE" = 1 ] || "$root/gaze/build.sh"
|
||||
fill_template "$root/gaze/$unit" | on_frame "mkdir -p ~/.config/systemd/user && cat > ~/.config/systemd/user/$unit"
|
||||
on_frame "chmod +x gaze/ft-gazed gaze/ft-gazectl"
|
||||
"$frame" --host "set -e; systemctl --user daemon-reload; systemctl --user enable $unit
|
||||
|
||||
Executable
+241
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline test of our own eye tracker's first calibration (gaze/gazecheck.py, "blind"): before
|
||||
it has a calibration, ft-eyes publishes no gaze, and the calibration must still open and take
|
||||
its dots. Until 2026-10-05 it couldn't: a fresh install that chose our tracker never calibrated.
|
||||
|
||||
Runs ft-gazed's Service with its sockets renamed and HOME in a temp folder (state and settings
|
||||
go there), a fake pointer helper (gaze mode on, headset worn), a fake ft-gaze (SteamVR sees both
|
||||
eyes; "own" is {"ok":0}, as with an uncalibrated ft-eyes), a fake ft-eyes control socket, and no
|
||||
panel (a stand-in process). Nothing reaches the live gaze service, the pointer helper, ft-eyes,
|
||||
or SteamVR, so it's safe next to them.
|
||||
|
||||
gaze/test/first-calibration-test.py
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
HOME = tempfile.mkdtemp(prefix="ft-gaze-first-cal-test-")
|
||||
os.environ["HOME"] = HOME # before gazecal: its STATE, and frametop.conf, follow HOME
|
||||
|
||||
import importlib.machinery # noqa: E402
|
||||
import importlib.util # noqa: E402
|
||||
import json # noqa: E402
|
||||
import selectors # noqa: E402
|
||||
import shutil # noqa: E402
|
||||
import socket # noqa: E402
|
||||
import subprocess # noqa: E402
|
||||
import sys # noqa: E402
|
||||
import threading # noqa: E402
|
||||
import time # noqa: E402
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
GAZE = os.path.join(HERE, "..")
|
||||
sys.path.insert(0, GAZE)
|
||||
loader = importlib.machinery.SourceFileLoader("ftgazed", os.path.join(GAZE, "ft-gazed"))
|
||||
gazed = importlib.util.module_from_spec(importlib.util.spec_from_loader("ftgazed", loader))
|
||||
loader.exec_module(gazed)
|
||||
import gazecheck # noqa: E402 (the module ft-gazed imported)
|
||||
|
||||
tag = f"ft_gaze_first_cal_test_{os.getpid()}"
|
||||
gazed.ME = f"\0{tag}_gazed"
|
||||
gazed.POINTER = gazecheck.POINTER = f"\0{tag}_helper"
|
||||
gazecheck.SCREENS = f"\0{tag}_screens"
|
||||
gazecheck.PANEL = f"\0{tag}_panel"
|
||||
gazed.EYES_SOCKET = gazecheck.EYES = f"\0{tag}_eyes"
|
||||
gazed.read_settings = lambda: ("own", "auto", "auto", 55.0)
|
||||
DOTS = 3
|
||||
real_dots = gazecheck.check_dots
|
||||
gazecheck.check_dots = lambda kind, own: real_dots(kind, own)[:DOTS] # a short calibration
|
||||
logs = []
|
||||
gazed.log = gazecheck.log = lambda msg: logs.append(msg)
|
||||
|
||||
# The fake ft-gaze: 90 samples a second, SteamVR sees both eyes, no gaze from ours.
|
||||
FAKE = os.path.join(HOME, "ft-gaze")
|
||||
with open(FAKE, "w") as f:
|
||||
f.write('''import json, os, select, sys, time
|
||||
while True:
|
||||
if select.select([sys.stdin], [], [], 1 / 90)[0]:
|
||||
if not os.read(0, 4096):
|
||||
break
|
||||
print(json.dumps({"t": time.monotonic(), "src": {"mmap1": {"hy": 1.0, "hp": 2.0, "unc": [0.001, 0.001],
|
||||
"open": [0.8, 0.8]}, "own": {"ok": 0}}}), flush=True)
|
||||
''')
|
||||
SLEEPER = [sys.executable, "-c", "import sys; sys.stdin.read()"] # quits when its stdin closes
|
||||
|
||||
|
||||
def start_helper(self):
|
||||
"""ft-gaze, straight from here instead of the dev container."""
|
||||
self.proc = subprocess.Popen([sys.executable, FAKE], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE)
|
||||
self.proc_sources = self.wanted_sources()
|
||||
os.set_blocking(self.proc.stdout.fileno(), False)
|
||||
os.set_blocking(self.proc.stderr.fileno(), False)
|
||||
self.sel.register(self.proc.stdout, selectors.EVENT_READ, "stdout")
|
||||
self.sel.register(self.proc.stderr, selectors.EVENT_READ, "stderr")
|
||||
self.buf = b""
|
||||
|
||||
|
||||
def start_eyes(self):
|
||||
"""ft-eyes' process: a stand-in. Its control socket is the fake below."""
|
||||
self.eyes_proc = subprocess.Popen(SLEEPER, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
os.set_blocking(self.eyes_proc.stderr.fileno(), False)
|
||||
self.sel.register(self.eyes_proc.stderr, selectors.EVENT_READ, "eyes")
|
||||
|
||||
|
||||
def start_panel(self):
|
||||
self.panel_proc = subprocess.Popen(SLEEPER, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
os.set_blocking(self.panel_proc.stderr.fileno(), False)
|
||||
self.sel.register(self.panel_proc.stderr, selectors.EVENT_READ, "panel")
|
||||
|
||||
|
||||
gazed.Service.start_helper = start_helper
|
||||
gazed.Service.start_eyes = start_eyes
|
||||
gazecheck.Checks.start_panel = start_panel
|
||||
|
||||
# The fake ft-eyes: uncalibrated until calib-fit. "fail" answers the next calib-point with that.
|
||||
eyes_state = {"cal": None, "points": [], "fail": None}
|
||||
eyes = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
eyes.bind(gazed.EYES_SOCKET)
|
||||
eyes.settimeout(0.2)
|
||||
|
||||
|
||||
def eyes_answer():
|
||||
while True:
|
||||
try:
|
||||
data, addr = eyes.recvfrom(512)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
return
|
||||
w = data.decode().split()
|
||||
if w[0] == "status":
|
||||
reply = json.dumps({"calibration": eyes_state["cal"], "calibrating": False, "dots": 0,
|
||||
"eyes": {"right": {"reseat": False}, "left": {"reseat": False}}})
|
||||
elif w[0] == "calib-start":
|
||||
eyes_state["points"] = []
|
||||
reply = "ok"
|
||||
elif w[0] == "calib-point":
|
||||
eyes_state["points"].append(tuple(map(float, w[1:5])))
|
||||
reply, eyes_state["fail"] = eyes_state["fail"] or "ok 50 50 1.00 1.00", None
|
||||
elif w[0] == "calib-fit":
|
||||
eyes_state["cal"] = {"made": "test", "dots": len(eyes_state["points"])}
|
||||
reply = f"ok {len(eyes_state['points'])} dots"
|
||||
else:
|
||||
reply = f"fail unknown command {w[0]}"
|
||||
if addr:
|
||||
eyes.sendto(reply.encode(), addr)
|
||||
|
||||
|
||||
threading.Thread(target=eyes_answer, daemon=True).start()
|
||||
|
||||
helper_state = {"reply": "ok off worn", "heard": []}
|
||||
helper = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
helper.bind(gazed.POINTER)
|
||||
helper.settimeout(0.2)
|
||||
|
||||
|
||||
def helper_answer():
|
||||
while True:
|
||||
try:
|
||||
data, addr = helper.recvfrom(512)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
return
|
||||
if data.startswith(b"gaze ?") and addr:
|
||||
helper.sendto(helper_state["reply"].encode(), addr)
|
||||
else:
|
||||
helper_state["heard"].append(data.decode())
|
||||
|
||||
|
||||
threading.Thread(target=helper_answer, daemon=True).start()
|
||||
svc = gazed.Service(None, False, gazed.POINTER)
|
||||
threading.Thread(target=svc.run, daemon=True).start()
|
||||
ctl = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
ctl.bind("")
|
||||
ctl.settimeout(3)
|
||||
|
||||
|
||||
def ask(cmd):
|
||||
ctl.sendto(cmd.encode(), gazed.ME)
|
||||
return ctl.recv(65536).decode()
|
||||
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def check(label, got, want):
|
||||
ok = got == want
|
||||
print(("ok " if ok else "FAIL ") + label + ("" if ok else f": got {got!r}, want {want!r}"), flush=True)
|
||||
if not ok:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
def wait(cond, seconds):
|
||||
end = time.monotonic() + seconds
|
||||
while time.monotonic() < end:
|
||||
if cond():
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
return cond()
|
||||
|
||||
|
||||
def check_state():
|
||||
return svc.checks.check or {}
|
||||
|
||||
|
||||
def take_dot(i):
|
||||
"""Wait for dot i to settle, then click: is it taken?"""
|
||||
wait(lambda: check_state().get("i") == i and not check_state().get("done_at"), 3)
|
||||
time.sleep(gazecheck.CHECK_SETTLE + gazecheck.CHECK_WINDOW + 0.1)
|
||||
before = check_state().get("captured", 0)
|
||||
ask("calaccept")
|
||||
return wait(lambda: check_state().get("captured", 0) > before, 2)
|
||||
|
||||
|
||||
check("gaze mode off, Gaze page open (wake): ours runs, uncalibrated", ask("wake 60"), "ok")
|
||||
check("the service knows ours has no calibration", wait(lambda: svc.checks.calibrated() is False, 6), True)
|
||||
# ft-eyes' status can come before the fake ft-gaze's first sample, and without one the refusal
|
||||
# below is "the headset is off" instead (failed about 1 run in 4 until 2026-10-06).
|
||||
check("SteamVR sees the eyes (the fake ft-gaze is sending)", wait(svc.checks.eyes_seen, 6), True)
|
||||
check("a quick check is refused while ours has no calibration", svc.checks.start("quick", "test"),
|
||||
"error our tracker isn't calibrated yet: use Calibrate")
|
||||
helper_state["reply"] = "ok on worn"
|
||||
check("gaze mode on: the calibration opens by itself", wait(lambda: check_state().get("kind") == "full", 6), True)
|
||||
check("it runs blind (no gaze from ours yet)", check_state().get("blind"), True)
|
||||
check("nothing says it can't open", any("can't open" in m for m in logs), False)
|
||||
# Live 2026-10-05: turning gaze mode on woke our tracker, and the calibration opened before
|
||||
# DON_DELAY of eyes had passed, so "the headset went on" came while it was open. That re-armed
|
||||
# it, and a second calibration opened as soon as the first ended.
|
||||
svc.checks.away, svc.checks.back_since = True, None
|
||||
|
||||
check("dot 1: a click takes it", take_dot(0), True)
|
||||
check("the headset went on while it was open",
|
||||
wait(lambda: not svc.checks.away, gazecheck.DON_DELAY + 2) and bool(svc.checks.check), True)
|
||||
t0, t1, yaw, pitch = eyes_state["points"][0]
|
||||
dot = gazecheck.check_dots("full", True)[0]
|
||||
check("its window is the look up to the click (about CHECK_WINDOW)",
|
||||
abs((t1 - t0) - gazecheck.CHECK_WINDOW) < 0.15, True)
|
||||
check("ft-eyes got that dot's direction", (round(yaw, 3), round(pitch, 3)), (round(dot[0], 3), round(dot[1], 3)))
|
||||
|
||||
eyes_state["fail"] = "fail the left eye was seen in only 3 frames"
|
||||
wait(lambda: check_state().get("i") == 1 and not check_state().get("done_at"), 3)
|
||||
time.sleep(gazecheck.CHECK_SETTLE + gazecheck.CHECK_WINDOW + 0.1)
|
||||
ask("calaccept")
|
||||
check("ft-eyes refusing a dot: its reason reaches the panel's note",
|
||||
wait(lambda: "left eye in only 3 frames" in check_state().get("note", ""), 2), True)
|
||||
check("dot 2, second try: taken", take_dot(1), True)
|
||||
check("dot 3: taken", take_dot(2), True)
|
||||
|
||||
check("all dots: ours fits its calibration (calib-fit)",
|
||||
wait(lambda: eyes_state["cal"] is not None and not svc.checks.check, 4), True)
|
||||
check("the service sees it calibrated", wait(lambda: svc.checks.calibrated() is True, 4), True)
|
||||
check("and gaze mode stays on", "gaze off" in helper_state["heard"], False)
|
||||
check("and no second calibration opens", wait(lambda: svc.checks.check is not None, 3), False)
|
||||
check("one calibration in the log", sum(m.startswith("full check:") for m in logs), 1)
|
||||
|
||||
print("FAILED: " + ", ".join(failures) if failures else "all passed", flush=True)
|
||||
svc.running = False
|
||||
time.sleep(0.7)
|
||||
shutil.rmtree(HOME, ignore_errors=True)
|
||||
os._exit(1 if failures else 0)
|
||||
@@ -0,0 +1,180 @@
|
||||
// Offline test of ft-gaze's eye-server.mmap layout detection (EyeFile::Detect in
|
||||
// gaze/ft-gaze.cpp) and of reading a sample at the layout it found, on a made-up file in
|
||||
// memory: no SteamVR, no eye tracker. Detect takes the time as an argument, so the passes
|
||||
// of ft-gaze's loop are played here with a made-up clock. mmap-layout-test.sh builds and
|
||||
// runs it in the dev container.
|
||||
#define main ft_gaze_main
|
||||
#include "../ft-gaze.cpp"
|
||||
#undef main
|
||||
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
int failures = 0;
|
||||
#define CHECK(c) \
|
||||
do { \
|
||||
if (!(c)) std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #c), ++failures; \
|
||||
} while (0)
|
||||
|
||||
// The file as the eye server writes it, one sample at a time, with every field from the
|
||||
// timestamp on moved by `shift` (0 stable, 5 the 0.4.x beta).
|
||||
struct File {
|
||||
std::vector<uint8_t> bytes = std::vector<uint8_t>(324122); // eye-server.mmap's size
|
||||
EyeFile eyes;
|
||||
uint32_t n = 0;
|
||||
File() { eyes.p = bytes.data(), eyes.size = bytes.size(); }
|
||||
template <class T> void Put(size_t off, const T &v) { std::memcpy(bytes.data() + off, &v, sizeof v); }
|
||||
void Sample(size_t shift, double t, bool leftLost = false) {
|
||||
const float left[3] = {0.05f, 0.02f, -0.9985f}, right[3] = {-0.05f, 0.02f, -0.9985f}, none[3] = {};
|
||||
Put(kCounter, ++n);
|
||||
Put(kTime + shift, t);
|
||||
Put(kLeft1 + shift, leftLost ? none : left);
|
||||
Put(kRight1 + shift, right);
|
||||
Put(kLeft2 + shift, left);
|
||||
Put(kRight2 + shift, right);
|
||||
const float open[2] = {0.8f, 0.7f}, var[6] = {1e-3f, 2e-3f, 3e-3f, 4e-3f, 5e-3f, 6e-3f};
|
||||
const float meas[8] = {0.1f, 0.2f, 0.3f, 0.4f, 2e-5f, 3e-5f, 4e-5f, 5e-5f}, fix[3] = {0, 0.02f, -0.6f};
|
||||
Put(kFix1 + shift, fix);
|
||||
Put(kVar1 + shift, var);
|
||||
Put(kVar2 + shift, var);
|
||||
Put(kOpen + shift, open);
|
||||
Put(kMeas + shift, meas);
|
||||
}
|
||||
};
|
||||
|
||||
constexpr double kStart = 5000; // the made-up CLOCK_MONOTONIC_RAW at the first pass
|
||||
constexpr double kPass = 0.004; // ft-gaze's loop
|
||||
constexpr double kAge = 0.017; // how old a sample is when it appears
|
||||
|
||||
// Samples every 1/hz s in `shift`'s layout, Detect called on every loop pass from the first
|
||||
// sample on (as ft-gaze does once the counter moved), until it decides or `until` s pass.
|
||||
// Returns the outcome and when (s after the first sample) in `at`.
|
||||
EyeFile::Detection Run(File &f, size_t shift, double hz, double until, double &at) {
|
||||
double next = kStart;
|
||||
for (double now = kStart; now < kStart + until; now += kPass) {
|
||||
if (now >= next) f.Sample(shift, now - kAge), next += 1 / hz;
|
||||
const EyeFile::Detection d = f.eyes.Detect(now);
|
||||
if (d != EyeFile::kWaiting) {
|
||||
at = now - kStart;
|
||||
return d;
|
||||
}
|
||||
}
|
||||
at = until;
|
||||
return EyeFile::kWaiting;
|
||||
}
|
||||
|
||||
void Layouts() {
|
||||
for (const size_t shift : {size_t(0), size_t(5)}) {
|
||||
File f;
|
||||
double at;
|
||||
CHECK(Run(f, shift, 90, 2, at) == EyeFile::kFound);
|
||||
CHECK(f.eyes.known && f.eyes.shift == shift);
|
||||
CHECK(at <= 1 / 90.0 + kPass); // the next sample confirms it
|
||||
}
|
||||
}
|
||||
|
||||
void SlowWriters() {
|
||||
// 15 a second, as seen on the beta: the next sample, 67 ms on, confirms it (PR #26's
|
||||
// fixed 60 ms wait missed about 1 try in 10 there).
|
||||
File beta;
|
||||
double at;
|
||||
CHECK(Run(beta, 5, 15, 2, at) == EyeFile::kFound && beta.eyes.shift == 5);
|
||||
CHECK(at > 1 / 15.0 - kPass && at <= 1 / 15.0 + kPass);
|
||||
// 3 a second still works (within kConfirm); 1 a second can't confirm in time.
|
||||
File slow;
|
||||
CHECK(Run(slow, 0, 3, 2, at) == EyeFile::kFound && slow.eyes.shift == 0);
|
||||
File slower;
|
||||
CHECK(Run(slower, 0, 1, 2, at) == EyeFile::kNone && !slower.eyes.known);
|
||||
CHECK(at > EyeFile::kConfirm && at < EyeFile::kConfirm + 2 * kPass);
|
||||
CHECK(!slower.eyes.Detecting()); // and the next try starts over
|
||||
}
|
||||
|
||||
void Refused() {
|
||||
// A layout we don't know: the same fields, moved by some other amount.
|
||||
for (size_t shift = 1; shift <= 16; ++shift) {
|
||||
if (shift == 5) continue;
|
||||
File f;
|
||||
double at;
|
||||
const EyeFile::Detection d = Run(f, shift, 90, 1, at);
|
||||
CHECK(d == EyeFile::kNone && !f.eyes.known);
|
||||
if (d != EyeFile::kNone) std::printf(" (moved by %zu)\n", shift);
|
||||
}
|
||||
// A server that stopped: its last sample is 10 s old. Refused at once, no waiting.
|
||||
File stale;
|
||||
stale.Sample(0, kStart - 10);
|
||||
CHECK(stale.eyes.Detect(kStart) == EyeFile::kNone && !stale.eyes.Detecting());
|
||||
// One that stopped just now: its timestamp fits but never moves on.
|
||||
File stopped;
|
||||
stopped.Sample(0, kStart - kAge);
|
||||
CHECK(stopped.eyes.Detect(kStart) == EyeFile::kWaiting);
|
||||
EyeFile::Detection d = EyeFile::kWaiting;
|
||||
double now = kStart;
|
||||
while (d == EyeFile::kWaiting && now < kStart + 2) d = stopped.eyes.Detect(now += kPass);
|
||||
CHECK(d == EyeFile::kNone && !stopped.eyes.known);
|
||||
// A set-1 direction that isn't a unit vector (here zeros).
|
||||
File warm;
|
||||
warm.Sample(0, kStart - kAge, true);
|
||||
CHECK(warm.eyes.Detect(kStart) == EyeFile::kNone);
|
||||
// An empty file (the server never wrote).
|
||||
File empty;
|
||||
CHECK(empty.eyes.Detect(kStart) == EyeFile::kNone);
|
||||
}
|
||||
|
||||
void TornWrite() {
|
||||
// A pass that reads the timestamp mid-write (the counter already moved on, the top half
|
||||
// of the new timestamp not written yet, so it's nowhere near the clock) keeps waiting:
|
||||
// the next pass confirms it.
|
||||
File f;
|
||||
f.Sample(0, kStart - kAge);
|
||||
CHECK(f.eyes.Detect(kStart) == EyeFile::kWaiting);
|
||||
const double next = kStart + 0.011 - kAge;
|
||||
std::memcpy(f.bytes.data() + kTime, &next, 4);
|
||||
std::memset(f.bytes.data() + kTime + 4, 0, 4);
|
||||
f.Put(kCounter, ++f.n);
|
||||
CHECK(f.eyes.Detect(kStart + 0.012) == EyeFile::kWaiting);
|
||||
f.Put(kTime, next);
|
||||
CHECK(f.eyes.Detect(kStart + 0.016) == EyeFile::kFound && f.eyes.shift == 0);
|
||||
}
|
||||
|
||||
void ReadsTheLayoutFound() {
|
||||
// After detection on the beta, a sample comes from the moved fields.
|
||||
File f;
|
||||
double at;
|
||||
CHECK(Run(f, 5, 90, 1, at) == EyeFile::kFound && f.eyes.shift == 5);
|
||||
f.Sample(5, kStart + 1);
|
||||
EyeSample s;
|
||||
CHECK(ReadSample(f.eyes, s));
|
||||
CHECK(s.n == f.n && s.t == kStart + 1);
|
||||
CHECK(std::fabs(s.left1.x - 0.05) < 1e-6 && std::fabs(s.right2.x + 0.05) < 1e-6);
|
||||
CHECK(std::fabs(s.fix1.z + 0.6) < 1e-6);
|
||||
CHECK(s.open[0] == 0.8f && s.open[1] == 0.7f);
|
||||
CHECK(s.var1[5] == 6e-3f && s.var2[0] == 1e-3f && s.meas[3] == 0.4f && s.meas[7] == 5e-5f);
|
||||
}
|
||||
|
||||
void NoMapping() {
|
||||
// ft-gaze without the file: the loop's one read of it gives 0, and touches no memory.
|
||||
EyeFile none;
|
||||
CHECK(none.Counter() == 0);
|
||||
File f;
|
||||
f.Sample(0, kStart);
|
||||
CHECK(f.eyes.Counter() == f.n);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
Layouts();
|
||||
SlowWriters();
|
||||
Refused();
|
||||
TornWrite();
|
||||
ReadsTheLayoutFound();
|
||||
NoMapping();
|
||||
if (failures) {
|
||||
std::printf("%d failed\n", failures);
|
||||
return 1;
|
||||
}
|
||||
std::printf("all passed\n");
|
||||
return 0;
|
||||
}
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# Offline test of ft-gaze's eye-server.mmap layout detection (gaze/test/mmap-layout-test.cpp):
|
||||
# builds it against gaze/ft-gaze.cpp in the dev container and runs it there. Nothing reaches
|
||||
# SteamVR or the eye tracker, so it's safe next to them. gaze/build.sh fetches the OpenVR
|
||||
# header it needs, so run that once first.
|
||||
#
|
||||
# gaze/test/mmap-layout-test.sh
|
||||
set -euo pipefail
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
"$root/scripts/sync.sh" >/dev/null
|
||||
exec "$root/scripts/frame.sh" -C gaze 'set -e
|
||||
[ -f build/include/openvr.h ] || { echo "no build/include/openvr.h: run gaze/build.sh first" >&2; exit 1; }
|
||||
g++ -std=c++17 -O2 -Wall -Wno-unused-parameter -Wno-missing-field-initializers -Ibuild/include -I../pointer/common \
|
||||
-o build/mmap-layout-test test/mmap-layout-test.cpp -L/opt/steamvr/bin/linuxarm64 -lopenvr_api \
|
||||
-Wl,-rpath,/opt/steamvr/bin/linuxarm64 -lpthread
|
||||
build/mmap-layout-test'
|
||||
+11
-3
@@ -4,7 +4,9 @@
|
||||
# (frametop-eyegrab.service, gaze/tracker/install.sh), so this checks it
|
||||
# only needs glibc symbols the SteamOS host has (2.39; the container has 2.43).
|
||||
# build/venv Python with numpy and OpenCV (requirements.txt) for ft-eyes and lab/,
|
||||
# remade when requirements.txt changes.
|
||||
# remade when requirements.txt changes. In the image (pack/Containerfile)
|
||||
# they're in its locked venv already (uv.lock has the same versions), so
|
||||
# build/venv is a small venv that sees that one's packages, not a copy.
|
||||
# Usage: gaze/tracker/build.sh
|
||||
set -euo pipefail
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
@@ -14,10 +16,16 @@ gcc -std=gnu11 -O2 -Wall -Wextra -pthread -o build/ft-eyegrab ft-eyegrab.c
|
||||
max=$(objdump -T build/ft-eyegrab | grep -oE "GLIBC_[0-9.]+" | sort -uV | tail -1)
|
||||
echo "built build/ft-eyegrab, newest glibc symbol: $max"
|
||||
[ "$(printf "%s\n" "$max" GLIBC_2.39 | sort -V | tail -1)" = GLIBC_2.39 ] || { echo "needs newer glibc than the host has" >&2; exit 1; }
|
||||
if ! cmp -s requirements.txt build/venv/requirements.done; then
|
||||
if [ "${FRAME_IN_BOX:-0}" = 1 ] && [ -x /opt/frametop/venv/bin/python ]; then
|
||||
rm -rf build/venv
|
||||
python3 -m venv --without-pip build/venv
|
||||
/opt/frametop/venv/bin/python -c "import sysconfig; print(sysconfig.get_path(\"purelib\"))" \
|
||||
>"$(build/venv/bin/python -c "import sysconfig; print(sysconfig.get_path(\"purelib\"))")/frametop-image.pth"
|
||||
elif ! cmp -s requirements.txt build/venv/requirements.done; then
|
||||
rm -rf build/venv
|
||||
python3 -m venv build/venv
|
||||
build/venv/bin/pip install -q --disable-pip-version-check -r requirements.txt
|
||||
cp requirements.txt build/venv/requirements.done
|
||||
fi
|
||||
echo "build/venv: $(build/venv/bin/python -c "import numpy, cv2; print(\"numpy\", numpy.__version__, \"opencv\", cv2.__version__)")"'
|
||||
v=$(build/venv/bin/python -c "import numpy, cv2; print(\"numpy\", numpy.__version__, \"opencv\", cv2.__version__)")
|
||||
echo "build/venv: $v"'
|
||||
@@ -111,6 +111,9 @@ reading only.
|
||||
|
||||
The file is 324,122 bytes. Only bytes 0x0-0x1f3 are used; the rest is zero. It's packed and
|
||||
unaligned, so read it with memcpy. Offsets are also in `~/frametop/gaze/ft-gaze.cpp`.
|
||||
On the SteamOS 0.4.x beta (SteamVR 2.18.2) every field from 0x157 on sits 5 bytes later, and the
|
||||
counter stays at 0x38 (measured 2026-10-04, PR #26). The offsets below are stable's; ft-gaze detects
|
||||
which layout is live.
|
||||
|
||||
| Offset | What |
|
||||
| --- | --- |
|
||||
|
||||
@@ -20,7 +20,7 @@ sudo_run() { frame_sudo "$1"; }
|
||||
|
||||
case ${1:-install} in
|
||||
install)
|
||||
"$root/gaze/tracker/build.sh"
|
||||
[ "$FRAME_RELEASE" = 1 ] || "$root/gaze/tracker/build.sh"
|
||||
ids=$(on_frame 'echo "$(id -u):$(id -g)"')
|
||||
fill_template "$root/gaze/tracker/$unit" | sed "s|@UID@|${ids%:*}|g; s|@GID@|${ids#*:}|g" |
|
||||
on_frame "cat > /tmp/$unit"
|
||||
|
||||
@@ -6,40 +6,143 @@
|
||||
#
|
||||
# It asks which version to install, clones the repo into ~/frametop (or updates the clone
|
||||
# that's there), and runs its install.sh. Run it again to update, or to switch versions.
|
||||
# With --release it installs a release instead: Frametop built, in one file. It downloads the
|
||||
# release's Frametop.zip from GitHub (about 1.1 GB: the newest stable release, or with
|
||||
# --experimental the newest of any), unpacks it in ~/.cache/frametop/release, and runs its
|
||||
# install-release.sh, which checks this SteamOS build against the releases' SteamOS table and
|
||||
# installs without building anything (pack/README.md, Releases). The same zip installs with
|
||||
# FrameDrop from a PC, or unpacked by hand on the headset.
|
||||
# Options (piped, they go after "bash -s --"):
|
||||
# --stable the main branch: tested releases (the default for a new install)
|
||||
# --experimental the experimental branch: the newest features, less tested
|
||||
# --dir DIR where the repo goes (default ~/frametop)
|
||||
# --clone-only get or update the repo, but don't run install.sh
|
||||
# --yes, --no-bluetooth passed to install.sh (--yes also answers this script's question:
|
||||
# the version already there, or stable)
|
||||
# --branch NAME another branch, such as a fix to test before it's released
|
||||
# --dir DIR where the repo goes (default ~/frametop; for --release, where the
|
||||
# releases go, default ~/.local/share/frametop/releases)
|
||||
# --clone-only get or update the repo (or unpack the release), but don't run install.sh
|
||||
# --release install a release from GitHub instead of cloning the repo
|
||||
# --version V with --release: that release (tag vV), not the newest
|
||||
# --zip FILE|URL with --release: this Frametop.zip instead of GitHub's newest
|
||||
# --any-steamos with --release: install even on a SteamOS build the release breaks on
|
||||
# --yes, --no-eye-tracker, --no-bluetooth, --bluetooth passed to install.sh (--yes also
|
||||
# answers this script's questions: the version already there, or stable)
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage: get.sh [--stable | --experimental] [--dir DIR] [--clone-only] [--yes] [--no-bluetooth]
|
||||
usage: get.sh [--stable | --experimental | --branch NAME] [--dir DIR] [--clone-only] [--yes]
|
||||
[--no-eye-tracker] [--no-bluetooth | --bluetooth]
|
||||
get.sh --release [--stable | --experimental | --version V | --zip FILE|URL]
|
||||
[--any-steamos] [--dir DIR] [--clone-only] [--yes] [--no-eye-tracker]
|
||||
[--no-bluetooth | --bluetooth]
|
||||
piped: curl -fsSL https://deejanuz.github.io/frametop/get.sh | bash -s -- [options]
|
||||
EOF
|
||||
}
|
||||
|
||||
SLUG=${FRAMETOP_REPO:-DeeJanuz/frametop} # FRAMETOP_REPO: another repo's releases, such as a fork's
|
||||
|
||||
# release_zip CHANNEL VERSION: the URL of a release's Frametop.zip on GitHub.
|
||||
release_zip() {
|
||||
if [ -n "$2" ]; then
|
||||
echo "https://github.com/$SLUG/releases/download/v$2/Frametop.zip"
|
||||
elif [ "$1" = stable ]; then
|
||||
echo "https://github.com/$SLUG/releases/latest/download/Frametop.zip" # newest non-prerelease
|
||||
else
|
||||
curl -fsSL "https://api.github.com/repos/$SLUG/releases?per_page=30" | python3 -c '
|
||||
import json, sys
|
||||
for r in json.load(sys.stdin): # newest first
|
||||
for a in r.get("assets", []):
|
||||
if a.get("name") == "Frametop.zip" and not r.get("draft"):
|
||||
print(a["browser_download_url"])
|
||||
sys.exit(0)
|
||||
sys.exit(1)'
|
||||
fi
|
||||
}
|
||||
|
||||
# install_release CHANNEL VERSION ZIP DIR CLONE_ONLY ANY_STEAMOS -- INSTALL_ARGS...
|
||||
install_release() {
|
||||
local channel=$1 version=$2 zip=$3 dir=$4 clone_only=$5 any=$6 cache=$HOME/.cache/frametop/release
|
||||
shift 7
|
||||
local args=("$@")
|
||||
[ -n "$dir" ] && args+=(--dir "$dir")
|
||||
[ "$clone_only" = 1 ] && args+=(--unpack-only)
|
||||
[ "$any" = 1 ] && args+=(--any-steamos)
|
||||
mkdir -p "$cache"
|
||||
local url= file loc key status=0
|
||||
case $zip in
|
||||
https://*|'')
|
||||
url=$zip
|
||||
if [ -z "$url" ]; then
|
||||
url=$(release_zip "$channel" "$version") ||
|
||||
{ echo "couldn't find a Frametop release on GitHub (or GitHub can't be reached)" >&2; return 1; }
|
||||
fi
|
||||
# "latest" names a different file after each release: resume only the same release's.
|
||||
if [[ $url == */releases/latest/download/* ]]; then
|
||||
loc=$(curl -fsSI --proto '=https' "$url" | tr -d '\r' | sed -n 's/^[Ll]ocation: //p' | head -1)
|
||||
[[ $loc == https://github.com/* ]] && url=$loc
|
||||
fi
|
||||
key=$(printf %s "$url" | sha256sum | cut -c1-16)
|
||||
file=$cache/Frametop-$key.zip
|
||||
find "$cache" -maxdepth 1 -name 'Frametop-*.zip*' ! -name "Frametop-$key.zip*" -delete
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "Downloading $url (about 1.1 GB; if it stops, run this again to resume)"
|
||||
curl -fL --proto '=https' -C - -o "$file.part" "$url" ||
|
||||
{ echo "the download didn't finish: run this again to resume it" >&2; return 1; }
|
||||
mv "$file.part" "$file"
|
||||
fi ;;
|
||||
*://*) echo "the zip has to come over https: $zip" >&2; return 1 ;;
|
||||
*) file=$zip; [ -f "$file" ] || { echo "no file $file" >&2; return 1; } ;;
|
||||
esac
|
||||
echo "Unpacking $file"
|
||||
rm -rf "$cache/unpacked"
|
||||
if ! unzip -q "$file" -d "$cache/unpacked"; then
|
||||
[ -n "$url" ] && rm -f "$file"
|
||||
echo "$file didn't unpack: run this again to download it again" >&2
|
||||
return 1
|
||||
fi
|
||||
"$cache/unpacked/Frametop/install-release.sh" "${args[@]}" || status=$?
|
||||
rm -rf "$cache/unpacked"
|
||||
if [ "$status" = 3 ] && [ -n "$url" ]; then
|
||||
rm -f "$file" # damaged: the next run downloads it again
|
||||
fi
|
||||
[ "$status" = 0 ] || return "$status"
|
||||
# Loaded into podman and copied out: the download isn't needed any more.
|
||||
[ "$clone_only" = 1 ] || rm -rf "$cache"
|
||||
}
|
||||
|
||||
# Everything happens in main, called on the last line, so a download cut short runs nothing.
|
||||
main() {
|
||||
local repo=https://github.com/DeeJanuz/frametop.git dir=$HOME/frametop branch= clone_only=0
|
||||
local yes=0 tty=0 current= def answer
|
||||
local repo=https://github.com/DeeJanuz/frametop.git dir= branch= clone_only=0
|
||||
local yes=0 tty=0 current= def answer release=0 zip= want= any=0
|
||||
local pass=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
--stable) branch=main ;;
|
||||
--experimental) branch=experimental ;;
|
||||
--branch) branch=${2:?--branch needs a branch name}; shift ;;
|
||||
--dir) dir=${2:?--dir needs a folder}; shift ;;
|
||||
--clone-only) clone_only=1 ;;
|
||||
--release) release=1 ;;
|
||||
--zip) zip=${2:?--zip needs a file or URL}; shift ;;
|
||||
--version) want=${2:?--version needs a version}; shift ;;
|
||||
--any-steamos) any=1 ;;
|
||||
--yes) yes=1; pass+=("$1") ;;
|
||||
--no-bluetooth) pass+=("$1") ;;
|
||||
--no-bluetooth|--bluetooth|--no-eye-tracker) pass+=("$1") ;;
|
||||
-h|--help) usage; return 0 ;;
|
||||
*) echo "unknown option: $1" >&2; usage >&2; return 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
if [ "$release" = 0 ] && { [ -n "$zip" ] || [ -n "$want" ] || [ "$any" = 1 ]; }; then
|
||||
echo "--zip, --version, and --any-steamos go with --release" >&2
|
||||
return 2
|
||||
fi
|
||||
if [ "$release" = 1 ] && [ -n "$branch" ] && [ "$branch" != main ] && [ "$branch" != experimental ]; then
|
||||
echo "releases come from the stable or experimental list, not a branch" >&2
|
||||
return 2
|
||||
fi
|
||||
if [ -z "$dir" ] && [ "$release" = 0 ]; then
|
||||
dir=$HOME/frametop
|
||||
fi
|
||||
|
||||
if ! { grep -qx 'ID=steamos' /etc/os-release && grep -qE '^VARIANT_ID="?vr"?$' /etc/os-release; } 2>/dev/null; then
|
||||
echo "Frametop installs on a Steam Frame (SteamOS, VR variant). Run this in a terminal on the headset." >&2
|
||||
@@ -52,16 +155,19 @@ main() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -e "$dir/.git" ]; then
|
||||
if [ "$release" = 0 ] && [ -e "$dir/.git" ]; then
|
||||
git -C "$dir" remote get-url origin 2>/dev/null | grep -qi 'frametop' ||
|
||||
{ echo "$dir is a git repo, but not Frametop's. Pick another folder with --dir." >&2; return 1; }
|
||||
current=$(git -C "$dir" branch --show-current)
|
||||
elif [ -e "$dir" ]; then
|
||||
elif [ "$release" = 0 ] && [ -e "$dir" ]; then
|
||||
echo "$dir is there and isn't Frametop's repo. Move it, or pick another folder with --dir." >&2
|
||||
return 1
|
||||
elif [ "$release" = 1 ] && [ -f "${dir:-$HOME/.local/share/frametop/releases}/current/.frametop-release" ]; then
|
||||
current=$(sed -n 's/^CHANNEL=//p' "${dir:-$HOME/.local/share/frametop/releases}/current/.frametop-release")
|
||||
[ "$current" = stable ] && current=main
|
||||
fi
|
||||
|
||||
if [ -z "$branch" ]; then
|
||||
if [ -z "$branch" ] && { [ "$release" = 0 ] || { [ -z "$zip" ] && [ -z "$want" ]; }; }; then
|
||||
def=main
|
||||
[ "$current" = experimental ] && def=experimental
|
||||
if [ "$yes" = 1 ]; then
|
||||
@@ -80,6 +186,17 @@ main() {
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$release" = 1 ]; then
|
||||
install_release "$([ "$branch" = experimental ] && echo experimental || echo stable)" "$want" "$zip" "$dir" \
|
||||
"$clone_only" "$any" -- ${pass[@]+"${pass[@]}"}
|
||||
return
|
||||
fi
|
||||
|
||||
if ! git ls-remote --exit-code --heads "$repo" "$branch" >/dev/null; then
|
||||
echo "Frametop has no branch called $branch (or GitHub can't be reached)." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -e "$dir" ]; then
|
||||
echo "Cloning Frametop ($branch) into $dir"
|
||||
git clone --branch "$branch" "$repo" "$dir"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
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.
|
||||
|
||||
> **Fixed in Frametop 0.2.1 (October 5, 2026):** the recorder now works on headsets without the Arcturus color passthrough module too. If you installed it earlier and the camera check stopped with "Not all of the headset's tracking cameras are running (ft-camd publishes only 2 of 4 mono cameras ...)", 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. With your `sudo` password, it also takes back the camera broker's permission to read the cameras. If you also installed Frametop's live hand tracking, the camera broker keeps that permission, because live hand tracking still uses it. 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
-3
@@ -10,6 +10,8 @@ CFLAGS ?= -O2 -g -Wall -Wextra -Wno-unused-parameter
|
||||
CXXFLAGS ?= -O2 -g -Wall -Wextra -Wno-unused-parameter -Wno-psabi
|
||||
CXXFLAGS += -std=c++17 -fopenmp -I$(NCNN)/include/ncnn
|
||||
LDLIBS = $(NCNN)/lib/libncnn.a -ljsoncpp -fopenmp -lpthread
|
||||
# The standalone Hand Recorder's release build passes LDFLAGS=-static (it runs on the host).
|
||||
LDFLAGS ?=
|
||||
|
||||
CAMD = camd/camd.c camd/tp.c camd/xrcams.c
|
||||
TRACK = track/calib.cpp track/nets.cpp track/tracker.cpp track/io.cpp track/record.cpp track/pinch.cpp track/sides.cpp
|
||||
@@ -24,15 +26,15 @@ build/ft-camd: $(CAMD) camd/tp.h camd/xrcams.h camd/fhring.h
|
||||
|
||||
build/ft-hands: track/main.cpp $(TRACK) $(HDR) $(NCNN)/lib/libncnn.a
|
||||
@mkdir -p build
|
||||
$(CXX) $(CXXFLAGS) -o $@ track/main.cpp $(TRACK) $(LDLIBS)
|
||||
$(CXX) $(CXXFLAGS) $(LDFLAGS) -o $@ track/main.cpp $(TRACK) $(LDLIBS)
|
||||
|
||||
build/ft-handreplay: track/replay.cpp $(TRACK) $(HDR) $(NCNN)/lib/libncnn.a
|
||||
@mkdir -p build
|
||||
$(CXX) $(CXXFLAGS) -o $@ track/replay.cpp $(TRACK) $(LDLIBS)
|
||||
$(CXX) $(CXXFLAGS) $(LDFLAGS) -o $@ track/replay.cpp $(TRACK) $(LDLIBS)
|
||||
|
||||
build/sides-test: tests/sides_test.cpp $(TRACK) $(HDR) $(NCNN)/lib/libncnn.a
|
||||
@mkdir -p build
|
||||
$(CXX) $(CXXFLAGS) -o $@ tests/sides_test.cpp $(TRACK) $(LDLIBS)
|
||||
$(CXX) $(CXXFLAGS) $(LDFLAGS) -o $@ tests/sides_test.cpp $(TRACK) $(LDLIBS)
|
||||
|
||||
check: build/sides-test
|
||||
build/sides-test
|
||||
|
||||
+5
-4
@@ -31,12 +31,13 @@ hands/run.sh restart # after changing a setting
|
||||
hands/run.sh status
|
||||
hands/run.sh log [lines]
|
||||
hands/run.sh caps # after rebuilding ft-camd (a rebuild clears its capabilities)
|
||||
hands/run.sh uninstall
|
||||
hands/run.sh uncaps # take them back, unless the Hand Recorder or the services use them
|
||||
hands/run.sh uninstall # the services, then uncaps
|
||||
```
|
||||
|
||||
Settings in `~/.config/frametop.conf` (`FT_<name>` in the environment overrides them), read when ft-camd and ft-hands start:
|
||||
|
||||
- `HANDS_SWAP_SIDES=auto` (the default): ft-hands tells from the hands which side camera is which, and corrects ft-camd's names when they're backwards (see "Which camera is which" below). `1` forces them exchanged and `0` forces ft-camd's names; ft-hands still checks and logs a warning if the hands disagree.
|
||||
- `HANDS_SWAP_SIDES=auto` (the default): ft-hands tells from the hands which side camera is which, and corrects ft-camd's names when they're backwards (see "Which camera is which" below). `1` forces them exchanged and `0` forces ft-camd's names; ft-hands still checks, and if the hands disagree it logs a warning and publishes the hands' answer as the truth (`sides.json`), so recordings are labelled right. The example config said `0` until 2026-10-05; `scripts/conf-migrate.sh` (run by `install.sh` and `hands/rec/install.sh`) turns that untouched line into `auto`.
|
||||
- `HANDS_CPUS=5,6,7`: the CPUs the model threads run on (below).
|
||||
- `HANDS_CAMERAS` (`auto`), `HANDS_BRIGHT` (`all`), `HANDS_BRIGHT_ON` (40), `HANDS_BRIGHT_OFF` (25): which cameras ft-hands tracks with, as `--cams`, `--bright`, `--bright-on` and `--bright-off` (see ft-hands). `HANDS_CAMERAS=mono` also keeps ft-camd off the colour cameras.
|
||||
- `HANDS_COLOR_LEFT` (`color_video0`), `HANDS_COLOR_CROP` (`subtract`): how the colour module's calibration maps onto its images, as `--color-left` and `--color-crop`.
|
||||
@@ -86,7 +87,7 @@ Options:
|
||||
|
||||
It exits when XRService exits, or when a camera's buffers keep going stale, which means XRService has reallocated them. The service starts it again, and it attaches to the new buffers.
|
||||
|
||||
**Which camera is which:** video9 is `slam_left`, video13 is `slam_right`, video6 is `upper_left` and video7 is `upper_right`. This was checked by rendering the same view from each camera with the factory calibration. But ft-camd tells the side cameras' buffers apart only by XRService's allocation order, and after some XRService restarts it gets them backwards. Then every hand is seen by one camera only, at the wrong depth, and the hand holes land beside the hands. ft-hands now catches this by itself (`track/sides.h`, `HANDS_SWAP_SIDES=auto`):
|
||||
**Which camera is which:** video9 is `slam_right` (sensor `og01a1b 4-0036`), video13 is `slam_left` (`4-0060`), video6 is `upper_left` and video7 is `upper_right`; without the colour module the side pair is on video0 (`slam_right`) and video3. XRService says so in its log: `Found camera 'slam_left': ... v4l_subdev=/dev/v4l-subdev30` names each sensor, and each `TrackingCameraInit` line gives the device and subdev it opened. ft-hands and `camcheck.py` name the devices that way. Until 2026-10-05 they named them by the `TrackingCameraInit` index instead, which is only the order XRService opens them in (index 0 was `slam_right` on every start logged), and ft-camd bound each buffer queue to a device by the order of XRService's file descriptors, which changes when XRService restarts its cameras. The two mistakes made the side names come out swapped on most starts and right on some. ft-camd now asks each device which buffers it holds (`VIDIOC_QUERYBUF` names the descriptor XRService queued at each index) and only falls back to the order if that fails. With the names swapped, every hand is seen by one camera only, at the wrong depth, and the hand holes land beside the hands. ft-hands still checks by itself, in case (`track/sides.h`, `HANDS_SWAP_SIDES=auto`):
|
||||
- Whenever a hand's landmarks are found in two cameras at once (one of them a side camera), it intersects the rays through the 21 landmarks twice: once with the calibrations as named, once with the two side cameras exchanged. The same hand seen the right way meets within a few mm, in front of both cameras and as far away as its size says. The wrong way misses by centimetres or meets behind a camera.
|
||||
- With the names wrong, the tracker never gets such pairs on its own: it hands the hand over to where the wrong calibration puts it and finds nothing there. So 5 times a second while undecided, the check places a tracked hand in 3D under the other naming and runs the landmark model where that puts it in the other side camera.
|
||||
- It decides after 10 votes one way and none the other, or 20 with at most a fifth the other way, over at least 1 s. That takes about 1-2 s of hands in view. If the names are backwards, it exchanges them; the tracked views move with their images. Then it checks once more, more strictly.
|
||||
@@ -263,7 +264,7 @@ Guesses, not tested:
|
||||
|
||||
## Known issues
|
||||
|
||||
- **The side cameras can come out swapped.** ft-camd tells the side cameras' buffers apart only by XRService's allocation order, and some XRService restarts reverse it. ft-hands corrects it from the hands (`HANDS_SWAP_SIDES=auto`, the default). Until it has seen about 1-2 s of hands in both namings' reach, the cutouts may sit beside the hands. ft-camd itself still can't tell.
|
||||
- **The side cameras could come out swapped** (fixed 2026-10-05, see "Which camera is which"). If they still do, ft-hands corrects it from the hands (`HANDS_SWAP_SIDES=auto`, the default): until it has seen about 1-2 s of hands in both namings' reach, the cutouts may sit beside the hands. ft-camd's log says `bound by VIDIOC_QUERYBUF` for each camera when its buffers were matched exactly.
|
||||
- **The colour cameras can't be used while the headset is worn.** The colour module then writes only a half-size image into the top-left quarter of its buffers, and ft-camd drops those frames. So the service runs the mono cameras only, and tracking in bright light, where the mono cameras see dark hands, doesn't get the colour pair's help.
|
||||
- **The colour calibration mapping isn't settled.** Which colour camera is `passthrough_left` (`HANDS_COLOR_LEFT`) and how the module's crop applies (`HANDS_COLOR_CROP`) still need `tools/check_color.py` on a recording with a lit, textured view.
|
||||
- **Depth when one camera loses the hand.** A hand seen in one camera drifts 10% per update toward the one-camera depth guess (`kMonoDepthGain`, 0.1, in `track/tracker.cpp`). In the 2026-09-30 replays that was worse than keeping the last distance (see "3D" above). A smaller gain, such as 0.02, is the next thing to try.
|
||||
|
||||
+68
-9
@@ -3,7 +3,7 @@
|
||||
ft-hands see them all?
|
||||
|
||||
The Frame has four mono IR tracking cameras: the side pair slam_left and slam_right
|
||||
(/dev/video9 and /dev/video13) and the upper pair (/dev/video6 and /dev/video7). With the
|
||||
(/dev/video13 and /dev/video9) and the upper pair (/dev/video6 and /dev/video7). With the
|
||||
Arcturus colour module attached, SteamVR's XRService loads an FPGA image ("VCINT") onto the
|
||||
module whenever it opens the cameras (at start and after every wake). When that load fails
|
||||
(seen 2026-10-02 17:02, after a sleep), XRService runs only the two side cameras, the IR
|
||||
@@ -40,7 +40,7 @@ import time
|
||||
|
||||
LOG_DIR = os.path.expanduser("~/.local/share/Steam/logs")
|
||||
LOG_LINK = os.path.join(LOG_DIR, "xrservice.txt")
|
||||
SIDE_NODES = (9, 13) # slam_left, slam_right (TrackingCameraInit index 0 and 1)
|
||||
SIDE_NODES = (9, 13) # the side pair: slam_right on video9, slam_left on video13 (see camera_map)
|
||||
UPPER_NODES = (6, 7) # the upper pair (index 2 and 3)
|
||||
TRACKING = 4
|
||||
|
||||
@@ -55,14 +55,25 @@ USER_TEXT = ("The headset's upper cameras and IR light are off. SteamVR couldn't
|
||||
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")
|
||||
KEYS = ("FPGA", "VCINT", "Created", "TrackingCameraInit", "Found camera", "Closing tracking camera", "Streaming",
|
||||
"systemd suspend", "systemd resume", "XRService logging to", "Exiting XRService", "ISP ")
|
||||
# The tracking cameras' names. XRService says which sensor subdev each name is ("Found camera
|
||||
# 'slam_left': ... v4l_subdev=/dev/v4l-subdev30", once per instance) and which subdev and video
|
||||
# device each TrackingCameraInit index opened. The index is only the order it opens them in: on
|
||||
# every start logged since 2026-10-04, index 0 was slam_right. A log without the "Found camera"
|
||||
# lines falls back to this order. ft-hands names them the same way (track/main.cpp,
|
||||
# cameras_from_xrservice_log).
|
||||
NAMES = ("slam_left", "slam_right", "upper_left", "upper_right")
|
||||
RE_FOUND = re.compile(r"Found camera '(\w+)': interface=\S+ v4l_subdev=(\S+)")
|
||||
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_INIT = re.compile(r"TrackingCameraInit: index: (\d+)\. video device: /dev/video(\d+)(?:\. v4l subdevice: (\S+))?")
|
||||
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:
|
||||
@@ -79,13 +90,15 @@ class LogState:
|
||||
self.closed_at = ""
|
||||
self.episode = None
|
||||
self.nodes = {} # TrackingCameraInit index -> /dev/videoN, from the whole log
|
||||
self.subdevs = {} # TrackingCameraInit index -> its sensor subdev, from the whole log
|
||||
self.found = {} # sensor subdev -> camera name ("Found camera" lines)
|
||||
self.failures = [] # [(time, line)]: every VCINT failure in this log
|
||||
self.lines = 0
|
||||
|
||||
def _new_episode(self, t):
|
||||
self.closed = False
|
||||
self.episode = {"start": t, "fpga_before": "", "vcint": "", "interleave": None, "tasks": None,
|
||||
"inits": {}, "stream": "", "failure": "", "evidence": []}
|
||||
"inits": {}, "init_subdevs": {}, "stream": "", "isp": None, "failure": "", "evidence": []}
|
||||
if self.closed_at:
|
||||
self.episode["evidence"].append(self.closed_at)
|
||||
|
||||
@@ -146,18 +159,30 @@ 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)
|
||||
ep["tasks"] = tuple(int(v) for v in m.groups())
|
||||
ep["evidence"].append(short)
|
||||
return
|
||||
m = RE_FOUND.search(text)
|
||||
if m:
|
||||
self.found[m.group(2)] = m.group(1)
|
||||
return
|
||||
m = RE_INIT.search(text)
|
||||
if m:
|
||||
ep = self._ep(t)
|
||||
idx, node = int(m.group(1)), int(m.group(2))
|
||||
ep["inits"][idx] = node
|
||||
self.nodes[idx] = node
|
||||
if m.group(3):
|
||||
ep["init_subdevs"][idx] = self.subdevs[idx] = m.group(3)
|
||||
ep["evidence"].append(short)
|
||||
return
|
||||
m = RE_STREAM.search(text)
|
||||
@@ -184,6 +209,21 @@ 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): each one's subdev named by the
|
||||
"Found camera" lines, else by the index (see NAMES)."""
|
||||
ep = self.episode or {}
|
||||
inits, subdevs = (ep.get("inits"), ep.get("init_subdevs")) if ep.get("inits") else (self.nodes, self.subdevs)
|
||||
out = {}
|
||||
for i, node in sorted(inits.items()):
|
||||
name = self.found.get(subdevs.get(i))
|
||||
if name not in NAMES:
|
||||
name = NAMES[i] if 0 <= i < len(NAMES) else None
|
||||
if name:
|
||||
out[name] = node
|
||||
return out
|
||||
|
||||
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
|
||||
@@ -353,7 +393,9 @@ 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 ""}
|
||||
"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": []}
|
||||
|
||||
if proc:
|
||||
if pid is None:
|
||||
@@ -389,13 +431,30 @@ 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":
|
||||
status, reason = "degraded", ("ft-camd publishes only %d of %d mono cameras (it started while "
|
||||
"they were missing: restart it)" % (len(r["mono"]), TRACKING))
|
||||
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 "?"))
|
||||
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
|
||||
|
||||
|
||||
+29
-5
@@ -264,11 +264,11 @@ static void setup_camera(cam_t *c, xr_camera_t *cam, int pidfd)
|
||||
xr_slugify(cam->sensor, sensor, sizeof(sensor));
|
||||
snprintf(c->slug, sizeof(c->slug), "%.20s_video%d", sensor, cam->node);
|
||||
|
||||
bool own = false;
|
||||
bool own = false, exact = false;
|
||||
|
||||
for (int g = 0; g < xr.ngroups; g++)
|
||||
if (xr.groups[g].cam == cam && group_fits(&xr.groups[g], cam, c->need))
|
||||
own = true;
|
||||
own = true, exact = exact || xr.groups[g].exact;
|
||||
|
||||
char model[16];
|
||||
model_of(cam->sensor, model, sizeof(model));
|
||||
@@ -318,7 +318,8 @@ static void setup_camera(cam_t *c, xr_camera_t *cam, int pidfd)
|
||||
}
|
||||
|
||||
printf(" %-24s %-12s %ux%u pitch %u, %d candidate buffers%s\n", c->slug, cam->path,
|
||||
c->lay.width, c->lay.height, c->lay.pitch, c->nslots, own ? "" : " (shared run)");
|
||||
c->lay.width, c->lay.height, c->lay.pitch, c->nslots,
|
||||
!own ? " (shared run)" : exact ? ", bound by VIDIOC_QUERYBUF" : ", bound by open order");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ index -> buffer */
|
||||
@@ -379,9 +380,15 @@ 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;
|
||||
int depth = c->maxindex + 1, silent = 0;
|
||||
bool taken[MAX_SLOTS] = { false };
|
||||
|
||||
for (int i = 0; i < depth; i++) {
|
||||
@@ -396,6 +403,12 @@ 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;
|
||||
|
||||
@@ -403,9 +416,14 @@ 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++)
|
||||
printf(" %d", c->slot_of[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("\n");
|
||||
return true;
|
||||
}
|
||||
@@ -677,6 +695,12 @@ 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++;
|
||||
|
||||
+122
-12
@@ -9,8 +9,11 @@
|
||||
* - The V4L2 nodes and sensor subdevs it holds open come from /proc/<pid>/fd.
|
||||
* - Each node's geometry comes from VIDIOC_G_FMT on our own handle.
|
||||
* - Each node is traced back to its sensor through MEDIA_IOC_G_TOPOLOGY.
|
||||
* - Buffers are split into queues by allocation order: XRService opens a
|
||||
* sensor subdev, then allocates that camera's buffers.
|
||||
* - Buffers are split into runs by allocation order, and each run is bound to
|
||||
* its camera by VIDIOC_QUERYBUF on the camera's node, which names the
|
||||
* descriptor XRService queued at each index. Where that fails, the sensor
|
||||
* subdev opened just before the run decides (XRService opens a sensor's
|
||||
* subdev, then allocates its buffers), which isn't always right.
|
||||
*/
|
||||
|
||||
#define _GNU_SOURCE
|
||||
@@ -457,6 +460,38 @@ static bool scan_xr_fds(pid_t pid, char *err, size_t errn)
|
||||
|
||||
/* ------------------------------------------------------ camera discovery */
|
||||
|
||||
/*
|
||||
* Which of XRService's buffers each V4L2 index of a camera holds. vb2 lets any handle query a
|
||||
* queue's buffers, and for a DMABUF buffer it returns the descriptor its owner last queued it
|
||||
* with: that descriptor's number in XRService's fd table, the same numbers scan_xr_fds reads
|
||||
* from /proc. XRService queues each index with the same buffer every time.
|
||||
*/
|
||||
static void query_buffers(int fd, xr_camera_t *c, bool mplane)
|
||||
{
|
||||
c->nqbuf = 0;
|
||||
|
||||
for (int i = 0; i < XR_MAX_RUNBUFS; i++) {
|
||||
|
||||
struct v4l2_buffer b;
|
||||
struct v4l2_plane planes[VIDEO_MAX_PLANES];
|
||||
|
||||
memset(&b, 0, sizeof(b));
|
||||
memset(planes, 0, sizeof(planes));
|
||||
b.index = (unsigned)i;
|
||||
b.type = mplane ? V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE : V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
|
||||
if (mplane) {
|
||||
b.m.planes = planes;
|
||||
b.length = VIDEO_MAX_PLANES;
|
||||
}
|
||||
|
||||
if (ioctl(fd, VIDIOC_QUERYBUF, &b) < 0 || b.memory != V4L2_MEMORY_DMABUF)
|
||||
break; /* EINVAL past the last index */
|
||||
|
||||
c->qbuf_xfd[c->nqbuf++] = mplane ? planes[0].m.fd : b.m.fd;
|
||||
}
|
||||
}
|
||||
|
||||
static void probe_cameras(xr_state_t *st)
|
||||
{
|
||||
int seen[64];
|
||||
@@ -521,6 +556,8 @@ static void probe_cameras(xr_state_t *st)
|
||||
c->planesize[0] = fmt.fmt.pix.sizeimage;
|
||||
}
|
||||
|
||||
query_buffers(fd, c, fmt.type == V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE);
|
||||
|
||||
struct stat sb;
|
||||
|
||||
if (fstat(fd, &sb) == 0) {
|
||||
@@ -544,22 +581,26 @@ 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;
|
||||
|
||||
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));
|
||||
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)));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -591,7 +632,20 @@ 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;
|
||||
|
||||
if (c->pixfmt == V4L2_PIX_FMT_NV12 || c->pixfmt == V4L2_PIX_FMT_NV21) {
|
||||
/*
|
||||
* 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) {
|
||||
l->fmt = XR_FMT_NV12;
|
||||
l->rows = c->height + c->height / 2;
|
||||
} else {
|
||||
@@ -613,6 +667,46 @@ const char *xr_fmt_name(xr_fmt_t f)
|
||||
|
||||
/* ------------------------------------------------------- buffer grouping */
|
||||
|
||||
/* The camera whose VIDIOC_QUERYBUF names this XRService descriptor, or NULL. */
|
||||
static xr_camera_t *qbuf_owner(xr_state_t *st, int xfd)
|
||||
{
|
||||
for (int c = 0; c < st->ncameras; c++)
|
||||
for (int k = 0; k < st->cameras[c].nqbuf; k++)
|
||||
if (st->cameras[c].qbuf_xfd[k] == xfd)
|
||||
return &st->cameras[c];
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/*
|
||||
* A run can hold two cameras' queues when nothing between them in the fd table ends it (the
|
||||
* upper pair, both 640x480). Where VIDIOC_QUERYBUF says so, cut it where the owner changes.
|
||||
*/
|
||||
static void split_groups(xr_state_t *st)
|
||||
{
|
||||
for (int i = 0; i < st->ngroups && st->ngroups < XR_MAX_GROUPS; i++) {
|
||||
|
||||
xr_group_t *g = &st->groups[i];
|
||||
xr_camera_t *first = qbuf_owner(st, g->buf[0].xfd);
|
||||
int cut = -1;
|
||||
|
||||
for (int b = 1; first && b < g->nbufs && cut < 0; b++) {
|
||||
xr_camera_t *o = qbuf_owner(st, g->buf[b].xfd);
|
||||
if (o && o != first)
|
||||
cut = b;
|
||||
}
|
||||
|
||||
if (cut < 0)
|
||||
continue;
|
||||
|
||||
xr_group_t *t = &st->groups[st->ngroups++];
|
||||
*t = *g;
|
||||
t->nbufs = g->nbufs - cut;
|
||||
memmove(t->buf, g->buf + cut, (size_t)t->nbufs * sizeof(t->buf[0]));
|
||||
g->nbufs = cut;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* XRService allocates one udmabuf per plane, plane 0 then plane 1, a whole
|
||||
* queue at a time right after opening the sensor's subdev. Plane 1 matches
|
||||
@@ -686,6 +780,8 @@ static void build_groups(xr_state_t *st)
|
||||
i++; /* consume the plane 1 descriptor */
|
||||
}
|
||||
|
||||
split_groups(st);
|
||||
|
||||
int keep = 0;
|
||||
|
||||
for (int i = 0; i < st->ngroups; i++)
|
||||
@@ -695,7 +791,21 @@ static void build_groups(xr_state_t *st)
|
||||
st->ngroups = keep;
|
||||
|
||||
/*
|
||||
* Bind each run to a camera. The sensor marker alone can be wrong: XRService
|
||||
* Bind each run to a camera: exactly where a camera's VIDIOC_QUERYBUF names the run's first
|
||||
* buffer (query_buffers). The two side cameras have the same format, so for them nothing
|
||||
* else is sure.
|
||||
*/
|
||||
for (int i = 0; i < st->ngroups; i++)
|
||||
for (int c = 0; c < st->ncameras && !st->groups[i].cam; c++)
|
||||
for (int k = 0; k < st->cameras[c].nqbuf; k++)
|
||||
if (st->cameras[c].qbuf_xfd[k] == st->groups[i].buf[0].xfd) {
|
||||
st->groups[i].cam = &st->cameras[c];
|
||||
st->groups[i].exact = true;
|
||||
break;
|
||||
}
|
||||
|
||||
/*
|
||||
* The rest by the sensor marker and sizes. The sensor marker alone can be wrong: XRService
|
||||
* sometimes opens another sensor's subdev (e.g. the idle color camera)
|
||||
* between an upper camera's subdev and its buffers, and two upper cameras
|
||||
* can resolve to the same sensor name. So a marker match must also fit the
|
||||
@@ -775,9 +885,9 @@ void xr_print(const xr_state_t *st, FILE *f)
|
||||
|
||||
const xr_group_t *g = &st->groups[i];
|
||||
|
||||
fprintf(f, " queue %d: %d buffers plane0=%zu plane1=%zu fds %d..%d sensor '%s' -> %s\n",
|
||||
fprintf(f, " queue %d: %d buffers plane0=%zu plane1=%zu fds %d..%d sensor '%s' -> %s%s\n",
|
||||
i, g->nbufs, g->planesize[0], g->planesize[1],
|
||||
g->buf[0].xfd, g->buf[g->nbufs - 1].xfd1, g->sensor,
|
||||
g->cam ? g->cam->path : "(unbound)");
|
||||
g->cam ? g->cam->path : "(unbound)", g->exact ? " (VIDIOC_QUERYBUF)" : "");
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ typedef struct {
|
||||
uint32_t pixfmt;
|
||||
char sensor[XR_SENSOR_LEN]; /* media entity name of the sensor */
|
||||
const char *role;
|
||||
int nqbuf; /* V4L2 indices VIDIOC_QUERYBUF named */
|
||||
int qbuf_xfd[XR_MAX_RUNBUFS]; /* index -> its plane 0 fd in XRService */
|
||||
} xr_camera_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -48,6 +50,7 @@ typedef struct {
|
||||
xr_bufref_t buf[XR_MAX_RUNBUFS];
|
||||
char sensor[XR_SENSOR_LEN]; /* from the preceding sensor subdev */
|
||||
xr_camera_t *cam;
|
||||
bool exact; /* cam is from VIDIOC_QUERYBUF, not the order */
|
||||
} xr_group_t;
|
||||
|
||||
typedef struct {
|
||||
|
||||
+20
-5
@@ -23,13 +23,24 @@ The plan this belongs to is `~/Desktop/Projects/frame-hands/notes/hands-plan.md`
|
||||
|
||||
Every part runs in the dev container, as ft-hands and Input Settings do. The host Python isn't used: it lacks PySide6 and zstd. `setup/dev-container.sh` gains `zstd`.
|
||||
|
||||
### The standalone Hand Recorder
|
||||
|
||||
[frametop-hand-recorder](https://github.com/Frametop/frametop-hand-recorder) is the recorder for people without Frametop. It pins this repo as a submodule and ships the parts above with its own window, a Qt Quick Controls `main.qml` sized for SteamVR's dashboard. Its release builds the binaries for the SteamOS host: ft-camd and ft-hands statically (`make LDFLAGS=-static`), and ft-handpanel against SteamVR's `libopenvr_api`. Everything runs on the host, the Python from a venv (`ft_handrec.py --qml ITS_QML --style Basic`).
|
||||
|
||||
Its tarball keeps this tree's layout (`hands/build/`, `hands/rec/`, `hands/models/`), so the paths here don't change. A `standalone.json` at the top marks it (`takes.standalone()`):
|
||||
- `session.py` starts ft-hands directly, not through distrobox.
|
||||
- session.json's `tool` and the manifest's are `ft-handrec <describe> (frametop-hand-recorder <version>)`.
|
||||
- The repair hints say to run its install command again.
|
||||
|
||||
The recordings go to the same `~/.local/share/frametop/hands/contrib`, so either recorder shows both's sessions.
|
||||
|
||||
## Processes during a session
|
||||
|
||||
- **ft-camd** publishes the camera ring. If it isn't running, the session starts it as the transient user unit `frametop-handrec-camd.service`, the way `hands/ft-cutouts` starts `frametop-cutouts-camd.service` (needs `hands/build/ft-camd` with capabilities: `hands/run.sh caps`). An ft-camd already running from ft-cutouts or ft-handsctl is used as it is.
|
||||
- **A tracking ft-hands** gives feedback through the hands file: which hands are seen, the palm's distance, the index tip. If none is running, the session starts `ft-hands --no-gestures --status 0` (unit `frametop-handrec-hands.service`). An ft-hands already running is used as it is.
|
||||
- **A recording ft-hands** runs once per recording part: `ft-hands --record-only --record DIR --record-for SECONDS --record-hz 10 --status 0 --sides auto|0|1` (below, "Side cameras"). It runs as a plain child process of the session, ended with SIGTERM when the part ends. SIGTERM ends ft-hands' loop, and `Recorder` writes out its queue when it's destroyed. In step mode (below) a part is one step's countdown and hold, so a take has one part per step (about 40 in the hand poses); in auto mode a take is one part, plus one more after each pause. `--record-for` is only a safety net.
|
||||
- Why a process per part rather than one kept alive and paused: measured in the dev container with `ft-ringplay`'s ring (2026-10-02), `ft-hands --record-only` writes its first set 16-27 ms after it starts and ends 4-6 ms after SIGTERM, so a new part costs nothing the 3 s countdown doesn't cover. Every reader already takes parts in order (`takes.py`, `validate.py` through the export's single stream, the labeller's `fhl_io.py`, numbering `sets-10.bin` after `sets-9.bin`), ft-hands needs no new control, and nothing is written while a step waits. Before the hold starts the session checks that the part has written a set (`Recorder.has_data`, up to 3 s more), so the hold is recorded from its first frame.
|
||||
- **Side cameras.** ft-camd can name the two side cameras the wrong way round (hands/README.md, "Which camera is which"). The tracking ft-hands decides from the hands within about 2 s of them being in view (`HANDS_SWAP_SIDES=auto`, hands/track/sides.h) and publishes that in `/run/user/UID/frametop-hands/sides.json`. The session reads it (`sides.py`, `read_live`) and stores it in session.json `"sides"`. Each later part is recorded named right (`--sides 1` or `0`). Parts recorded before the decision use ft-camd's names (`--sides auto`; a record-only ft-hands can't tell), and readers rename them (`sides.py`). Each part's `names_swapped` goes into take.json `"parts"`. Without a tracking ft-hands nothing decides: `"swapped": null`, the names stay as recorded, and the maintainer's check (`hub_review check`, check_sides on a few sets per take) tells. `takes.py sides SESSION --set swapped|named` records a decision by hand.
|
||||
- **Side cameras.** ft-camd can name the two side cameras the wrong way round (hands/README.md, "Which camera is which"). The tracking ft-hands decides from the hands within about 2 s of them being in view (`HANDS_SWAP_SIDES=auto`, hands/track/sides.h) and publishes that in `/run/user/UID/frametop-hands/sides.json`. With `HANDS_SWAP_SIDES` forced to `0` or `1`, the hands still decide what's published once they disagree (state `"forced, disagrees"`; `read_live` also corrects an ft-hands built before that). The session reads it (`sides.py`, `read_live`) and stores it in session.json `"sides"`. Each later part is recorded named right (`--sides 1` or `0`). Parts recorded before the decision use ft-camd's names (`--sides auto`; a record-only ft-hands can't tell), and readers rename them (`sides.py`). Each part's `names_swapped` goes into take.json `"parts"`. Without a tracking ft-hands nothing decides: `"swapped": null`, the names stay as recorded, and the maintainer's check (`hub_review check`, check_sides on a few sets per take) tells. `takes.py sides SESSION --set swapped|named` records a decision by hand.
|
||||
- **ft-handpanel** runs as a child process with `--watch-stdin`. It shows the panel and logs poses during each take.
|
||||
- **The headset button's reader** is a thread of the session (`ButtonReader`, below), not a process.
|
||||
|
||||
@@ -283,7 +294,7 @@ Before section 7: "Put on both controllers and tighten the straps". Before secti
|
||||
- **Hands seen:** from the hands file. A hand counts as seen if its flags match the side and the file is fresh (publish within 0.3 s). Prompts with `hands` set show the `hands` chips. If an asked-for hand is lost for more than 1.5 s, the note says "I can't see your left hand: bring it into view".
|
||||
- **Controller tracking:** in sections 8 and 9, `devices` is polled once a second. A result other than 200 for more than 1 s says "The left controller lost tracking: turn your palm slightly toward you". Each such stretch goes into `prompts.jsonl` as `feedback` with `"controller": {...}`.
|
||||
- **Lighting, measured:** the checklist page measures the light when it opens, starting ft-camd (`frametop-handrec-camd.service`) if nothing runs it; the window stops it again on quit if it started it. The mean of every mono camera's `mean` and `dark_mean` from the ring (`hands/tools/ring.py` layout; struct only, no numpy) is compared with the person's earlier sessions. If it matches an earlier round's within 15%, the window says so before starting.
|
||||
- **Lighting label:** "Measured by the cameras" is the default. `ambient_ir`, the mono cameras' mean `dark_mean` (the room's infrared), labels the round `daylight` from 6.0 and `indoor` below (`session.classify_lighting`). Lamps and LEDs give off hardly any infrared, so a dim room and a lit one read about the same (1.8 by one lamp, 2.2 in a lamp-lit room) and the cameras can't tell them apart; the person can pick dim, room or daylight instead (`source: "picked"`). The 6.0 threshold is a guess until a daylight round is measured.
|
||||
- **Lighting label:** "Measured by the cameras" is the default. `ambient_ir`, the mono cameras' mean `dark_mean` (the room's infrared), labels the round `daylight` from 6.0 and `indoor` below (`session.classify_lighting`). Lamps and LEDs give off hardly any infrared, so a dim room and a lit one read about the same (1.8 by one lamp, 2.2 in a lamp-lit room) and the cameras can't tell them apart; the person can pick dim, room or daylight instead (`source: "picked"`). The 6.0 threshold was a guess, and the first daylight round (dataset PR #5, 2026-10-05, a room with big sunlit windows) read 2.38 and was labelled `indoor`: the windows are a small part of each picture. So the checklist now asks people to pick Daylight when sunlight comes in, and the maintainer corrects the label in review (`hub_review.py lighting`) from what the pictures show: windows lit by the sun, the time of day. Hands that stand out little from the room (PR #5: a median 1.15x as bright as their surroundings, against 1.5-1.7x in some lamp-lit rounds) go with daylight, but a pale room at night read 1.22 (PR #6), so that alone doesn't decide it.
|
||||
|
||||
### Camera check
|
||||
|
||||
@@ -296,11 +307,15 @@ On 2026-10-02 a whole session showed "I can't see your hands": after the headset
|
||||
### Controls
|
||||
|
||||
- The window has Start, a big Next (while a step waits; its hint is the panel's), Pause/Resume, Redo step, Skip section and Stop. While it has focus: Space is Next, P pauses or resumes, R redoes, S skips the section, Esc stops. The panel's bottom line and the window list them. The window also shows the step's picture, diagram, countdown and "Hold".
|
||||
- **The headset's button.** The Frame has a click button on its right side, for use without controllers: `KEY_SELECT` (353) on the evdev device `gpio-keys`. While a step waits (the welcome, a section's intro, a step's ready screen) a press is Next; during a countdown or hold (and auto mode's timed screens) it pauses; while paused it resumes. The session finds the device in `/proc/bus/input/devices` by name and its KEY bitmap (`event3` on the maintainer's Frame) and reads `input_event` structs with plain `struct` (no python-evdev), from a thread. Only key-downs count (value 1: releases and autorepeat, value 2, don't), and presses closer than 0.3 s count once.
|
||||
- **The headset's button.** The Frame has a click button on its right side, for use without controllers: `KEY_SELECT` (353) on the evdev device `gpio-keys`. It has three gestures (`ButtonGestures`), so a session can run with the window out of reach (the standalone Hand Recorder's window is in SteamVR's dashboard, closed while recording):
|
||||
- **A press.** While a step waits (the welcome, a section's intro, a step's ready screen, the no-hands question) it's Next; during a countdown or hold (and auto mode's timed screens) it pauses; while paused it resumes. It counts once 0.45 s have passed without a second press, so Next and pause come that much after the press.
|
||||
- **Two presses**, the second down within 0.45 s of the first one's release: redo, as R. It counts at the second press. Where R does nothing (nothing recorded yet, a section's intro), neither does this.
|
||||
- **A hold** of 1.5 s: stop, as Esc. It counts after 1.5 s, while the button is still down.
|
||||
- The session finds the device in `/proc/bus/input/devices` by name and its KEY bitmap (`event3` on the maintainer's Frame) and reads `input_event` structs with plain `struct` (no python-evdev), from a thread. Downs (value 1) and ups (value 0) count; autorepeat (value 2) doesn't. The gaps between events come from the kernel's timestamps, so a double press read in one go still counts as two. A release and a press closer than 30 ms are one press (gpio-keys debounces too).
|
||||
- It's read, never grabbed (`EVIOCGRAB`). Frametop's input relay (`input/input-relay.py`), ft-powerd, SteamVR and gamescope read the same device, and the relay remaps its volume keys (the experimental branch's `docs/hazards.md`). A grab would take the volume keys from the relay.
|
||||
- `steamos` is in the `input` group, so it needs no sudo. The dev container sees the host's `/dev/input` and `/proc/bus/input/devices`, and the group carries over (checked 2026-10-02), so it works from the window there and from `session.py` on the host. If the device is missing or can't be opened, the session logs it, keeps trying every 5 s, and the hints don't mention the button.
|
||||
- **Not known yet (needs the headset):** what SteamVR and gamescope do with the same press. They read it too, so it may also click whatever is under the head or gaze pointer in VR, or open something. Check on the first session; if it does, the fix is on their side or a different button, not a grab.
|
||||
- **The Next hint follows what's there.** No mouse connected: "Ready? Press the button on the right side of the headset" (the window's Next can't be clicked, and Space needs the window focused). A mouse: "Ready? Press Space, click Next, or press the headset button". No button: "Ready? Press Space or click Next". The panel's bottom line leads with "Headset button: next, pause". A mouse is a device in `/proc/bus/input/devices` with EV_REL, REL_X and REL_Y that isn't made in software: uinput devices (Frametop's virtual mouse, frame-voice's keyboard) sit under `/devices/virtual/input` or on the virtual bus (6), and are left out; Bluetooth mice come through uhid, under `/devices/virtual/misc/uhid`, and count. It's looked at again at each step, so a mouse plugged in mid-session counts from the next step.
|
||||
- **Not known yet (needs the headset):** what SteamVR and gamescope do with the same press. They read it too, so it may also click whatever is under the head or gaze pointer in VR, or open something, and a hold or a double press may mean something else to them. Check on the first session; if it does, the fix is on their side or a different button, not a grab.
|
||||
- **The Next hint follows what's there.** No mouse connected: "Ready? Press the button on the right side of the headset" (the window's Next can't be clicked, and Space needs the window focused). A mouse: "Ready? Press Space, click Next, or press the headset button". No button: "Ready? Press Space or click Next". The panel's bottom line leads with "Headset button: press for next or pause · press twice to redo · hold to stop", then the window's keys by letter. With the button, the pause note and the no-hands question say how to resume or stop with it too. A mouse is a device in `/proc/bus/input/devices` with EV_REL, REL_X and REL_Y that isn't made in software: uinput devices (Frametop's virtual mouse, frame-voice's keyboard) sit under `/devices/virtual/input` or on the virtual bus (6), and are left out; Bluetooth mice come through uhid, under `/devices/virtual/misc/uhid`, and count. It's looked at again at each step, so a mouse plugged in mid-session counts from the next step.
|
||||
- **R (redo).** During a step's countdown or hold: that step starts again from its ready screen. At a step's ready screen: the step before it (in this section) goes again. Either way a `redo` event marks the range of the try being redone, so its labels are skipped; the sets stay, to delete in review if wanted.
|
||||
- A pause stops the take's recording and starts it again on resume as the next part of the same take (`sets-2.bin`, and so on). takes.py reads all parts in order. A pause while a step waits only shows "Paused"; a Next pressed while paused doesn't count.
|
||||
|
||||
|
||||
+26
-9
@@ -24,7 +24,9 @@ Everything lives under ~/.local/share/frametop/hands/contrib (--base). Nothing i
|
||||
unless the person presses Upload; while CONSENT.md or UPLOAD.md is a draft, Upload stays off
|
||||
unless FT_HANDREC_ALLOW_UPLOAD=1 (the maintainer's rehearsal against a test repo, picked with
|
||||
FT_HANDREC_DATASET). --hub-dry-run does everything but the network calls.
|
||||
Launch with hands/rec/ft-handrec (host wrapper).
|
||||
Launch with hands/rec/ft-handrec (host wrapper). The standalone Hand Recorder
|
||||
(github.com/Frametop/frametop-hand-recorder) runs this backend on the host, from a venv, with
|
||||
its own window for SteamVR's dashboard: --qml its main.qml, --style Basic.
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
@@ -43,6 +45,7 @@ from PySide6.QtGui import QColor, QDesktopServices, QFont, QGuiApplication, QIco
|
||||
from PySide6.QtQml import QQmlApplicationEngine
|
||||
from PySide6.QtQuick import QQuickImageProvider
|
||||
from PySide6.QtQuickControls2 import QQuickStyle
|
||||
import shiboken6
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
@@ -292,6 +295,11 @@ class Backend(QObject):
|
||||
def consentText(self):
|
||||
return read_text(CONSENT_PATH) or "CONSENT.md is missing."
|
||||
|
||||
@Property(str, constant=True)
|
||||
def toolVersion(self):
|
||||
"""What session.json records as the tool (takes.tool_version), for the window to show."""
|
||||
return takes.tool_version()
|
||||
|
||||
@Property(str, constant=True)
|
||||
def consentVersion(self):
|
||||
return consent_version()
|
||||
@@ -389,7 +397,8 @@ class Backend(QObject):
|
||||
measured = ""
|
||||
try:
|
||||
ring = mod.ring_lighting()
|
||||
if ring is None and not self.sessionActive:
|
||||
# A dry run starts nothing, the camera broker included.
|
||||
if ring is None and not self.sessionActive and not self._session_options.get("dry_run"):
|
||||
if mod.start_camd():
|
||||
self._camd_started = True
|
||||
time.sleep(1.0) # the first near-black frames
|
||||
@@ -457,11 +466,12 @@ 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):
|
||||
def _check_now(self, repair=False):
|
||||
"""repair (off this thread only: it may restart ft-camd, up to 15 s): see session.camera_check."""
|
||||
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()
|
||||
return mod.camera_check(repair=repair)
|
||||
|
||||
@Slot()
|
||||
def checkCameras(self):
|
||||
@@ -470,7 +480,7 @@ class Backend(QObject):
|
||||
return
|
||||
self._camera_busy = True
|
||||
self.cameraChanged.emit()
|
||||
self._thread(lambda: self._cameraArrived.emit(self._check_now()))
|
||||
self._thread(lambda: self._cameraArrived.emit(self._check_now(repair=True)))
|
||||
|
||||
def _on_camera(self, result):
|
||||
self._camera = result
|
||||
@@ -499,7 +509,7 @@ class Backend(QObject):
|
||||
end = time.monotonic() + RECHECK_FOR_S
|
||||
time.sleep(RECHECK_S * 2)
|
||||
while time.monotonic() < end:
|
||||
result = self._check_now()
|
||||
result = self._check_now(repair=True)
|
||||
# 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
|
||||
@@ -1172,6 +1182,9 @@ def main():
|
||||
help="test: Upload checks the export and says what it would send, with no network calls")
|
||||
ap.add_argument("--ignore-cameras", action="store_true",
|
||||
help="start sessions even if the camera check (hands/camcheck.py) finds the upper cameras off")
|
||||
ap.add_argument("--qml", default=os.path.join(HERE, "main.qml"),
|
||||
help="the window (default: this folder's main.qml, which needs Kirigami)")
|
||||
ap.add_argument("--style", default="org.kde.desktop", help="the Qt Quick Controls style")
|
||||
a, qt_args = ap.parse_known_args()
|
||||
app = QGuiApplication([sys.argv[0]] + qt_args)
|
||||
app.setApplicationName("ft-handrec")
|
||||
@@ -1179,7 +1192,7 @@ def main():
|
||||
app.setDesktopFileName("ft-handrec")
|
||||
if not QIcon.themeName():
|
||||
QIcon.setThemeName("breeze")
|
||||
QQuickStyle.setStyle("org.kde.desktop")
|
||||
QQuickStyle.setStyle(a.style)
|
||||
store = takes.Store(a.base)
|
||||
engine = QQmlApplicationEngine()
|
||||
engine.addImageProvider("frames", FrameProvider(store))
|
||||
@@ -1194,7 +1207,7 @@ def main():
|
||||
app.aboutToQuit.connect(backend.shutdown)
|
||||
engine.rootContext().setContextProperty("backend", backend)
|
||||
engine.rootContext().setContextProperty("startPage", a.page)
|
||||
engine.load(QUrl.fromLocalFile(os.path.join(HERE, "main.qml")))
|
||||
engine.load(QUrl.fromLocalFile(os.path.abspath(a.qml)))
|
||||
if not engine.rootObjects():
|
||||
sys.exit(1)
|
||||
|
||||
@@ -1207,7 +1220,11 @@ def main():
|
||||
signal.signal(signal.SIGINT, on_signal)
|
||||
tick = QTimer(interval=500, timeout=lambda: None)
|
||||
tick.start()
|
||||
sys.exit(app.exec())
|
||||
code = app.exec()
|
||||
# The window goes before the backend: otherwise its bindings run again against a deleted
|
||||
# backend as Python tears down ("Cannot read property ... of null", one per binding).
|
||||
shiboken6.delete(engine)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
# "Launch a program", which runs apps outside it; the standalone recorder is for that).
|
||||
# It doesn't turn on Frametop's live hand tracking (that's hands/run.sh install, still deferred).
|
||||
# Usage: hands/rec/install.sh install or update
|
||||
# hands/rec/install.sh uninstall remove the menu entry (recordings stay where they are)
|
||||
# hands/rec/install.sh uninstall remove the menu entry, and ft-camd's capabilities unless
|
||||
# hand tracking's services use them (hands/run.sh uncaps).
|
||||
# Recordings stay where they are.
|
||||
set -euo pipefail
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
. "$root/scripts/_env.sh"
|
||||
@@ -23,6 +25,7 @@ case ${1:-install} in
|
||||
"$root/hands/rec/build.sh"
|
||||
echo "== 4/4 ft-camd's capabilities (asks for your password) and the menu entry"
|
||||
"$root/hands/run.sh" caps
|
||||
"$root/scripts/conf-migrate.sh" # HANDS_SWAP_SIDES=0, the old default, becomes auto
|
||||
fill_template "$root/hands/rec/ft-handrec.desktop" |
|
||||
on_frame "mkdir -p ~/.local/share/applications && cat > $entry"
|
||||
echo
|
||||
@@ -31,7 +34,9 @@ case ${1:-install} in
|
||||
;;
|
||||
uninstall)
|
||||
on_frame "rm -f $entry"
|
||||
echo "Removed the menu entry. Your recordings are still in ~/.local/share/frametop/hands/contrib:"
|
||||
echo "Removed the menu entry."
|
||||
"$root/hands/run.sh" uncaps # after the entry, which counts as a user of the capabilities
|
||||
echo "Your recordings are still in ~/.local/share/frametop/hands/contrib:"
|
||||
echo "delete that folder to remove them."
|
||||
;;
|
||||
*) echo "usage: $0 [install|uninstall]" >&2; exit 2 ;;
|
||||
|
||||
+6
-3
@@ -362,7 +362,8 @@ Kirigami.ApplicationWindow {
|
||||
wrapMode: Text.Wrap
|
||||
opacity: 0.7
|
||||
text: "Each round in a different light helps the most: dim, a normal room, daylight. The cameras "
|
||||
+ "tell daylight from indoor light themselves; to say dim or a normal room, pick it here."
|
||||
+ "can't tell daylight on their own (a sunlit room has measured as indoor light), so if "
|
||||
+ "sunlight comes into the room, pick Daylight here; dim or a normal room the same way."
|
||||
}
|
||||
Kirigami.InlineMessage {
|
||||
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
||||
@@ -486,7 +487,8 @@ Kirigami.ApplicationWindow {
|
||||
+ "Each step waits until you're ready: press the button on the right side of the headset, "
|
||||
+ "or Space or Next in this window. A 3-2-1 "
|
||||
+ "countdown follows, then hold the pose until the bar runs out. Nothing is recorded while "
|
||||
+ "a step waits. The headset button also pauses and resumes a recording. In this window P "
|
||||
+ "a step waits. The headset button also pauses and resumes a recording; press it twice "
|
||||
+ "to record a step again, or hold it to stop. In this window P "
|
||||
+ "pauses, R records the last step again, S skips a section and Esc stops.\n\n"
|
||||
+ "Nothing leaves the headset. Afterwards you watch the takes in Review, delete anything "
|
||||
+ "you don't want to share, and only then export."
|
||||
@@ -919,7 +921,8 @@ Kirigami.ApplicationWindow {
|
||||
opacity: 0.7
|
||||
text: "The instructions appear in the headset. "
|
||||
+ (sessionView.st.button ? "The button on the right side of the headset: "
|
||||
+ (sessionView.stepMode ? "next, " : "") + "pause or resume. " : "")
|
||||
+ (sessionView.stepMode ? "next, " : "") + "pause or resume; twice: record "
|
||||
+ "the last step again; hold: stop. " : "")
|
||||
+ "While this window has focus: "
|
||||
+ (sessionView.stepMode ? "Space: next · " : "")
|
||||
+ "P: pause or resume · R: record the last step again · S: skip section · Esc: stop. "
|
||||
|
||||
+193
-61
@@ -51,6 +51,7 @@ sys.path.insert(0, HANDS)
|
||||
import camcheck # noqa: E402 (hands/camcheck.py: are all four mono cameras running?)
|
||||
sys.path.insert(0, HERE)
|
||||
import sides # noqa: E402 (hands/rec/sides.py: which side camera is which)
|
||||
import takes # noqa: E402 (hands/rec/takes.py: the version, and the standalone recorder's build info)
|
||||
FT_HANDS = os.path.join(HANDS, "build", "ft-hands")
|
||||
FT_CAMD = os.path.join(HANDS, "build", "ft-camd")
|
||||
PANEL_BIN = os.path.join(HERE, "build", "ft-handpanel")
|
||||
@@ -60,6 +61,9 @@ BASE_DIR = os.path.expanduser("~/.local/share/frametop/hands/contrib")
|
||||
PANEL_SOCKET = "ft_handpanel"
|
||||
CAMD_UNIT = "frametop-handrec-camd.service"
|
||||
HANDS_UNIT = "frametop-handrec-hands.service"
|
||||
# The standalone Hand Recorder (takes.standalone()): its binaries run on the host, so ft-hands
|
||||
# starts directly rather than in the dev container, and repairs mean its install command.
|
||||
STANDALONE = takes.standalone()
|
||||
|
||||
RECORD_HZ = 10
|
||||
SIDES_READ_S = 0.5 # how often the tracking ft-hands' side camera decision is read
|
||||
@@ -72,15 +76,19 @@ MIN_FREE = 1.5e9 # stop the session before the disk fills
|
||||
COUNTDOWN_S = 3 # step mode: the 3-2-1 before each step, recorded
|
||||
FIRST_SET_S = 3.0 # step mode: how long the hold may wait for its recording's first set
|
||||
RESUME_HINT = "Paused. Resume: P in the Hand recorder window"
|
||||
RESUME_HINT_BUTTON = "Paused. Resume: press the headset button, or P in the Hand recorder window"
|
||||
READY_TEXT = "Ready? Press Space or click Next"
|
||||
# With the headset's button: it leads when no mouse is connected (the window's Next can't be clicked).
|
||||
READY_BUTTON = "Ready? Press the button on the right side of the headset"
|
||||
READY_BUTTON_MOUSE = "Ready? Press Space, click Next, or press the headset button"
|
||||
KEYS_STEP = "Hand recorder window: Space next \u00b7 P pause \u00b7 R redo \u00b7 S skip section \u00b7 Esc stop"
|
||||
KEYS_AUTO = "Hand recorder window: P pause \u00b7 R redo \u00b7 S skip section \u00b7 Esc stop"
|
||||
KEYS_STEP_BUTTON = ("Headset button: next, pause \u00b7 Window: Space next \u00b7 P pause \u00b7 R redo "
|
||||
"\u00b7 S skip \u00b7 Esc stop")
|
||||
KEYS_AUTO_BUTTON = "Headset button: pause \u00b7 Window: P pause \u00b7 R redo \u00b7 S skip section \u00b7 Esc stop"
|
||||
# The headset button: a press, two (redo) and a hold (stop): ButtonGestures. The window's keys follow
|
||||
# by letter: the window lists what they do.
|
||||
KEYS_STEP_BUTTON = ("Headset button: press for next or pause \u00b7 press twice to redo \u00b7 hold to stop "
|
||||
"\u00b7 Window keys: Space P R S Esc")
|
||||
KEYS_AUTO_BUTTON = ("Headset button: press to pause \u00b7 press twice to redo \u00b7 hold to stop "
|
||||
"\u00b7 Window keys: P R S Esc")
|
||||
# The early no-hands stop (DESIGN.md, "Camera check"): the first step of this section has both
|
||||
# hands up. If the live tracker publishes through its hold and never sees a hand, the session
|
||||
# stops that step and asks: try again, or stop. Only when the tracker published in at least
|
||||
@@ -93,6 +101,8 @@ NO_HANDS_TITLE = "I can't see your hands"
|
||||
NO_HANDS_TEXT = "The hand tracker didn't see either of your hands during that whole step."
|
||||
NO_HANDS_RETRY = ("Try again: hold both hands up in front of you, about 40 cm away. To stop instead: "
|
||||
"Esc or Stop in the Hand recorder window.")
|
||||
NO_HANDS_RETRY_BUTTON = ("Try again: hold both hands up in front of you, about 40 cm away, and press the headset "
|
||||
"button. To stop instead: hold the button, or Esc or Stop in the Hand recorder window.")
|
||||
|
||||
|
||||
def mono_ns():
|
||||
@@ -131,6 +141,13 @@ def host_command(*cmd):
|
||||
exe] + list(cmd)
|
||||
|
||||
|
||||
def fix_hint(frametop):
|
||||
"""How to repair a missing or broken part: Frametop's command, or the standalone install's."""
|
||||
if STANDALONE:
|
||||
return STANDALONE.get("reinstall") or "run the Hand Recorder's install command again"
|
||||
return frametop
|
||||
|
||||
|
||||
def host_path(path):
|
||||
"""A host file, from the container (/run/host) or the host itself."""
|
||||
for p in ("/run/host" + path, path):
|
||||
@@ -292,7 +309,7 @@ def similar_lighting(base_dir, lighting):
|
||||
return None
|
||||
|
||||
|
||||
DAYLIGHT_IR = 6.0 # ambient IR (the mono cameras' mean dark_mean) from which it's daylight
|
||||
DAYLIGHT_IR = 6.0 # ambient IR (the mono cameras' mean dark_mean) from which it's daylight; see classify_lighting
|
||||
|
||||
|
||||
def ambient_ir(ring):
|
||||
@@ -306,7 +323,13 @@ def ambient_ir(ring):
|
||||
def classify_lighting(ring):
|
||||
""""daylight" or "indoor" from the room's infrared light, "" if it can't tell. Sunlight
|
||||
carries a lot of infrared; lamps and LEDs hardly any, so a dim room and a bright one read
|
||||
about the same (2026-10: 1.8 by one lamp, 2.2 in a lamp-lit room) and aren't told apart."""
|
||||
about the same (2026-10: 1.8 by one lamp, 2.2 in a lamp-lit room) and aren't told apart.
|
||||
Nor does a sunlit room reliably: the first daylight round (dataset PR #5, big sunlit windows)
|
||||
read 2.38, since the windows are a small part of each picture and the mean barely moves. So
|
||||
"indoor" means "no strong daylight on the cameras", and the window asks people to pick
|
||||
daylight themselves. Review corrects a missed one from the pictures (sunlit windows, the time
|
||||
of day): hands standing out little from the room goes with daylight but also with pale rooms
|
||||
at night, so it isn't a test either."""
|
||||
ir = ambient_ir(ring)
|
||||
if ir is None:
|
||||
return ""
|
||||
@@ -369,10 +392,10 @@ def start_camd(path=None, log=lambda line: None, stop=lambda: False, timeout=15.
|
||||
if ring_alive(path):
|
||||
return False
|
||||
if not os.access(FT_CAMD, os.X_OK):
|
||||
raise RuntimeError("ft-camd isn't built: hands/build.sh")
|
||||
raise RuntimeError("ft-camd isn't built: " + fix_hint("hands/build.sh"))
|
||||
caps = subprocess.run(["getcap", FT_CAMD], capture_output=True, text=True) if shutil.which("getcap") else None
|
||||
if caps is not None and "cap_sys_ptrace" not in caps.stdout:
|
||||
raise RuntimeError("ft-camd needs its capabilities: hands/run.sh caps (asks for sudo)")
|
||||
raise RuntimeError("ft-camd needs its capabilities: " + fix_hint("hands/run.sh caps (asks for sudo)"))
|
||||
started = start_unit(CAMD_UNIT, "the camera broker", [FT_CAMD, "--status", "60"], log)
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
@@ -614,7 +637,7 @@ class Recorder:
|
||||
"--sides", "auto" if swap is None else "1" if swap else "0"]
|
||||
if ring:
|
||||
argv += ["--ring", ring]
|
||||
if not in_container():
|
||||
if not in_container() and not STANDALONE:
|
||||
argv = [os.path.expanduser("~/.local/bin/distrobox"), "enter", "dev", "--"] + argv
|
||||
self.proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=log_file, stderr=log_file)
|
||||
self.started_ns = mono_ns()
|
||||
@@ -673,7 +696,9 @@ EV_KEY, EV_REL = 0x01, 0x02
|
||||
REL_X, REL_Y = 0x00, 0x01
|
||||
KEY_SELECT = 353
|
||||
BUTTON_DEVICE = "gpio-keys"
|
||||
BUTTON_DEBOUNCE_S = 0.3 # presses closer than this count once
|
||||
BUTTON_DOUBLE_S = 0.45 # a second press this soon after the first one's release: a double press
|
||||
BUTTON_HOLD_S = 1.5 # held down this long: a hold
|
||||
BUTTON_BOUNCE_S = 0.03 # a release and press closer than this are one press (gpio-keys debounces too)
|
||||
|
||||
|
||||
def parse_input_devices(text):
|
||||
@@ -753,29 +778,84 @@ def mouse_connected(path=INPUT_DEVICES):
|
||||
return any(real_mouse(d) for d in read_input_devices(path))
|
||||
|
||||
|
||||
def button_presses(data):
|
||||
"""KEY_SELECT key-downs in a run of input_event structs (value 1; releases and autorepeat
|
||||
are left out). Returns (how many, the bytes left over after the last whole event)."""
|
||||
n, usable = 0, len(data) - len(data) % INPUT_EVENT.size
|
||||
def button_events(data):
|
||||
"""KEY_SELECT downs and ups in a run of input_event structs: [(True for a down or False for
|
||||
an up, the event's time in seconds), ...] (autorepeat, value 2, is left out). Returns (them,
|
||||
the bytes left over after the last whole event)."""
|
||||
out, usable = [], len(data) - len(data) % INPUT_EVENT.size
|
||||
for off in range(0, usable, INPUT_EVENT.size):
|
||||
_, _, etype, code, value = INPUT_EVENT.unpack_from(data, off)
|
||||
if etype == EV_KEY and code == KEY_SELECT and value == 1:
|
||||
n += 1
|
||||
return n, data[usable:]
|
||||
sec, usec, etype, code, value = INPUT_EVENT.unpack_from(data, off)
|
||||
if etype == EV_KEY and code == KEY_SELECT and value in (0, 1):
|
||||
out.append((value == 1, sec + usec / 1e6))
|
||||
return out, data[usable:]
|
||||
|
||||
|
||||
class ButtonGestures:
|
||||
"""The headset button's downs and ups, with their times, as gestures:
|
||||
"press": one short press, reported once the double-press time has passed without a second;
|
||||
"double": two presses, the second down within double_s of the first one's release, reported
|
||||
at the second down;
|
||||
"hold": held down for hold_s, reported while still down.
|
||||
A release and a press closer than bounce_s are one press. Each call returns the gestures that
|
||||
are due, in order; tick() is called now and then to report the ones that are due by time."""
|
||||
|
||||
def __init__(self, double_s=BUTTON_DOUBLE_S, hold_s=BUTTON_HOLD_S, bounce_s=BUTTON_BOUNCE_S):
|
||||
self.double_s, self.hold_s, self.bounce_s = double_s, hold_s, bounce_s
|
||||
self.down_at = None # when the button went down, while it's down
|
||||
self.done = False # the press that's down was already reported (a double, a hold)
|
||||
self.released = None # when a short press was let go, until it's reported or doubled
|
||||
self._up = None # the last release, for the bounce: (time, down_at, done, released before it)
|
||||
|
||||
def down(self, t):
|
||||
if self.down_at is not None: # a missed release
|
||||
return []
|
||||
if self._up and t - self._up[0] < self.bounce_s: # a bounce: still the same press
|
||||
_, self.down_at, self.done, self.released = self._up
|
||||
self._up = None
|
||||
return []
|
||||
out = self.tick(t)
|
||||
self.down_at, self.done = t, False
|
||||
if self.released is not None: # within the double-press time (else tick reported it)
|
||||
self.released, self.done = None, True
|
||||
out.append("double")
|
||||
return out
|
||||
|
||||
def up(self, t):
|
||||
if self.down_at is None:
|
||||
return []
|
||||
out = self.tick(t)
|
||||
self._up = (t, self.down_at, self.done, self.released)
|
||||
if not self.done:
|
||||
self.released = t
|
||||
self.down_at, self.done = None, False
|
||||
return out
|
||||
|
||||
def tick(self, t):
|
||||
if self.down_at is not None and not self.done and t - self.down_at >= self.hold_s:
|
||||
self.done = True
|
||||
return ["hold"]
|
||||
if self.released is not None and t - self.released > self.double_s:
|
||||
self.released = None
|
||||
return ["press"]
|
||||
return []
|
||||
|
||||
def waiting(self):
|
||||
"""Something is due by time alone: a hold, or a press that may still become a double."""
|
||||
return (self.down_at is not None and not self.done) or self.released is not None
|
||||
|
||||
|
||||
class ButtonReader:
|
||||
"""Reads the headset button on a thread and calls on_press() per press, debounced. path:
|
||||
an event device or, for testing, a FIFO carrying input_event structs. It's opened read-only
|
||||
and shared; if it can't be opened (no device, no permission, /dev/input not reachable in a
|
||||
container) it says so in the log and tries again now and then."""
|
||||
"""Reads the headset button on a thread and calls on_gesture("press" | "double" | "hold")
|
||||
(ButtonGestures). path: an event device or, for testing, a FIFO carrying input_event
|
||||
structs. It's opened read-only and shared; if it can't be opened (no device, no permission,
|
||||
/dev/input not reachable in a container) it says so in the log and tries again now and then."""
|
||||
|
||||
def __init__(self, path, on_press, log=None, debounce_s=BUTTON_DEBOUNCE_S):
|
||||
self.path, self.on_press, self.log = path, on_press, log or (lambda s: None)
|
||||
self.debounce_s = debounce_s
|
||||
def __init__(self, path, on_gesture, log=None, double_s=BUTTON_DOUBLE_S, hold_s=BUTTON_HOLD_S,
|
||||
bounce_s=BUTTON_BOUNCE_S):
|
||||
self.path, self.on_gesture, self.log = path, on_gesture, log or (lambda s: None)
|
||||
self.gestures = ButtonGestures(double_s, hold_s, bounce_s)
|
||||
self.ok = False # opened at least once
|
||||
self._stop = threading.Event()
|
||||
self._last = -1e9
|
||||
self._thread = threading.Thread(target=self._run, name="handrec-button", daemon=True)
|
||||
|
||||
def start(self):
|
||||
@@ -786,11 +866,9 @@ class ButtonReader:
|
||||
self._stop.set()
|
||||
self._thread.join(2)
|
||||
|
||||
def _press(self, n):
|
||||
now = time.monotonic()
|
||||
if n and now - self._last >= self.debounce_s:
|
||||
self._last = now
|
||||
self.on_press()
|
||||
def _report(self, gestures):
|
||||
for g in gestures:
|
||||
self.on_gesture(g)
|
||||
|
||||
def _run(self):
|
||||
failed = False
|
||||
@@ -809,8 +887,9 @@ class ButtonReader:
|
||||
rest = b""
|
||||
try:
|
||||
while not self._stop.is_set():
|
||||
r, _, _ = select.select([fd], [], [], 0.2)
|
||||
r, _, _ = select.select([fd], [], [], 0.02 if self.gestures.waiting() else 0.2)
|
||||
if not r:
|
||||
self._report(self.gestures.tick(time.monotonic()))
|
||||
continue
|
||||
try:
|
||||
data = os.read(fd, INPUT_EVENT.size * 64)
|
||||
@@ -819,8 +898,13 @@ class ButtonReader:
|
||||
if not data: # a FIFO's writer left: open it again
|
||||
self._stop.wait(0.2)
|
||||
break
|
||||
n, rest = button_presses(rest + data)
|
||||
self._press(n)
|
||||
events, rest = button_events(rest + data)
|
||||
# The kernel's times (the realtime clock) keep the gaps between events that
|
||||
# came in one read; the last one is taken as now.
|
||||
now = time.monotonic()
|
||||
for is_down, t in events:
|
||||
at = now - min(max(events[-1][1] - t, 0.0), 2.0)
|
||||
self._report(self.gestures.down(at) if is_down else self.gestures.up(at))
|
||||
except OSError as e: # the device went away
|
||||
self.log("headset button: %s: %s" % (self.path, e.strerror))
|
||||
self._stop.wait(2)
|
||||
@@ -1117,15 +1201,6 @@ class _Redo(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _git_describe():
|
||||
try:
|
||||
r = subprocess.run(["git", "-C", REPO, "describe", "--always", "--dirty", "--tags"],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
return r.stdout.strip() or "unknown"
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _os_version():
|
||||
path = "/run/host/etc/os-release" if in_container() else "/etc/os-release"
|
||||
try:
|
||||
@@ -1153,11 +1228,33 @@ def _steamvr_version():
|
||||
return ""
|
||||
|
||||
|
||||
def camera_check():
|
||||
"""camcheck.check() (the XRService log, its open cameras where readable, ft-camd's ring);
|
||||
never raises: a failure is "unknown"."""
|
||||
_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 camcheck.check()
|
||||
return int(r.stdout.strip() or 0)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def camera_check(repair=False):
|
||||
"""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."""
|
||||
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
|
||||
except Exception as e:
|
||||
return {"status": "unknown", "summary": "unknown: the camera check failed (%s)" % e,
|
||||
"reason": str(e), "evidence": []}
|
||||
@@ -1169,6 +1266,10 @@ 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, %s, and open it again. If that doesn't help, ask in the Frametop "
|
||||
"Discord." % (result.get("reason", ""), fix_hint("run ~/frametop/hands/rec/install.sh again")))
|
||||
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", ""))
|
||||
|
||||
@@ -1271,8 +1372,25 @@ class Session:
|
||||
self._want[key] = value
|
||||
self._wake.set()
|
||||
|
||||
def button_gesture(self, gesture):
|
||||
"""The headset's button (ButtonGestures). A press: Next while a step waits, pause during
|
||||
a countdown or hold (and auto mode's timed screens), resume while paused. A double press:
|
||||
redo, as R. A hold: stop, as Esc."""
|
||||
if gesture == "press":
|
||||
self.button_press()
|
||||
elif gesture == "double":
|
||||
if self._status["state"] in ("done", "stopped", "error") or not self._status.get("can_redo"):
|
||||
return
|
||||
self.redo()
|
||||
self._log("headset button (redo)")
|
||||
elif gesture == "hold":
|
||||
if self._status["state"] in ("done", "stopped", "error"):
|
||||
return
|
||||
self._log("headset button (stop)")
|
||||
self.stop(wait=0)
|
||||
|
||||
def button_press(self):
|
||||
"""The headset's button: Next while a step waits, pause during a countdown or hold
|
||||
"""The headset button's press: Next while a step waits, pause during a countdown or hold
|
||||
(and auto mode's timed screens), resume while paused."""
|
||||
with self._lock:
|
||||
paused = self._want["pause"]
|
||||
@@ -1418,7 +1536,7 @@ class Session:
|
||||
self._ring_path = ring_path
|
||||
if not self.dry_run:
|
||||
if not os.access(FT_HANDS, os.X_OK):
|
||||
raise _Fail("ft-hands isn't built: hands/build.sh")
|
||||
raise _Fail("ft-hands isn't built: " + fix_hint("hands/build.sh"))
|
||||
self._ensure_ring(ring_path)
|
||||
if not tracker_running():
|
||||
if self.start_processes:
|
||||
@@ -1436,7 +1554,7 @@ class Session:
|
||||
pass
|
||||
removed = self._write_calibration() + self._write_device()
|
||||
self._session_json = {
|
||||
"schema": 1, "tool": "ft-handrec " + _git_describe(), "started": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||
"schema": 1, "tool": takes.tool_version(), "started": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||
"contributor": self.profile.get("contributor", ""),
|
||||
"lighting": lighting_record(self.lighting_choice, lighting),
|
||||
"checklist": self.checklist,
|
||||
@@ -1451,6 +1569,10 @@ 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:
|
||||
@@ -1477,7 +1599,8 @@ class Session:
|
||||
if not path:
|
||||
self._log("headset button: no %s device with KEY_SELECT in %s" % (BUTTON_DEVICE, INPUT_DEVICES))
|
||||
return
|
||||
self._button = ButtonReader(path, self.button_press, log=self._log, debounce_s=BUTTON_DEBOUNCE_S).start()
|
||||
self._button = ButtonReader(path, self.button_gesture, log=self._log, double_s=BUTTON_DOUBLE_S,
|
||||
hold_s=BUTTON_HOLD_S, bounce_s=BUTTON_BOUNCE_S).start()
|
||||
end = time.monotonic() + 0.5 # opened in a moment, or it isn't reachable
|
||||
while not self._button.ok and time.monotonic() < end:
|
||||
time.sleep(0.01)
|
||||
@@ -1545,6 +1668,9 @@ class Session:
|
||||
self._save_session()
|
||||
self._log("side cameras: %s (%s, %s)" % ("SWAPPED" if swapped else "as named", new["decided_by"],
|
||||
json.dumps(new["evidence"])))
|
||||
if new["state"] == "forced, disagrees":
|
||||
self._log("side cameras: HANDS_SWAP_SIDES in ~/.config/frametop.conf forces names the hands say are "
|
||||
"backwards; the recording goes by the hands. Set HANDS_SWAP_SIDES=auto.")
|
||||
|
||||
def _sides_swapped(self):
|
||||
"""session.json's decision: True, False, or None (not known yet)."""
|
||||
@@ -1566,11 +1692,12 @@ class Session:
|
||||
raise _Stop()
|
||||
|
||||
def _start_tracker(self):
|
||||
up = os.path.join(REPO, "scripts", "container-up.sh")
|
||||
if os.access(up, os.X_OK):
|
||||
subprocess.run(host_command(up), capture_output=True, timeout=120)
|
||||
argv = [os.path.expanduser("~/.local/bin/distrobox"), "enter", "dev", "--", FT_HANDS,
|
||||
"--no-gestures", "--status", "0"]
|
||||
argv = [FT_HANDS, "--no-gestures", "--status", "0"]
|
||||
if not STANDALONE: # built in the dev container: it runs there
|
||||
up = os.path.join(REPO, "scripts", "container-up.sh")
|
||||
if os.access(up, os.X_OK):
|
||||
subprocess.run(host_command(up), capture_output=True, timeout=120)
|
||||
argv = [os.path.expanduser("~/.local/bin/distrobox"), "enter", "dev", "--"] + argv
|
||||
if self.ring:
|
||||
argv += ["--ring", self.ring]
|
||||
self._start_unit(HANDS_UNIT, "hand tracking for feedback", argv)
|
||||
@@ -1593,7 +1720,7 @@ class Session:
|
||||
self._log("using the ft-handpanel that's running")
|
||||
return
|
||||
if not os.access(self.panel_bin, os.X_OK):
|
||||
raise _Fail("ft-handpanel isn't built: hands/rec/build.sh")
|
||||
raise _Fail("ft-handpanel isn't built: " + fix_hint("hands/rec/build.sh"))
|
||||
self._panel_proc = subprocess.Popen([self.panel_bin, "--watch-stdin"], stdin=subprocess.PIPE,
|
||||
stdout=self._log_file, stderr=self._log_file)
|
||||
end = time.monotonic() + 15
|
||||
@@ -1709,9 +1836,10 @@ class Session:
|
||||
if self._recording:
|
||||
self._stop_recording()
|
||||
self._event("pause")
|
||||
hint = RESUME_HINT_BUTTON if self._button_ok() else RESUME_HINT
|
||||
self._panel.cmd("paused on")
|
||||
self._panel.set("note", "note " + RESUME_HINT)
|
||||
self._emit(state="paused", note=RESUME_HINT)
|
||||
self._panel.set("note", "note " + hint)
|
||||
self._emit(state="paused", note=hint)
|
||||
self._log("paused")
|
||||
|
||||
def _unpause(self, record=True):
|
||||
@@ -1897,10 +2025,13 @@ class Session:
|
||||
for e in items)))
|
||||
self._status.update(strip=items, cue=cue)
|
||||
|
||||
def _button_ok(self):
|
||||
return bool(self._button and self._button.ok)
|
||||
|
||||
def _hints(self, action=True):
|
||||
"""The Next hint (with action) and the key line for what's there now: the headset button
|
||||
leads when no mouse is connected. Looked at again for each step, so a mouse plugged in counts."""
|
||||
button = bool(self._button and self._button.ok)
|
||||
button = self._button_ok()
|
||||
mouse = mouse_connected(self.input_devices)
|
||||
text = (READY_BUTTON_MOUSE if mouse else READY_BUTTON) if button else READY_TEXT
|
||||
keys = (KEYS_AUTO_BUTTON if self.auto else KEYS_STEP_BUTTON) if button else (KEYS_AUTO if self.auto else KEYS_STEP)
|
||||
@@ -2206,7 +2337,7 @@ class Session:
|
||||
for line in (cam or {}).get("evidence", []):
|
||||
self._log(" " + line)
|
||||
text = "%s|%s|%s" % (NO_HANDS_TEXT, cam_text or "The camera check found nothing wrong (%s)." % summary,
|
||||
NO_HANDS_RETRY)
|
||||
NO_HANDS_RETRY_BUTTON if self._button_ok() else NO_HANDS_RETRY)
|
||||
self._stop_note = "Stopped: no hands were seen in the first step. Camera check: %s." % summary
|
||||
if cam_text:
|
||||
self._stop_note += " " + cam_text
|
||||
@@ -2459,7 +2590,8 @@ def main():
|
||||
help="test: press Next by itself after S seconds of waiting (real time)")
|
||||
ap.add_argument("--poses", help="the pose pictures' folder, with poses.json (default hands/rec/poses)")
|
||||
ap.add_argument("--no-headset-button", action="store_true",
|
||||
help="don't read the headset's button (gpio-keys KEY_SELECT: Next, pause, resume)")
|
||||
help="don't read the headset's button (gpio-keys KEY_SELECT: a press is Next, pause or resume; "
|
||||
"two are redo; a hold is stop)")
|
||||
ap.add_argument("--button-device", metavar="PATH",
|
||||
help="test: read the button from this event device or FIFO of input_event structs (also in a dry run)")
|
||||
ap.add_argument("--quick", action="store_true",
|
||||
|
||||
+9
-3
@@ -1,8 +1,9 @@
|
||||
"""Which side camera is which, in recordings: the rules every reader shares.
|
||||
|
||||
ft-camd tells slam_left's buffers from slam_right's by the order XRService allocated them, and
|
||||
some XRService starts reverse it: then each side camera's images carry the other's name. A
|
||||
tracking ft-hands tells from the hands (hands/track/sides.h, HANDS_SWAP_SIDES=auto) and publishes
|
||||
Before 2026-10-05, ft-hands named the side cameras by XRService's start-up order and ft-camd bound
|
||||
their buffers by XRService's descriptor order, so on most starts each side camera's images carried
|
||||
the other's name (hands/README.md, "Which camera is which"). Both are exact now, and a
|
||||
tracking ft-hands still tells from the hands (hands/track/sides.h, HANDS_SWAP_SIDES=auto) and publishes
|
||||
what it found in /run/user/UID/frametop-hands/sides.json (read_live). Two things are recorded:
|
||||
|
||||
swapped whether ft-camd's naming was backwards during the recording (the truth);
|
||||
@@ -125,4 +126,9 @@ def read_live(path=None, ring_path=None, now_ns=None):
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
if (s.get("state") == "forced, disagrees" and s.get("swapped") is not None
|
||||
and bool(s["swapped"]) == bool(s.get("names_swapped"))):
|
||||
# An ft-hands built before 2026-10-06 kept a forced HANDS_SWAP_SIDES as the truth even
|
||||
# when the hands disagreed. The hands are right: the truth is the other way round.
|
||||
s = dict(s, swapped=not s["swapped"], decided_by="auto")
|
||||
return s
|
||||
+19
-1
@@ -83,8 +83,26 @@ def find_zstd():
|
||||
return next((p for p in ZSTD_PATHS if os.access(p, os.X_OK)), None)
|
||||
|
||||
|
||||
def standalone(path=None):
|
||||
"""The standalone Hand Recorder's build info, or None in a Frametop checkout. Its release
|
||||
(github.com/Frametop/frametop-hand-recorder) ships this repo's tree with standalone.json at
|
||||
the top: {"name", "version", "frametop": this repo's git describe, "reinstall": how to repair
|
||||
an install}. Its binaries are built for the SteamOS host, so nothing runs in the dev container."""
|
||||
try:
|
||||
with open(path or os.path.join(REPO, "standalone.json")) as f:
|
||||
info = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return info if isinstance(info, dict) else None
|
||||
|
||||
|
||||
def tool_version():
|
||||
"""ft-handrec plus the checkout's git describe, for session.json and the manifest."""
|
||||
"""ft-handrec plus the checkout's git describe, for session.json and the manifest. The
|
||||
standalone Hand Recorder has no checkout: the describe it was built from, and its version."""
|
||||
info = standalone()
|
||||
if info:
|
||||
return "ft-handrec %s (%s %s)" % (info.get("frametop") or "unknown", info.get("name") or "standalone",
|
||||
info.get("version") or "unknown")
|
||||
try:
|
||||
out = subprocess.run(["git", "-C", REPO, "describe", "--always", "--dirty", "--tags"],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"""main.qml against ft_handrec.Backend: every backend.name(...) the window calls is a slot, and
|
||||
every backend.name it reads is a property or a slot. A method that lost its @Slot shows up in
|
||||
QML only as "is not a function" when its button is pressed (2026-10-03: Export did nothing).
|
||||
Needs PySide6 (the dev container); skipped without it.
|
||||
Needs PySide6 (the dev container); skipped without it. FT_HANDREC_QML=PATH checks another
|
||||
window against the same backend (the standalone Hand Recorder's, in its CI).
|
||||
|
||||
python3 hands/rec/tests/test_qml_backend.py
|
||||
"""
|
||||
@@ -14,6 +15,7 @@ import unittest
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
REC = os.path.dirname(HERE)
|
||||
sys.path.insert(0, REC)
|
||||
QML = os.environ.get("FT_HANDREC_QML") or os.path.join(REC, "main.qml")
|
||||
|
||||
|
||||
class QmlBackendTest(unittest.TestCase):
|
||||
@@ -25,7 +27,7 @@ class QmlBackendTest(unittest.TestCase):
|
||||
meta = self.meta = ft_handrec.Backend.staticMetaObject
|
||||
self.slots = {bytes(meta.method(i).name()).decode() for i in range(meta.methodCount())}
|
||||
self.props = {meta.property(i).name() for i in range(meta.propertyCount())}
|
||||
with open(os.path.join(REC, "main.qml")) as f:
|
||||
with open(QML) as f:
|
||||
self.qml = f.read()
|
||||
|
||||
def test_calls_are_slots(self):
|
||||
|
||||
+154
-17
@@ -458,8 +458,15 @@ B: MSC=10
|
||||
"""
|
||||
|
||||
|
||||
def event(etype, code, value):
|
||||
return session.INPUT_EVENT.pack(1, 2, etype, code, value)
|
||||
def event(etype, code, value, t=1.000002):
|
||||
return session.INPUT_EVENT.pack(int(t), round(t % 1 * 1e6), etype, code, value)
|
||||
|
||||
|
||||
def taps(*times):
|
||||
"""Presses, each 50 ms down, at these times (the kernel's clock), as one write."""
|
||||
return b"".join(event(session.EV_KEY, session.KEY_SELECT, 1, t) + event(0, 0, 0, t)
|
||||
+ event(session.EV_KEY, session.KEY_SELECT, 0, t + 0.05) + event(0, 0, 0, t + 0.05)
|
||||
for t in times)
|
||||
|
||||
|
||||
PRESS = event(session.EV_KEY, session.KEY_SELECT, 1) + event(0, 0, 0)
|
||||
@@ -479,35 +486,74 @@ class InputTest(unittest.TestCase):
|
||||
# another gpio-keys without KEY_SELECT isn't the button
|
||||
self.assertIsNone(session.find_button(session.parse_input_devices(DEVICES.replace("200000000", "0"))))
|
||||
|
||||
def test_presses(self):
|
||||
data = (PRESS + event(session.EV_KEY, session.KEY_SELECT, 2) * 3 # autorepeat: not presses
|
||||
def test_events(self):
|
||||
data = (PRESS + event(session.EV_KEY, session.KEY_SELECT, 2) * 3 # autorepeat: left out
|
||||
+ RELEASE + event(session.EV_KEY, 115, 1) + PRESS) # another key
|
||||
self.assertEqual(session.button_presses(data), (2, b""))
|
||||
n, rest = session.button_presses(PRESS + PRESS[:10])
|
||||
self.assertEqual((n, rest), (1, PRESS[:10])) # half an event waits for the rest
|
||||
self.assertEqual(session.button_presses(rest + PRESS[10:])[0], 1)
|
||||
self.assertEqual(session.button_events(data), ([(True, 1.000002), (False, 1.000002), (True, 1.000002)], b""))
|
||||
out, rest = session.button_events(PRESS + PRESS[:10])
|
||||
self.assertEqual((out, rest), ([(True, 1.000002)], PRESS[:10])) # half an event waits for the rest
|
||||
self.assertEqual(session.button_events(rest + PRESS[10:])[0], [(True, 1.000002)])
|
||||
self.assertEqual([t for _, t in session.button_events(taps(5.25))[0]], [5.25, 5.3])
|
||||
|
||||
def test_gestures(self):
|
||||
def run(steps, **kw):
|
||||
"""steps: (time, "down" | "up" | "tick"). The gestures, with the time each came."""
|
||||
g, out = session.ButtonGestures(double_s=0.4, hold_s=1.5, bounce_s=0.05, **kw), []
|
||||
for t, what in steps:
|
||||
out += [(t, x) for x in getattr(g, what)(t)]
|
||||
return out
|
||||
# a press comes once the double-press time has passed
|
||||
self.assertEqual(run([(0, "down"), (0.1, "up"), (0.3, "tick"), (0.6, "tick")]), [(0.6, "press")])
|
||||
# two: a double, at the second down, and nothing at its release
|
||||
self.assertEqual(run([(0, "down"), (0.1, "up"), (0.4, "down"), (0.5, "up"), (2, "tick")]),
|
||||
[(0.4, "double")])
|
||||
# the second too late: two presses (the first reported when the second goes down)
|
||||
self.assertEqual(run([(0, "down"), (0.1, "up"), (0.6, "down"), (0.7, "up"), (1.2, "tick")]),
|
||||
[(0.6, "press"), (1.2, "press")])
|
||||
# a hold: while still down, and nothing at the release
|
||||
self.assertEqual(run([(0, "down"), (1.0, "tick"), (1.5, "tick"), (3, "up"), (4, "tick")]),
|
||||
[(1.5, "hold")])
|
||||
# a hold noticed only at the release (a slow read) still counts
|
||||
self.assertEqual(run([(0, "down"), (2, "up"), (3, "tick")]), [(2, "hold")])
|
||||
# a bounce (up and down 20 ms apart) is one press
|
||||
self.assertEqual(run([(0, "down"), (0.1, "up"), (0.12, "down"), (0.2, "up"), (1, "tick")]),
|
||||
[(1, "press")])
|
||||
# a press, then a hold soon after: a double (the hold counts from a fresh down)
|
||||
self.assertEqual(run([(0, "down"), (0.1, "up"), (0.3, "down"), (2, "tick"), (2.1, "up")]),
|
||||
[(0.3, "double")])
|
||||
# a release with no down, and two downs: left alone
|
||||
self.assertEqual(run([(0, "up"), (1, "down"), (1.1, "down"), (1.2, "up"), (2, "tick")]),
|
||||
[(2, "press")])
|
||||
|
||||
def test_reader_fifo(self):
|
||||
tmp = tempfile.mkdtemp(prefix="handrec-button-test-")
|
||||
try:
|
||||
fifo = os.path.join(tmp, "button")
|
||||
os.mkfifo(fifo)
|
||||
presses = []
|
||||
reader = session.ButtonReader(fifo, lambda: presses.append(time.monotonic()), debounce_s=0.3).start()
|
||||
got = []
|
||||
reader = session.ButtonReader(fifo, got.append, double_s=0.15, hold_s=0.5).start()
|
||||
with open(fifo, "wb", buffering=0) as w:
|
||||
w.write(PRESS + RELEASE)
|
||||
w.write(PRESS[:7]) # a press split across writes, a bounce
|
||||
time.sleep(0.05)
|
||||
w.write(PRESS + RELEASE) # a press
|
||||
time.sleep(0.3)
|
||||
w.write(PRESS[:7]) # two, the first split across writes
|
||||
time.sleep(0.02)
|
||||
w.write(PRESS[7:] + RELEASE)
|
||||
time.sleep(0.4)
|
||||
time.sleep(0.07)
|
||||
w.write(PRESS + RELEASE)
|
||||
time.sleep(0.3)
|
||||
w.write(taps(10, 10.1)) # two in one read: the kernel's times tell them apart
|
||||
time.sleep(0.3)
|
||||
w.write(PRESS) # held
|
||||
time.sleep(0.7)
|
||||
self.assertEqual(got, ["press", "double", "double", "hold"]) # the hold came before the release
|
||||
w.write(RELEASE)
|
||||
time.sleep(0.2)
|
||||
with open(fifo, "wb", buffering=0) as w: # the writer comes back: read again
|
||||
time.sleep(0.4)
|
||||
w.write(PRESS)
|
||||
w.write(PRESS + RELEASE)
|
||||
time.sleep(0.3)
|
||||
reader.stop()
|
||||
self.assertEqual(len(presses), 3)
|
||||
self.assertEqual(got, ["press", "double", "double", "hold", "press"])
|
||||
self.assertTrue(reader.ok)
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
@@ -533,7 +579,7 @@ class ButtonSessionTest(SessionBase):
|
||||
writer = os.open(fifo, os.O_RDWR) # kept open, so the reader never sees the end
|
||||
press = lambda: os.write(writer, PRESS + RELEASE)
|
||||
try:
|
||||
with mock.patch.object(session, "BUTTON_DEBOUNCE_S", 0.0):
|
||||
with mock.patch.object(session, "BUTTON_DOUBLE_S", 0.1):
|
||||
s = self.session(speed=4, button_device=fifo)
|
||||
s.input_devices = procfile
|
||||
s.start()
|
||||
@@ -563,6 +609,41 @@ class ButtonSessionTest(SessionBase):
|
||||
finally:
|
||||
os.close(writer)
|
||||
|
||||
def test_redo_and_stop(self):
|
||||
"""Two presses redo the step, a hold stops the session; the hints say so."""
|
||||
fifo = os.path.join(self.tmp, "button")
|
||||
os.mkfifo(fifo)
|
||||
writer = os.open(fifo, os.O_RDWR)
|
||||
press = lambda: os.write(writer, PRESS + RELEASE)
|
||||
try:
|
||||
with mock.patch.multiple(session, BUTTON_DOUBLE_S=0.15, BUTTON_HOLD_S=0.4):
|
||||
s = self.session(speed=2, button_device=fifo)
|
||||
s.start()
|
||||
self.waiting(s, "starting")
|
||||
press()
|
||||
self.waiting(s, "intro")
|
||||
press()
|
||||
self.waiting(s, "ready", "Fist.")
|
||||
press()
|
||||
self.wait_for(s, lambda st: st["state"] == "running", "the hold")
|
||||
os.write(writer, taps(1, 1.1)) # twice: redo
|
||||
self.waiting(s, "ready", "Fist.")
|
||||
time.sleep(0.1) # (sooner would be a bounce of the last)
|
||||
press()
|
||||
self.wait_for(s, lambda st: st["state"] == "running", "the hold again")
|
||||
press() # pause
|
||||
st = self.wait_for(s, lambda st: st["state"] == "paused", "paused")
|
||||
self.assertEqual(st["note"], session.RESUME_HINT_BUTTON)
|
||||
os.write(writer, PRESS) # held: stop
|
||||
s.join(20)
|
||||
os.write(writer, RELEASE)
|
||||
self.assertEqual(s.state, "stopped")
|
||||
names = [e["event"] for e in self.events(s)]
|
||||
self.assertEqual(names, ["take", "ready", "prompt", "redo", "wait", "ready", "prompt", "pause", "end"])
|
||||
self.assertIn("panel: keys " + session.KEYS_STEP_BUTTON, self.panel)
|
||||
finally:
|
||||
os.close(writer)
|
||||
|
||||
def test_no_button(self):
|
||||
s = self.session(next_after=0.0, button=False, button_device="/nonexistent")
|
||||
s.start()
|
||||
@@ -630,5 +711,61 @@ class ManyPartsTest(unittest.TestCase):
|
||||
self.assertEqual(r.summary["sets"], 120 + 30)
|
||||
|
||||
|
||||
class StandaloneTest(unittest.TestCase):
|
||||
"""The standalone Hand Recorder's tree (standalone.json at the top): ft-hands runs on the
|
||||
host, the version comes from the build info, and repairs point at its install command."""
|
||||
INFO = {"name": "frametop-hand-recorder", "version": "0.1.0", "frametop": "4ba49af",
|
||||
"reinstall": "run the install command again"}
|
||||
|
||||
def test_build_info(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "standalone.json")
|
||||
self.assertIsNone(takes.standalone(path))
|
||||
with open(path, "w") as f:
|
||||
json.dump(self.INFO, f)
|
||||
self.assertEqual(takes.standalone(path), self.INFO)
|
||||
with open(path, "w") as f:
|
||||
f.write("[1]")
|
||||
self.assertIsNone(takes.standalone(path))
|
||||
with mock.patch.object(takes, "standalone", return_value=self.INFO):
|
||||
self.assertEqual(takes.tool_version(), "ft-handrec 4ba49af (frametop-hand-recorder 0.1.0)")
|
||||
|
||||
def recorder_argv(self, standalone):
|
||||
with tempfile.TemporaryDirectory() as d, mock.patch.object(session, "STANDALONE", standalone), \
|
||||
mock.patch.object(session, "in_container", return_value=False), \
|
||||
mock.patch.object(session.subprocess, "Popen") as popen:
|
||||
session.Recorder(d, 1, 10, None, None)
|
||||
return popen.call_args[0][0]
|
||||
|
||||
def tracker_argv(self, standalone):
|
||||
fake = mock.Mock(ring=None)
|
||||
with mock.patch.object(session, "STANDALONE", standalone), \
|
||||
mock.patch.object(session.subprocess, "run") as run:
|
||||
session.Session._start_tracker(fake)
|
||||
return fake._start_unit.call_args[0][2], run
|
||||
|
||||
def test_ft_hands_on_the_host(self):
|
||||
self.assertEqual(self.recorder_argv(self.INFO)[0], session.FT_HANDS)
|
||||
argv, run = self.tracker_argv(self.INFO)
|
||||
self.assertEqual(argv[0], session.FT_HANDS)
|
||||
self.assertIn("--no-gestures", argv)
|
||||
run.assert_not_called() # no container to bring up
|
||||
|
||||
def test_ft_hands_in_the_dev_container(self):
|
||||
self.assertTrue(self.recorder_argv(None)[0].endswith("distrobox"))
|
||||
argv, _ = self.tracker_argv(None)
|
||||
self.assertEqual(argv[:4], [os.path.expanduser("~/.local/bin/distrobox"), "enter", "dev", "--"])
|
||||
self.assertEqual(argv[4], session.FT_HANDS)
|
||||
|
||||
def test_fix_hint(self):
|
||||
with mock.patch.object(session, "STANDALONE", self.INFO):
|
||||
self.assertEqual(session.fix_hint("hands/build.sh"), "run the install command again")
|
||||
text = session.camera_text({"status": "degraded", "reason": "2 of 4", "ring_missing": ["upper_left"]})
|
||||
with mock.patch.object(session, "STANDALONE", None):
|
||||
self.assertEqual(session.fix_hint("hands/build.sh"), "hands/build.sh")
|
||||
self.assertIn("run the install command again", text)
|
||||
self.assertNotIn("~/frametop", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -91,6 +91,29 @@ class RulesTest(unittest.TestCase):
|
||||
finally:
|
||||
shutil.rmtree(d)
|
||||
|
||||
def test_read_live_forced(self):
|
||||
"""HANDS_SWAP_SIDES=0 forced and the hands disagree: the hands are the truth, whether
|
||||
ft-hands published them (built after 2026-10-06) or kept the forced value (before)."""
|
||||
d = tempfile.mkdtemp()
|
||||
try:
|
||||
path = os.path.join(d, "sides.json")
|
||||
|
||||
def live(**kw):
|
||||
s = dict(pid=1, ring_ino=0, mode="0", names_swapped=False,
|
||||
updated_ns=time.clock_gettime_ns(time.CLOCK_MONOTONIC), **kw)
|
||||
with open(path, "w") as f:
|
||||
json.dump(s, f)
|
||||
return sides.read_live(path)
|
||||
|
||||
self.assertEqual(live(state="forced", swapped=False, decided_by="config")["swapped"], False)
|
||||
self.assertEqual(live(state="forced, agrees", swapped=False, decided_by="config")["swapped"], False)
|
||||
old = live(state="forced, disagrees", swapped=False, decided_by="config")
|
||||
self.assertEqual((old["swapped"], old["decided_by"]), (True, "auto"))
|
||||
new = live(state="forced, disagrees", swapped=True, decided_by="auto")
|
||||
self.assertEqual((new["swapped"], new["decided_by"]), (True, "auto"))
|
||||
finally:
|
||||
shutil.rmtree(d)
|
||||
|
||||
|
||||
class TakesTest(unittest.TestCase):
|
||||
"""A swapped session: one take's parts recorded before the decision (ft-camd's names) and
|
||||
@@ -226,6 +249,22 @@ class SessionSidesTest(unittest.TestCase):
|
||||
self.assertTrue(s._session_json["sides"]["reversed_from"]["swapped"])
|
||||
self.assertFalse(takes.read_json(os.path.join(self.tmp, "session.json"))["sides"]["swapped"])
|
||||
|
||||
def test_read_sides_forced(self):
|
||||
"""PR #4 on the dataset: HANDS_SWAP_SIDES=0 from the old example config, and the hands
|
||||
disagree. The session takes the hands' answer, not the forced one."""
|
||||
s = session.Session(os.path.join(self.tmp, "base"), {}, {}, "room", self.script, dry_run=True,
|
||||
hands_dir=self.tmp)
|
||||
s.session_dir = self.tmp
|
||||
s._session_json = {"sides": {"swapped": None}}
|
||||
self.live(mode="0", state="forced", swapped=False, decided_by="config", names_swapped=False, evidence=None)
|
||||
s._read_sides(force=True)
|
||||
self.assertIs(s._sides_swapped(), False)
|
||||
self.live(mode="0", state="forced, disagrees", swapped=False, decided_by="config", names_swapped=False)
|
||||
s._read_sides(force=True)
|
||||
self.assertIs(s._sides_swapped(), True)
|
||||
self.assertEqual(s._session_json["sides"]["decided_by"], "auto")
|
||||
self.assertEqual(s._session_json["sides"]["reversed_from"]["decided_by"], "config")
|
||||
|
||||
def test_recorder_parts(self):
|
||||
calls = []
|
||||
|
||||
|
||||
+30
-6
@@ -4,10 +4,11 @@
|
||||
# ft-handsctl on|off (on the Frame) or hands/run.sh start|stop.
|
||||
# Usage: hands/run.sh install|uninstall
|
||||
# hands/run.sh caps # give ft-camd its capabilities again (a rebuild clears them)
|
||||
# hands/run.sh uncaps # take them back, unless the Hand Recorder or the services use them
|
||||
# hands/run.sh start|stop|restart|status|log [lines]
|
||||
# install and caps need the password (sudo setcap, once per build of ft-camd): it's asked in
|
||||
# the terminal, on the Frame or from a PC (frame_sudo in scripts/_env.sh, which also takes it
|
||||
# from the repo's .env).
|
||||
# install and caps need the password (sudo setcap, once per build of ft-camd), and so do
|
||||
# uninstall and uncaps when they take the capabilities back. It's asked in the terminal, on the
|
||||
# Frame or from a PC (frame_sudo in scripts/_env.sh, which also takes it from the repo's .env).
|
||||
set -euo pipefail
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
. "$root/scripts/_env.sh"
|
||||
@@ -16,6 +17,10 @@ units="frametop-camd.service frametop-hands.service"
|
||||
# pidfd_getfd on XRService (ptrace_scope=1), system-wide tracepoints, and their root-only
|
||||
# format files. ft-camd drops them all once it has set up.
|
||||
caps=cap_sys_ptrace,cap_perfmon,cap_dac_read_search+ep
|
||||
# Installed, two things use ft-camd's capabilities: the Hand Recorder (hands/rec/install.sh: its
|
||||
# menu entry) and the services. ft-cutouts uses them too, but installs nothing.
|
||||
handrec_entry='~/.local/share/applications/frametop-handrec.desktop'
|
||||
camd_unit='~/.config/systemd/user/frametop-camd.service'
|
||||
|
||||
sudo_run() { frame_sudo "$1"; }
|
||||
|
||||
@@ -29,6 +34,22 @@ set_caps() { # only when missing: a rebuild clears them, a reinstall doesn't
|
||||
sudo_run "setcap $caps $bin && getcap $bin"
|
||||
}
|
||||
|
||||
# A left-over binary shouldn't keep its read-any-file powers, but while the Hand Recorder or
|
||||
# the services are installed, they still need them. Best effort: without the password it warns.
|
||||
drop_caps() {
|
||||
local bin who
|
||||
bin=$(printf %q "$FRAME_REPO/hands/build/ft-camd")
|
||||
who=$(on_frame "if ! { [ -x $bin ] && getcap $bin | grep -q cap_sys_ptrace; }; then echo none
|
||||
elif [ -e $handrec_entry ]; then echo recorder
|
||||
elif [ -e $camd_unit ]; then echo services
|
||||
else echo nobody; fi") || who=none
|
||||
case $who in
|
||||
recorder) echo "kept ft-camd's capabilities: the Hand Recorder uses them" ;;
|
||||
services) echo "kept ft-camd's capabilities: hand tracking's services use them (hands/run.sh uninstall)" ;;
|
||||
nobody) sudo_run "setcap -r $bin" || echo "warning: couldn't drop ft-camd's capabilities (no password?)" >&2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
states="for u in $units; do echo \"\$u: \$(systemctl --user is-active \$u)\"; done"
|
||||
|
||||
case ${1:-status} in
|
||||
@@ -43,11 +64,14 @@ case ${1:-status} in
|
||||
mkdir -p ~/.local/bin && ln -sfn $(printf %q "$FRAME_REPO/hands/ft-handsctl") ~/.local/bin/ft-handsctl
|
||||
$states; echo 'start it with: ft-handsctl on'" ;;
|
||||
caps) set_caps ;;
|
||||
uninstall) "$frame" --host "systemctl --user disable --now $units 2>/dev/null
|
||||
uncaps) drop_caps ;;
|
||||
uninstall)
|
||||
"$frame" --host "systemctl --user disable --now $units 2>/dev/null
|
||||
for u in $units; do rm -f ~/.config/systemd/user/\$u; done; systemctl --user daemon-reload
|
||||
[ -L ~/.local/bin/ft-handsctl ] && rm -f ~/.local/bin/ft-handsctl; echo removed" ;;
|
||||
[ -L ~/.local/bin/ft-handsctl ] && rm -f ~/.local/bin/ft-handsctl; echo removed"
|
||||
drop_caps ;; # once the units are gone, so they don't count as a user
|
||||
start|stop|restart) "$frame" --host "systemctl --user $1 $units; $states" ;;
|
||||
status) "$frame" --host "$states; journalctl --user -u frametop-hands.service --no-pager -o cat -n 4" || true ;;
|
||||
log) "$frame" --host "journalctl --user -u frametop-camd.service -u frametop-hands.service --no-pager -o short -n ${2:-30}" ;;
|
||||
*) echo "usage: $0 install|uninstall|caps|start|stop|restart|status|log [lines]" >&2; exit 2 ;;
|
||||
*) echo "usage: $0 install|uninstall|caps|uncaps|start|stop|restart|status|log [lines]" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -66,6 +66,22 @@ 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):
|
||||
@@ -128,6 +144,41 @@ 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_camera_map_by_found_camera(self):
|
||||
"""XRService's own naming (2026-10-05's log): index 0 is slam_right, by its subdev."""
|
||||
found = [L("10:00:00", "DeckardCaptureSource: Found camera '%s': interface=msm_csiphy%d v4l_subdev=/dev/v4l-subdev%d "
|
||||
"driver=/sys/bus/i2c/drivers/%s" % f) for f in (
|
||||
("slam_left", 0, 30, "og01a1b/4-0060"), ("slam_right", 1, 31, "og01a1b/4-0036"),
|
||||
("upper_left", 0, 32, "og0ve10/5-0060"), ("upper_right", 1, 33, "og0ve10/5-003e"))]
|
||||
inits = [L("10:00:03", "TrackingCameraInit: index: %d. video device: /dev/video%d. v4l subdevice: /dev/v4l-subdev%d"
|
||||
% f) for f in ((0, 9, 31), (1, 13, 30), (2, 6, 32), (3, 7, 33))]
|
||||
st = state(START + found + GOOD_OPEN[:-4] + inits)
|
||||
self.assertEqual(st.camera_map(), {"slam_right": 9, "slam_left": 13, "upper_left": 6, "upper_right": 7})
|
||||
# without the colour module the sides are on video0 and video3, named the same way
|
||||
unplug = [L("10:30:00", "TrackingCameraInit: index: %d. video device: /dev/video%d. v4l subdevice: /dev/v4l-subdev%d"
|
||||
% f) for f in ((0, 0, 31), (1, 3, 30))]
|
||||
st = state(START + found + GOOD_OPEN[:-4] + inits + CLOSE + unplug)
|
||||
self.assertEqual(st.camera_map(), {"slam_right": 0, "slam_left": 3})
|
||||
# a new instance forgets the old one's names: back to the index order
|
||||
self.assertEqual(state(START + found + START + GOOD_OPEN).camera_map()["slam_left"], 9)
|
||||
|
||||
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")
|
||||
@@ -223,9 +274,9 @@ class RealLog(unittest.TestCase):
|
||||
self.assertEqual(out.getvalue().split("\n")[0], "ok")
|
||||
|
||||
|
||||
def make_ring(path, mono_names, alive=True):
|
||||
def make_ring(path, mono_names, alive=True, nodes=None):
|
||||
"""A ring header as ft-camd writes it (camd/fhring.h), no frames."""
|
||||
cams = [(b"og01a1b", n.encode(), 9 + i) for i, n in enumerate(mono_names)]
|
||||
cams = [(b"og01a1b", n.encode(), nodes[i] if nodes else 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)
|
||||
@@ -252,6 +303,19 @@ 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)
|
||||
|
||||
@@ -14,9 +14,9 @@ line: {"pair", "verdict": "named"|"swapped"|"unknown", "named", "swapped", "matc
|
||||
ft-hands decides the side cameras' naming by itself (HANDS_SWAP_SIDES=auto, track/sides.h);
|
||||
this is the independent check, from the scene rather than hands.
|
||||
|
||||
ft-camd tells the two side cameras' buffers apart by the order XRService allocated them,
|
||||
and after some XRService restarts that order puts each camera's images under the other's
|
||||
name. The tracker then sees every hand in one camera only, at the wrong depth. This
|
||||
Before 2026-10-05 the side cameras' images often carried each other's names (hands/README.md,
|
||||
"Which camera is which"); recordings from then may still. The tracker then sees every hand in
|
||||
one camera only, at the wrong depth. This
|
||||
matches features between the two images and measures how close each pair's rays pass
|
||||
with the factory calibration, once as named and once swapped: true matches meet in
|
||||
front of both cameras only under the right naming.
|
||||
@@ -34,8 +34,28 @@ 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', # as ft-hands maps them
|
||||
PIPES = {'msm_vfe3_video0': 'slam_right', 'msm_vfe4_video0': 'slam_left', # with the colour module
|
||||
'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')}
|
||||
|
||||
|
||||
@@ -100,8 +120,9 @@ 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 = PIPES.get(open('/sys/class/video4linux/video%d/name' % c.node).read().strip())
|
||||
name = names_of.get(c.node)
|
||||
if name and not c.name.endswith('-dark'):
|
||||
cams[name] = c
|
||||
for k in range(count):
|
||||
|
||||
+107
-21
@@ -22,12 +22,14 @@
|
||||
// own clock, so they're placed on the mono cameras' by when they were dequeued, less the
|
||||
// mono cameras' measured delay.
|
||||
//
|
||||
// Which side camera is which (--sides, HANDS_SWAP_SIDES): ft-camd tells slam_left's buffers from
|
||||
// slam_right's by XRService's allocation order, which some XRService starts reverse. auto (the
|
||||
// default) tells from the hands it tracks (track/sides.h): once it's sure, it exchanges the two
|
||||
// Which side camera is which (--sides, HANDS_SWAP_SIDES): the names come from XRService's log
|
||||
// (cameras_from_xrservice_log) and ft-camd matches each camera's buffers exactly (VIDIOC_QUERYBUF),
|
||||
// so they should be right; before 2026-10-05 they were often swapped. auto (the default) still
|
||||
// tells from the hands it tracks (track/sides.h): once it's sure, it exchanges the two
|
||||
// cameras if they're backwards (the tracked views move with their images), and checks once
|
||||
// more. 0 and 1 force the naming (1: exchanged; --swap-sides is --sides 1); it still checks,
|
||||
// and warns if the hands disagree. The decision is published in /run/user/UID/frametop-hands/sides.json (see
|
||||
// and if the hands disagree it warns and publishes what the hands say as the truth ("swapped"),
|
||||
// so recordings are labelled right while tracking keeps the forced names. The decision is published in /run/user/UID/frametop-hands/sides.json (see
|
||||
// write_sides below) and, for recordings, in DIR/sides.json. --record-only can't tell (it tracks
|
||||
// nothing): under auto it records the ring's names as they are.
|
||||
//
|
||||
@@ -35,7 +37,8 @@
|
||||
// options override both): HANDS_SWAP_SIDES (auto, 0 or 1), HANDS_CPUS (as --cpus),
|
||||
// HANDS_CAMERAS, HANDS_BRIGHT, HANDS_BRIGHT_ON, HANDS_BRIGHT_OFF, HANDS_COLOR_LEFT (which
|
||||
// colour camera is passthrough_left: color_video0 or color_video3), HANDS_COLOR_CROP
|
||||
// (subtract or none: tools/check_color.py tells both).
|
||||
// (subtract or none: tools/check_color.py tells both), HANDS_MISREAD_GUARD (0 or 1: the
|
||||
// tracker's guards for fine-tuned landmark models, Tracker::set_misread_guard).
|
||||
#include "io.h"
|
||||
#include "pinch.h"
|
||||
#include "record.h"
|
||||
@@ -46,6 +49,7 @@
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <ctime>
|
||||
#include <memory>
|
||||
@@ -63,14 +67,63 @@ namespace {
|
||||
|
||||
volatile std::sig_atomic_t g_stop = 0, g_record = 0;
|
||||
|
||||
// which calibrated camera each capture pipe carries (XRService's fixed routing)
|
||||
// Which calibrated camera each video device carries. XRService names each tracking camera's
|
||||
// sensor subdev once per instance ("Found camera 'slam_left': interface=msm_csiphy0
|
||||
// v4l_subdev=/dev/v4l-subdev30 ...") and logs the device and subdev each index opened at every
|
||||
// camera start ("TrackingCameraInit: index: 0. video device: /dev/video9. v4l subdevice:
|
||||
// /dev/v4l-subdev31"). The index is only the order it opens them in: on every start logged since
|
||||
// 2026-10-04, index 0 was slam_right. So the subdev names the device; a log without "Found
|
||||
// camera" lines falls back to the index order (slam_left, slam_right, upper_left, upper_right).
|
||||
// The devices depend on the colour module: with it, the side cameras are on vfe3 (slam_right)
|
||||
// and vfe4 (slam_left) 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: ", found_key = "Found camera '";
|
||||
std::map<int, std::pair<int, std::string>> init; // index -> (N of /dev/videoN, subdev), latest start
|
||||
std::map<std::string, std::string> name_of; // subdev -> camera name
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
if (line.find("XRService logging to") != std::string::npos) init.clear(), name_of.clear();
|
||||
if (const auto at = line.find(found_key); at != std::string::npos) {
|
||||
const auto name_end = line.find('\'', at + found_key.size());
|
||||
const auto sub = line.find("v4l_subdev=", at);
|
||||
if (name_end != std::string::npos && sub != std::string::npos) {
|
||||
const auto sub_end = line.find_first_of(" \t\r", sub + 11);
|
||||
name_of[line.substr(sub + 11, sub_end == std::string::npos ? std::string::npos : sub_end - sub - 11)] =
|
||||
line.substr(at + found_key.size(), name_end - at - found_key.size());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const auto at = line.find(key);
|
||||
int index = -1, node = -1;
|
||||
char subdev[64] = "";
|
||||
if (at != std::string::npos &&
|
||||
std::sscanf(line.c_str() + at + key.size(), "%d. video device: /dev/video%d. v4l subdevice: %63s", &index,
|
||||
&node, subdev) >= 2 &&
|
||||
index >= 0 && index < 4)
|
||||
init[index] = {node, subdev};
|
||||
}
|
||||
std::map<int, std::string> out;
|
||||
for (auto &[index, ns] : init) {
|
||||
const auto it = name_of.find(ns.second);
|
||||
const bool known = it != name_of.end() && std::find(std::begin(names), std::end(names), it->second) != std::end(names);
|
||||
out[ns.first] = known ? it->second : names[index];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const char *camera_for_pipe(int node) {
|
||||
char path[64], name[64] = "";
|
||||
std::snprintf(path, sizeof path, "/sys/class/video4linux/video%d/name", node);
|
||||
std::ifstream f(path);
|
||||
f.getline(name, sizeof name);
|
||||
if (!std::strcmp(name, "msm_vfe3_video0")) return "slam_left";
|
||||
if (!std::strcmp(name, "msm_vfe4_video0")) return "slam_right";
|
||||
if (!std::strcmp(name, "msm_vfe3_video0")) return "slam_right";
|
||||
if (!std::strcmp(name, "msm_vfe4_video0")) return "slam_left";
|
||||
if (!std::strcmp(name, "msm_vfe2_video0")) return "upper_left";
|
||||
if (!std::strcmp(name, "msm_vfe2_video1")) return "upper_right";
|
||||
return nullptr;
|
||||
@@ -204,6 +257,7 @@ int main(int argc, char **argv) {
|
||||
// dim recording), but makes the landmarks jitter, so they get plain crops.
|
||||
Contrast palm_contrast, hand_contrast{Contrast::None};
|
||||
double keep_presence = 0.5; // landmark presence a tracked view needs to stay
|
||||
bool misread_guard = setting("HANDS_MISREAD_GUARD") == "1";
|
||||
PinchParams pinch_params;
|
||||
GripParams grip_params;
|
||||
bool gesture_log = false; // what the pinch and grip detectors measure, 10 times a second
|
||||
@@ -234,6 +288,7 @@ int main(int argc, char **argv) {
|
||||
else if (a == "--record-for" && more) record_for = std::atof(argv[++i]);
|
||||
else if (a == "--record-hz" && more) record_hz = std::max(0.0, std::atof(argv[++i]));
|
||||
else if (a == "--keep-presence" && more) keep_presence = std::atof(argv[++i]);
|
||||
else if (a == "--misread-guard" && more) misread_guard = std::string(argv[++i]) == "1";
|
||||
else if (a == "--cams" && more) cams_arg = argv[++i];
|
||||
else if (a == "--bright" && more) bright_arg = argv[++i];
|
||||
else if (a == "--bright-on" && more) light.on = std::atof(argv[++i]);
|
||||
@@ -254,6 +309,7 @@ int main(int argc, char **argv) {
|
||||
" [--sides auto|0|1] (auto: tell from the hands which side camera is which; 1: exchange them,\n"
|
||||
" as --swap-sides; 0: as ft-camd names them)\n"
|
||||
" [--keep-presence P] (0.5) [--ring PATH] (ft-camd's, or ft-ringplay's)\n"
|
||||
" [--misread-guard 0|1] (0; 1 for fine-tuned landmark models: see Tracker::set_misread_guard)\n"
|
||||
" [--cams auto|mono|color|all] (auto) [--bright all|color] (all) [--bright-on L] (40) [--bright-off L] (25)\n"
|
||||
" [--color-left color_video0|color_video3] [--color-crop subtract|none]\n"
|
||||
" [--pinch-begin M] (0.020) [--pinch-end M] (0.035) [--pinch-triangulated] [--pinch-palm-down MAX] (1: off)\n"
|
||||
@@ -266,7 +322,8 @@ int main(int argc, char **argv) {
|
||||
"camera's newest dark frame, as <name>_dk; with --with-color, the color cameras' as color_video<N>.\n"
|
||||
"auto picks the cameras by the light (see the top of track/main.cpp).\n"
|
||||
"Settings in ~/.config/frametop.conf: HANDS_SWAP_SIDES=auto|0|1, HANDS_CPUS=5,6,7, HANDS_CAMERAS, HANDS_BRIGHT,\n"
|
||||
"HANDS_BRIGHT_ON, HANDS_BRIGHT_OFF, HANDS_COLOR_LEFT, HANDS_COLOR_CROP (FT_<name> overrides).\n",
|
||||
"HANDS_BRIGHT_ON, HANDS_BRIGHT_OFF, HANDS_COLOR_LEFT, HANDS_COLOR_CROP, HANDS_MISREAD_GUARD=0|1\n"
|
||||
"(FT_<name> overrides).\n",
|
||||
argv[0]);
|
||||
return a == "--help" ? 0 : 1;
|
||||
}
|
||||
@@ -302,6 +359,7 @@ 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).
|
||||
@@ -312,7 +370,8 @@ 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) + "}\n");
|
||||
(decision_evidence.empty() ? "" : ", \"evidence\": " + decision_evidence) +
|
||||
(cams_json.empty() ? "" : ", \"cameras\": " + cams_json) + "}\n");
|
||||
};
|
||||
auto start_recording = [&](const std::string &dir, std::string &e) {
|
||||
rec = std::make_unique<Recorder>();
|
||||
@@ -340,19 +399,42 @@ 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) {
|
||||
if (ring.camera(i).flags & FH_CAM_COLOR) {
|
||||
const std::string name = "color_video" + std::to_string(ring.camera(i).node);
|
||||
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);
|
||||
dark[name] = i, color[name] = i;
|
||||
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];
|
||||
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;
|
||||
}
|
||||
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");
|
||||
@@ -397,7 +479,7 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
const bool switching = automatic && !color.empty();
|
||||
Cams mode = switching || color.empty() ? Cams::Mono : fixed;
|
||||
std::printf("cameras:");
|
||||
std::printf("cameras (by %s):", named_by);
|
||||
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),
|
||||
@@ -413,6 +495,7 @@ int main(int argc, char **argv) {
|
||||
Pool pool(threads, cpus);
|
||||
Tracker tracker(used, nets, pool);
|
||||
tracker.set_keep_presence(keep_presence);
|
||||
tracker.set_misread_guard(misread_guard);
|
||||
std::map<std::string, std::vector<uint8_t>> pixels;
|
||||
std::map<std::string, uint64_t> lit_seen; // per colour camera: the frame last counted for the light
|
||||
const uint64_t start = mono_ns();
|
||||
@@ -446,6 +529,7 @@ 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();
|
||||
@@ -454,14 +538,16 @@ int main(int argc, char **argv) {
|
||||
const bool backwards = v == SideCheck::Swapped; // relative to the names as they are now
|
||||
const double after = (now - start) / 1e9;
|
||||
const std::string ev = side_check.json(), text = side_check.summary();
|
||||
if (sides_mode != "auto") { // forced: only say so
|
||||
if (sides_mode != "auto") { // forced: the names stay; if the hands disagree, they're the truth
|
||||
const std::string what = (sides_from == "option" ? "--sides " : "HANDS_SWAP_SIDES=") + sides_mode;
|
||||
if (backwards)
|
||||
std::printf("side cameras: %s looks WRONG: the hands say the side cameras are the other way round (%s). "
|
||||
"Use auto.\n", what.c_str(), text.c_str());
|
||||
"Tracking keeps the forced names; recordings are labelled by the hands. Use auto.\n",
|
||||
what.c_str(), text.c_str());
|
||||
else
|
||||
std::printf("side cameras: %s agrees with the hands (%s)\n", what.c_str(), text.c_str());
|
||||
sides_state = backwards ? "forced, disagrees" : "forced, agrees";
|
||||
if (backwards) truth = !names_swapped, decided_by = "auto", decided_after_s = after;
|
||||
decision_evidence = ev;
|
||||
checking = false;
|
||||
} else if (side_round == 0 || backwards) {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// --timeline: per processed set, a line per hand (time, id, side, views, wrist) and per view
|
||||
// (hand, camera, presence, next crop, set index).
|
||||
// --keep-presence P: landmark presence a tracked view needs to stay (default 0.5, as new ones).
|
||||
// --misread-guard: the tracker's guards for fine-tuned landmark models (Tracker::set_misread_guard).
|
||||
// --pinch-begin M, --pinch-end M, --pinch-triangulated, --pinch-palm-down MAX: the pinch detector (track/pinch.h);
|
||||
// the timeline gets its begin/end/lost events and both distance measures per set.
|
||||
// --grip-begin R, --grip-end R: the grip detector (a closed hand; track/pinch.h); the timeline
|
||||
@@ -86,6 +87,7 @@ int main(int argc, char **argv) {
|
||||
bool cost = false;
|
||||
Contrast palm_contrast, hand_contrast{Contrast::None}; // as ft-hands's
|
||||
double keep_presence = 0.5; // landmark presence a tracked view needs to stay
|
||||
bool misread_guard = false;
|
||||
PinchParams pinch_params;
|
||||
GripParams grip_params;
|
||||
std::string use = "mono", color_left = "color_video0", color_crop = "subtract", sides = "file";
|
||||
@@ -102,6 +104,7 @@ int main(int argc, char **argv) {
|
||||
else if (a == "--models" && more) models = argv[++i];
|
||||
else if (a == "--cost") cost = true;
|
||||
else if (a == "--keep-presence" && more) keep_presence = std::atof(argv[++i]);
|
||||
else if (a == "--misread-guard") misread_guard = true;
|
||||
else if (a == "--cams" && more) use = argv[++i];
|
||||
else if (a == "--sides" && more) sides = argv[++i];
|
||||
else if (a == "--pinch-begin" && more) pinch_params.begin_m = std::atof(argv[++i]);
|
||||
@@ -183,6 +186,7 @@ int main(int argc, char **argv) {
|
||||
return n == "slam_left" ? "slam_right" : n == "slam_right" ? "slam_left" : n;
|
||||
};
|
||||
tracker.set_keep_presence(keep_presence);
|
||||
tracker.set_misread_guard(misread_guard);
|
||||
FILE *dp = depth.empty() ? nullptr : std::fopen(depth.c_str(), "w");
|
||||
FILE *pp = poses.empty() ? nullptr : std::fopen(poses.c_str(), "w");
|
||||
if (dp)
|
||||
|
||||
+24
-4
@@ -223,8 +223,16 @@ bool Tracker::hand_3d(Hand &hand, std::vector<View *> views, int64_t t_ns) {
|
||||
if (residual > 0.03 || size_misfit({views.begin(), views.end()}, pts) < 0) {
|
||||
View *best = *std::max_element(views.begin(), views.end(),
|
||||
[](View *a, View *b) { return a->lm.presence < b->lm.presence; });
|
||||
for (View *v : views)
|
||||
if (v != best) v->hand = -1;
|
||||
// a view that disagrees but sits where this hand already is in its camera is this hand
|
||||
// misread: drop it (-2), and the hand-over gives a fresh crop next frame
|
||||
for (View *v : views) {
|
||||
if (v == best) continue;
|
||||
v->hand = -1;
|
||||
if (!misread_guard_ || !hand.has_pts) continue;
|
||||
double z;
|
||||
const V2 at = v->cam->project(hand.pts[9], &z);
|
||||
if (z > 0 && norm(at - palm_centre(v->lm)) < 0.5 * hand_size(v->lm)) v->hand = -2;
|
||||
}
|
||||
++stats.splits;
|
||||
return hand_3d(hand, {best}, t_ns);
|
||||
}
|
||||
@@ -416,7 +424,14 @@ std::vector<const Hand *> Tracker::step(const std::map<std::string, Image> &imag
|
||||
if (int(chosen.size()) > hand_budget_) chosen.resize(hand_budget_);
|
||||
run_landmarks(images, chosen);
|
||||
std::vector<View *> kept;
|
||||
for (View *v : chosen)
|
||||
for (View *v : chosen) {
|
||||
if (misread_guard_ && v->has_lm) { // see set_misread_guard
|
||||
const auto h = hands_.find(v->hand);
|
||||
if (h != hands_.end() && h->second.frames >= 5 && std::fabs(v->lm.right - h->second.right_score) > 0.7) {
|
||||
++stats.lost;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (v->lm.presence >= (v->frames > 0 ? keep_presence_ : min_presence_)) {
|
||||
v->roi = v->lm.next_roi();
|
||||
++v->frames;
|
||||
@@ -424,6 +439,7 @@ std::vector<const Hand *> Tracker::step(const std::map<std::string, Image> &imag
|
||||
} else {
|
||||
++(v->frames > 0 ? stats.lost : stats.handoff_miss);
|
||||
}
|
||||
}
|
||||
// the same hand twice in one camera: keep the more confident
|
||||
std::sort(kept.begin(), kept.end(), [](View *a, View *b) { return a->lm.presence > b->lm.presence; });
|
||||
std::vector<View> next;
|
||||
@@ -537,7 +553,11 @@ std::vector<const Hand *> Tracker::step(const std::map<std::string, Image> &imag
|
||||
++it;
|
||||
}
|
||||
}
|
||||
// views split off by a failed triangulation start over as new hands next frame
|
||||
// views split off by a failed triangulation start over as new hands next frame, unless they
|
||||
// were this hand misread (-2, the misread guard: dropped)
|
||||
const size_t before = views_.size();
|
||||
views_.erase(std::remove_if(views_.begin(), views_.end(), [](const View &v) { return v.hand == -2; }), views_.end());
|
||||
stats.lost += int(before - views_.size());
|
||||
for (View &v : views_)
|
||||
if (v.hand <= 0) {
|
||||
v.hand = next_id_++;
|
||||
|
||||
@@ -93,6 +93,13 @@ public:
|
||||
// bright rooms the camera exposes for the room, the hands come out dim, and presence
|
||||
// dips under 0.5 for a frame at a time.
|
||||
void set_keep_presence(double p) { keep_presence_ = p; }
|
||||
// Misread guards, for fine-tuned landmark models (frame-hands' students): they stay sure of a
|
||||
// hand when two hands touch and can read the held hand as the other side, which made a split /
|
||||
// hand-over / duplicate loop. On: a reading of an established hand (5+ frames) whose side is
|
||||
// more than 0.7 off the hand's own is dropped (the hand-over crops it afresh next frame), and a
|
||||
// view split off where its hand already is in that camera is dropped instead of starting a new
|
||||
// hand. Off by default: the stock model's side is noisier and the first guard costs it tracking.
|
||||
void set_misread_guard(bool on) { misread_guard_ = on; }
|
||||
// One view's 3D hand: each landmark along its ray, as far as how big the palm looks says
|
||||
// for a hand `scale` times the model's (Hand::scale). False if the palm is degenerate.
|
||||
static bool single_view(const Camera &cam, const Landmarks &lm, double scale, V3 out[21]);
|
||||
@@ -130,6 +137,7 @@ private:
|
||||
Pool &pool_;
|
||||
int max_views_, hand_budget_ = 4, search_budget_ = 3;
|
||||
double min_presence_ = 0.5, keep_presence_ = 0.5;
|
||||
bool misread_guard_ = false;
|
||||
std::vector<View> views_;
|
||||
std::map<int, Hand> hands_;
|
||||
std::vector<Tile> tiles_;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
rem Frametop host setup: makes this PC a host for Frametop's remote displays (frametop-host-setup.ps1).
|
||||
rem It asks Windows for admin.
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0frametop-host-setup.ps1" %*
|
||||
@@ -0,0 +1,325 @@
|
||||
# Frametop host setup for Windows: makes this PC a host for Frametop's remote displays, its
|
||||
# monitors shown as screens on a Steam Frame (docs/remote-displays.md). Run
|
||||
# "Setup Frametop host.cmd"; it asks Windows for admin.
|
||||
#
|
||||
# 1. Vibepollo 2.0.0 (github.com/Nonary/Vibepollo), installed with its own installer if it
|
||||
# isn't here (checked against its SHA-256 first).
|
||||
# 2. Frametop's build of Vibepollo's sunshine.exe (github.com/Frametop/frametop-vibepollo):
|
||||
# it can stream any of your monitors (not only the main one), keeps your monitor layout
|
||||
# when a virtual display goes away, and doesn't stall the Web UI. Checked against its
|
||||
# SHA-256; the original is kept as sunshine.exe.2.0.0-original.
|
||||
# 3. Settings Frametop needs (the rest of Vibepollo's settings stay as they are): remote
|
||||
# displays carry the PC's sound, and a virtual display goes away when Frametop
|
||||
# disconnects it, but stays through a dropped stream.
|
||||
# 4. The Web UI login you sign in with once from the Steam Frame: keep the one there is, or
|
||||
# set one. The password is typed here and never shown or saved.
|
||||
# 5. Checks: Vibepollo's firewall rule covers every network type (a Steam Link dongle's
|
||||
# network counts as Public), whether a Steam Link dongle is plugged in, and that the Web
|
||||
# UI answers.
|
||||
#
|
||||
# Options:
|
||||
# -FrametopBuild PATH|URL where Frametop's sunshine.exe comes from (default: BuildUrl
|
||||
# below, else sunshine-frametop.exe next to this script)
|
||||
# -SkipLogin leave the Web UI login alone
|
||||
# -Check only say what would change
|
||||
# -Undo put back the original sunshine.exe and the settings from before
|
||||
# A log goes to %ProgramData%\Frametop\host-setup.log, backups to %ProgramData%\Frametop\backup-*.
|
||||
param([string]$FrametopBuild = "", [switch]$SkipLogin, [switch]$Check, [switch]$Undo)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
$VibepolloVersion = "2.0.0"
|
||||
$SetupUrl = "https://github.com/Nonary/Vibepollo/releases/download/2.0.0/VibepolloSetup-v2.0.0.exe"
|
||||
$SetupSha = "7B3500EC0C774644CE5A435A48F61C046C48494D0F18B67AFA0B3561931794B7"
|
||||
$OriginalSha = "2CC018FD92DDB4D3748D91D8DA25316909ED45DB3710D1FF278BD73D51EB00C1" # its sunshine.exe
|
||||
# Frametop's build: Frametop/frametop-vibepollo branch frametop/2.0.0 at a84b6cfc (its CI).
|
||||
$BuildSha = "6AF4F34503C7F6F84D1F6967D9FCEFAF2BD8145B34044C992B5E26A614E37A91"
|
||||
$BuildUrl = "" # not published yet
|
||||
# Earlier Frametop builds, replaced by this one like the original is.
|
||||
$OlderBuildShas = @(
|
||||
"B5B7D2E7353454AEA6D895D0B68DE235E4CC581684C9DD44F8EFAF2E872F517F" # 2f032252, built by hand without WebRTC
|
||||
)
|
||||
$Settings = [ordered]@{
|
||||
"remote_monitor_mute_audio" = "disabled"
|
||||
"remote_monitor_disconnect_on_client_disconnect" = "enabled"
|
||||
"remote_monitor_disconnect_on_stream_end" = "disabled"
|
||||
}
|
||||
$Service = "ApolloService" # Vibepollo keeps Apollo's service name
|
||||
$Data = Join-Path $env:ProgramData "Frametop"
|
||||
$Here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path }
|
||||
|
||||
$me = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
if (-not $me.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
$pass = @()
|
||||
foreach ($k in $PSBoundParameters.Keys) {
|
||||
$v = $PSBoundParameters[$k]
|
||||
if ($v -is [switch]) { if ($v) { $pass += "-$k" } } else { $pass += "-$k `"$v`"" }
|
||||
}
|
||||
Start-Process powershell -Verb RunAs -ArgumentList ("-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`" " + ($pass -join " "))
|
||||
exit
|
||||
}
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
New-Item -ItemType Directory -Force $Data | Out-Null
|
||||
$Log = Join-Path $Data "host-setup.log"
|
||||
|
||||
function Say([string]$text) {
|
||||
Write-Host $text
|
||||
Add-Content -Path $Log -Value ((Get-Date -Format "yyyy-MM-dd HH:mm:ss") + " " + $text)
|
||||
}
|
||||
|
||||
function Sha([string]$path) { (Get-FileHash $path -Algorithm SHA256).Hash }
|
||||
|
||||
function Plain($secure) {
|
||||
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
|
||||
try { [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
|
||||
}
|
||||
|
||||
# One argument for a Windows program, quoted so it reads back exactly this string
|
||||
# (Windows PowerShell 5 mangles arguments that contain double quotes).
|
||||
function Quote([string]$s) {
|
||||
$r = '"'
|
||||
$slashes = 0
|
||||
foreach ($c in $s.ToCharArray()) {
|
||||
if ($c -eq '\') { $slashes++; continue }
|
||||
if ($c -eq '"') { $r += ('\' * (2 * $slashes + 1)) + '"' } else { $r += ('\' * $slashes) + $c }
|
||||
$slashes = 0
|
||||
}
|
||||
$r + ('\' * (2 * $slashes)) + '"'
|
||||
}
|
||||
|
||||
function Find-Vibepollo {
|
||||
$keys = "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*", "HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"
|
||||
$entry = Get-ItemProperty $keys -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -eq "Vibepollo" -and $_.InstallLocation } | Select-Object -First 1
|
||||
$dir = if ($entry) { $entry.InstallLocation.TrimEnd('\') } else { "C:\Program Files\Apollo" }
|
||||
if ((Test-Path "$dir\sunshine.exe") -and (Get-Service $Service -ErrorAction SilentlyContinue)) { return $dir }
|
||||
return $null
|
||||
}
|
||||
|
||||
function Stop-Vibepollo {
|
||||
Stop-Service $Service
|
||||
for ($i = 0; $i -lt 30 -and (Get-Process sunshine -ErrorAction SilentlyContinue); $i++) { Start-Sleep -Milliseconds 500 }
|
||||
}
|
||||
|
||||
# sunshine.conf: "key = value" lines. Returns the lines with $Settings applied.
|
||||
function Set-Settings([string[]]$lines) {
|
||||
$out = New-Object System.Collections.Generic.List[string]
|
||||
$done = @{}
|
||||
foreach ($line in $lines) {
|
||||
$key = ($line -split "=", 2)[0].Trim()
|
||||
if ($Settings.Contains($key)) {
|
||||
if (-not $done[$key]) { $out.Add("$key = $($Settings[$key])"); $done[$key] = $true }
|
||||
} else {
|
||||
$out.Add($line)
|
||||
}
|
||||
}
|
||||
foreach ($key in $Settings.Keys) { if (-not $done[$key]) { $out.Add("$key = $($Settings[$key])") } }
|
||||
return , $out.ToArray()
|
||||
}
|
||||
|
||||
function Get-Build {
|
||||
$from = $FrametopBuild
|
||||
if (-not $from) { $from = if ($BuildUrl) { $BuildUrl } else { Join-Path $Here "sunshine-frametop.exe" } }
|
||||
if ($from -match "^https?://") {
|
||||
$file = Join-Path $env:TEMP "sunshine-frametop.exe"
|
||||
Say "Downloading Frametop's build of Vibepollo..."
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $from -OutFile $file
|
||||
} else {
|
||||
$file = $from
|
||||
}
|
||||
if (-not (Test-Path $file)) { return $null }
|
||||
if ((Sha $file) -ne $BuildSha) { throw "$file isn't Frametop's build of Vibepollo $VibepolloVersion (its SHA-256 doesn't match)." }
|
||||
return $file
|
||||
}
|
||||
|
||||
function Wait-WebUi {
|
||||
Add-Type @"
|
||||
using System.Net;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
public class FrametopTrustLocal : ICertificatePolicy {
|
||||
public bool CheckValidationResult(ServicePoint s, X509Certificate c, WebRequest r, int p) { return true; }
|
||||
}
|
||||
"@ -ErrorAction SilentlyContinue
|
||||
$old = [Net.ServicePointManager]::CertificatePolicy
|
||||
[Net.ServicePointManager]::CertificatePolicy = New-Object FrametopTrustLocal # 127.0.0.1 only
|
||||
try {
|
||||
for ($i = 0; $i -lt 40; $i++) {
|
||||
try { Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 "https://127.0.0.1:47990/" | Out-Null; return $true } catch { Start-Sleep 1 }
|
||||
}
|
||||
return $false
|
||||
} finally {
|
||||
[Net.ServicePointManager]::CertificatePolicy = $old
|
||||
}
|
||||
}
|
||||
|
||||
function Main {
|
||||
Say "== Frametop host setup$(if ($Check) { ' (check only)' })$(if ($Undo) { ' (undo)' })"
|
||||
$dir = Find-Vibepollo
|
||||
$exe = if ($dir) { "$dir\sunshine.exe" } else { $null }
|
||||
$orig = if ($dir) { "$dir\sunshine.exe.$VibepolloVersion-original" } else { $null }
|
||||
|
||||
if ($Undo) {
|
||||
if (-not $dir) { throw "Vibepollo isn't installed here." }
|
||||
$backup = Get-ChildItem $Data -Directory -Filter "backup-*" -ErrorAction SilentlyContinue | Sort-Object Name | Select-Object -First 1
|
||||
Stop-Vibepollo
|
||||
if (Test-Path $orig) { Copy-Item $orig $exe -Force; Say "Put back the original sunshine.exe." }
|
||||
if ($backup -and (Test-Path "$($backup.FullName)\sunshine.conf")) {
|
||||
Copy-Item "$($backup.FullName)\sunshine.conf" "$dir\config\sunshine.conf" -Force
|
||||
Say "Put back the settings from $($backup.FullName)."
|
||||
}
|
||||
Start-Service $Service
|
||||
Say "Done. (The Web UI login stays as it is.)"
|
||||
return
|
||||
}
|
||||
|
||||
# 1. Vibepollo
|
||||
if (-not $dir) {
|
||||
if ($Check) { Say "Would install Vibepollo $VibepolloVersion."; return }
|
||||
$setup = Join-Path $env:TEMP "VibepolloSetup-v$VibepolloVersion.exe"
|
||||
Say "Downloading Vibepollo $VibepolloVersion..."
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $SetupUrl -OutFile $setup
|
||||
if ((Sha $setup) -ne $SetupSha) { throw "The Vibepollo installer didn't match its SHA-256; not running it." }
|
||||
Say "Running Vibepollo's installer: follow its steps, then come back here."
|
||||
Start-Process $setup -Wait
|
||||
$dir = Find-Vibepollo
|
||||
if (-not $dir) { throw "Vibepollo doesn't seem to be installed. Run this again once it is." }
|
||||
$exe = "$dir\sunshine.exe"
|
||||
$orig = "$dir\sunshine.exe.$VibepolloVersion-original"
|
||||
}
|
||||
Say "Vibepollo: $dir"
|
||||
|
||||
# 2. Frametop's build of sunshine.exe
|
||||
$now = Sha $exe
|
||||
$swap = $null
|
||||
if ($now -eq $BuildSha) {
|
||||
Say "Frametop's build of Vibepollo is already installed."
|
||||
} elseif ($now -eq $OriginalSha) {
|
||||
$swap = Get-Build
|
||||
if (-not $swap) {
|
||||
Say "Frametop's build of Vibepollo isn't available here, so this PC can't stream its own monitors yet (virtual displays work)."
|
||||
}
|
||||
} elseif ($OlderBuildShas -contains $now) {
|
||||
$swap = Get-Build
|
||||
if (-not $swap) { Say "An older Frametop build of Vibepollo is installed, and the new one isn't available here; keeping it." }
|
||||
} else {
|
||||
$v = (Get-Item $exe).VersionInfo.ProductVersion
|
||||
throw "This is Vibepollo $v, and Frametop's build is for $VibepolloVersion. Install Vibepollo $VibepolloVersion ($SetupUrl), then run this again."
|
||||
}
|
||||
|
||||
# 3. Settings
|
||||
$conf = "$dir\config\sunshine.conf"
|
||||
$lines = if (Test-Path $conf) { [IO.File]::ReadAllLines($conf) } else { @() }
|
||||
$new = Set-Settings $lines
|
||||
$changed = ($new -join "`n") -ne ($lines -join "`n")
|
||||
|
||||
# 4. Login
|
||||
$state = "$dir\config\sunshine_state.json"
|
||||
$user = $null
|
||||
if (Test-Path $state) { try { $user = (Get-Content $state -Raw | ConvertFrom-Json).username } catch { } }
|
||||
$password = $null
|
||||
if (-not $SkipLogin -and -not $Check) {
|
||||
if ($user) {
|
||||
$answer = Read-Host "The Web UI login is '$user'. Keep it? You'll sign in with it once from the Steam Frame. (Y/n)"
|
||||
$setLogin = $answer -match "^[nN]"
|
||||
} else {
|
||||
Write-Host "Vibepollo has no Web UI login yet. Make one: you'll sign in with it once from the Steam Frame."
|
||||
$setLogin = $true
|
||||
}
|
||||
if ($setLogin) {
|
||||
$typed = Read-Host "User name$(if ($user) { " (Enter keeps '$user')" })"
|
||||
if ($typed) { $user = $typed }
|
||||
while (-not $user) { $user = Read-Host "User name" }
|
||||
while ($true) {
|
||||
$a = Plain (Read-Host "Password" -AsSecureString)
|
||||
$b = Plain (Read-Host "Same password again" -AsSecureString)
|
||||
if ($a.Length -ge 4 -and $a -ceq $b) { $password = $a; break }
|
||||
Write-Host "They don't match, or it's shorter than 4 characters. Try again."
|
||||
}
|
||||
Remove-Variable a, b
|
||||
}
|
||||
}
|
||||
|
||||
if ($Check) {
|
||||
Say "Would $(if ($swap) { 'install' } else { 'not change' }) sunshine.exe, $(if ($changed) { 'change' } else { 'not change' }) the settings."
|
||||
} elseif ($swap -or $changed -or $password) {
|
||||
$backup = Join-Path $Data ("backup-" + (Get-Date -Format "yyyyMMdd-HHmmss"))
|
||||
New-Item -ItemType Directory $backup | Out-Null
|
||||
foreach ($f in "$dir\config\sunshine.conf", "$dir\config\apps.json") { if (Test-Path $f) { Copy-Item $f $backup } }
|
||||
Say "Backed up the settings to $backup."
|
||||
Say "Stopping Vibepollo..."
|
||||
Stop-Vibepollo
|
||||
try {
|
||||
if ($swap) {
|
||||
if (-not (Test-Path $orig) -and $now -eq $OriginalSha) { Copy-Item $exe $orig }
|
||||
Copy-Item $swap $exe -Force
|
||||
Say "Installed Frametop's build of Vibepollo (the original is $orig)."
|
||||
}
|
||||
if ($changed) {
|
||||
[IO.File]::WriteAllLines($conf, [string[]]$new)
|
||||
Say "Set: $(($Settings.Keys | ForEach-Object { "$_ = $($Settings[$_])" }) -join ', ')."
|
||||
}
|
||||
if ($password) {
|
||||
$info = New-Object Diagnostics.ProcessStartInfo
|
||||
$info.FileName = $exe
|
||||
$info.WorkingDirectory = $dir
|
||||
$info.Arguments = "--creds " + (Quote $user) + " " + (Quote $password)
|
||||
$info.UseShellExecute = $false
|
||||
$info.RedirectStandardOutput = $true
|
||||
$info.RedirectStandardError = $true
|
||||
$proc = [Diagnostics.Process]::Start($info)
|
||||
$info.Arguments = ""
|
||||
$proc.StandardOutput.ReadToEnd() | Out-Null
|
||||
$proc.StandardError.ReadToEnd() | Out-Null
|
||||
$proc.WaitForExit()
|
||||
Remove-Variable password
|
||||
if ($proc.ExitCode -ne 0) { throw "Setting the Web UI login failed (sunshine.exe --creds: exit code $($proc.ExitCode))." }
|
||||
Say "Web UI login set: '$user'."
|
||||
}
|
||||
} finally {
|
||||
Start-Service $Service
|
||||
Say "Vibepollo is running again."
|
||||
}
|
||||
}
|
||||
|
||||
# 5. Checks
|
||||
$rules = @(Get-NetFirewallApplicationFilter -ErrorAction SilentlyContinue |
|
||||
Where-Object { [Environment]::ExpandEnvironmentVariables($_.Program) -ieq $exe } |
|
||||
Get-NetFirewallRule | Where-Object { $_.Enabled -eq "True" -and $_.Direction -eq "Inbound" -and $_.Action -eq "Allow" })
|
||||
$everywhere = $rules | Where-Object { $_.Profile -eq "Any" -or ($_.Profile -match "Private" -and $_.Profile -match "Public") }
|
||||
if ($everywhere) {
|
||||
Say "Firewall: Vibepollo is allowed on every network type."
|
||||
} elseif ($Check) {
|
||||
Say "Would add a firewall rule letting Vibepollo in on every network type."
|
||||
} else {
|
||||
New-NetFirewallRule -DisplayName "Vibepollo (Frametop)" -Program $exe -Direction Inbound -Action Allow -Profile Any | Out-Null
|
||||
Say "Firewall: added a rule letting Vibepollo in on every network type (a Steam Link dongle's network is Public)."
|
||||
}
|
||||
$dongle = Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceDescription -match "For Valve" } | Select-Object -First 1
|
||||
if ($dongle -and $dongle.Status -eq "Up") {
|
||||
$ip = (Get-NetIPAddress -InterfaceIndex $dongle.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | Select-Object -First 1).IPAddress
|
||||
Say "Steam Link dongle: connected$(if ($ip) { " ($ip)" }). Frametop can stream over it, past your router."
|
||||
} elseif ($dongle) {
|
||||
Say "Steam Link dongle: plugged in, not connected to the Steam Frame. Frametop uses your network until it is."
|
||||
} else {
|
||||
Say "Steam Link dongle: none. Frametop streams over your network."
|
||||
}
|
||||
if (-not $Check) {
|
||||
if (Wait-WebUi) { Say "The Web UI answers (https://localhost:47990)." } else { Say "The Web UI didn't answer yet; check that Vibepollo is running." }
|
||||
}
|
||||
|
||||
$addresses = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object {
|
||||
$_.IPAddress -notmatch "^(127\.|169\.254\.)" -and $_.InterfaceAlias -notmatch "Tailscale|vEthernet|Loopback" -and
|
||||
(-not $dongle -or $_.InterfaceIndex -ne $dongle.ifIndex) } | ForEach-Object { $_.IPAddress }
|
||||
Write-Host ""
|
||||
Say "Done. On the Steam Frame: open Frametop Remote Displays, Add computer, pick $([Net.Dns]::GetHostName()) ($($addresses -join ', ')), and sign in as '$user'."
|
||||
}
|
||||
|
||||
try {
|
||||
Main
|
||||
} catch {
|
||||
Say "Failed: $_"
|
||||
if (-not $Check -and (Get-Service $Service -ErrorAction SilentlyContinue) -and (Get-Service $Service).Status -ne "Running") {
|
||||
Start-Service $Service -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
if (-not $env:FRAMETOP_NO_PAUSE) { Read-Host "Press Enter to close" | Out-Null }
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Launch Frametop Input Settings from a Plasma session on the Frame host.
|
||||
# The app runs in the dev container (PySide6 and Kirigami come from Fedora there).
|
||||
# The app runs in Frametop's container (PySide6 and Kirigami come from Fedora there).
|
||||
# podman needs the real XDG_RUNTIME_DIR and the real user bus (to reach systemd for
|
||||
# the container's cgroup; the Frametop session runs on a private bus from
|
||||
# dbus-run-session). The session's Wayland socket and bus go to the app itself.
|
||||
@@ -10,8 +10,7 @@ case $wl in /*) ;; *) wl="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/$wl" ;; esac
|
||||
session_bus=${DBUS_SESSION_BUS_ADDRESS:-}
|
||||
export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
||||
export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus
|
||||
"$here/../scripts/container-up.sh"
|
||||
exec "$HOME/.local/bin/distrobox" enter dev -- env WAYLAND_DISPLAY="$wl" DISPLAY="${DISPLAY:-}" \
|
||||
exec "$here/../scripts/in-box" env WAYLAND_DISPLAY="$wl" DISPLAY="${DISPLAY:-}" \
|
||||
XAUTHORITY="${XAUTHORITY:-}" DBUS_SESSION_BUS_ADDRESS="$session_bus" \
|
||||
QT_QPA_PLATFORM="wayland;xcb" \
|
||||
python3 "$here/ft_input_settings.py" "$@"
|
||||
+73
-19
@@ -27,7 +27,7 @@ look; held, the pointer stops there and your head steers it (it stays put in you
|
||||
the release clicks; held still for half a second, it's a real press that your head drags
|
||||
("gazekey left|right 1|0" to the helper; by default Meta+J and Meta+K, DEFAULT_KEY_BINDINGS),
|
||||
gaze_quickcal = the gaze service's one-dot check ("quickcal" to @ft_gazed), sens_up, sens_down,
|
||||
layout_reset = put the desktop screens back in their saved layout, screens_toggle = hide or show the desktop screens,
|
||||
layout_reset = open the profile in use again, or put the desktop screens back in their layout (ft-layout reset), screens_toggle = hide or show the desktop screens,
|
||||
keyboard_toggle = open or close Frametop's keyboard, float_toggle = float the desktop window under the
|
||||
pointer (else the active one) in VR, or put it back if it floats, dock_all = put every floating
|
||||
window back (both to ft-floatd, @frametop_float), spin_next and spin_prev = turn every panel in the
|
||||
@@ -66,12 +66,18 @@ Typing on a keyboard sends the helper "typing" (at most 4 times a second): it ta
|
||||
pinches right after a key, since typing touches thumb to index like a pinch.
|
||||
|
||||
Keys also go to ft-screens (@ft_screens, the Frametop desktop's compositor), which
|
||||
types them into the desktop screen that has focus: from pass-through keyboards, and
|
||||
keys a pointer device passes through. Typing goes to the panel clicked last, and
|
||||
ft-screens says which ("keyboard desktop|steam" on the control socket, every second).
|
||||
While it's the desktop, pass-through keyboards are grabbed, so gamescope, which reads
|
||||
every keyboard itself, doesn't type them into its focused app too. Without word from
|
||||
ft-screens for 3 seconds they're released. With SHARE_KEYS=1 in ~/.config/frametop.conf,
|
||||
types them into the desktop screen that has focus: from pass-through keyboards, a USB or
|
||||
Bluetooth keyboard's media keys (its Consumer Control node, which has volume keys, so it's
|
||||
never grabbed and gamescope has them too), and keys a pointer device passes through. In
|
||||
pointer mode a mouse button passed through as a key (BTN_MOUSE..BTN_TASK, a side button for
|
||||
Back) goes there too, and ft-screens gives it the screen the pointer is on, not the one
|
||||
typing goes to (it releases the button itself when the pointer leaves the screens, they
|
||||
hide, or Frametop pauses). Other buttons and keys from KEY_OK up don't go there.
|
||||
Typing goes to the panel clicked last, and ft-screens says which ("keyboard
|
||||
desktop|steam" on the control socket, every second). While it's the desktop,
|
||||
pass-through keyboards are grabbed, so gamescope, which reads every keyboard itself,
|
||||
doesn't type them into its focused app too. Without word from ft-screens for 3 seconds
|
||||
they're released. With SHARE_KEYS=1 in ~/.config/frametop.conf,
|
||||
a grabbed keyboard's keys also go out as "key <code> <value> <device name>" datagrams on
|
||||
@frametop_keys, for programs that watch every keyboard for a hotkey and lose it to the grab.
|
||||
It's off by default: any local process that binds that name first gets every key typed
|
||||
@@ -85,7 +91,7 @@ default: only while no pass-through keyboard is connected; a program's uinput ke
|
||||
doesn't count), "button" (only the keyboard_toggle action opens it), or "never"
|
||||
(keyboard_toggle does nothing either). With "vr_keyboard_persist" (the default), it stays
|
||||
open when the text field loses focus, until its Close key, keyboard_toggle, or a layout reset
|
||||
(ft-layout apply) closes it.
|
||||
(ft-layout reset) closes it.
|
||||
|
||||
Volume keys, from every device that has them (the headset's own buttons included),
|
||||
are handled here: wpctl steps the default output. Nothing else may see a volume key,
|
||||
@@ -153,6 +159,7 @@ KEY_A = 30
|
||||
REL_X, REL_Y, REL_WHEEL, REL_MAX = 0x00, 0x01, 0x08, 0x0F
|
||||
SCROLLS = {0x06, REL_WHEEL, 0x0B, 0x0C} # REL_HWHEEL, REL_WHEEL and their _HI_RES
|
||||
BTN_LEFT, BTN_RIGHT, BTN_MIDDLE, BTN_SIDE, BTN_EXTRA = 0x110, 0x111, 0x112, 0x113, 0x114
|
||||
BTN_MOUSE, BTN_TASK = 0x110, 0x117 # mouse buttons, the first and the last
|
||||
KEY_LEFTMETA, KEY_RIGHTMETA = 125, 126
|
||||
KEY_MUTE, KEY_VOLUMEDOWN, KEY_VOLUMEUP = 113, 114, 115
|
||||
# Volume keys are remapped to KEY_MACRO28, KEY_MACRO29 and KEY_MACRO30: above 255, so X11
|
||||
@@ -498,6 +505,10 @@ class Pointer:
|
||||
self.pending = 0
|
||||
self.pending_since = 0.0
|
||||
self.gaze_awake_until = 0.0 # the helper's gaze mode keeps the pointer until then
|
||||
# Driver buttons this pointer pressed and hasn't released (trigger, b, x, joystick: the
|
||||
# left, right, middle and back actions, from a mouse button, a mapped controller button
|
||||
# or a key combination): pausing drops releases, so stand_down has to send them itself.
|
||||
self.driver_down = set()
|
||||
|
||||
def send(self, command, droppable=False):
|
||||
"""To the helper, in order, without blocking. While the helper doesn't keep up (place and
|
||||
@@ -607,6 +618,10 @@ class Pointer:
|
||||
self.wake(now)
|
||||
self.flush()
|
||||
self.send(f"btn {driver} {value}")
|
||||
if value == 1:
|
||||
self.driver_down.add(driver)
|
||||
else:
|
||||
self.driver_down.discard(driver)
|
||||
elif value != 1:
|
||||
return # the rest act on press
|
||||
elif name in ("scroll_up", "scroll_down"):
|
||||
@@ -643,7 +658,7 @@ class Pointer:
|
||||
pass # ft-screens not running
|
||||
elif name == "layout_reset":
|
||||
# Runs a few seconds and borrows the pointer; ft-layout refuses a second copy.
|
||||
subprocess.Popen([FT_LAYOUT, "apply"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
subprocess.Popen([FT_LAYOUT, "reset"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
log("layout reset")
|
||||
elif name in ("sens_up", "sens_down"):
|
||||
@@ -706,18 +721,39 @@ class Pointer:
|
||||
return wait
|
||||
|
||||
def stand_down(self):
|
||||
"""Frametop is pausing: a pulse under way ends now, and the pointer lets go."""
|
||||
"""Frametop is pausing: a pulse under way ends now, and the pointer lets go.
|
||||
|
||||
A click held into the pause (driver_down) comes up here, since pausing drops its
|
||||
release; otherwise the driver keeps the button down and the virtual controller
|
||||
reconnects with it pressed on resume. Gaze holds (gazekey, gazedrag, precision) aren't
|
||||
tracked here: the helper ends them itself when the pointer hides.
|
||||
|
||||
The releases go before "hide", and "hide" follows them even when the pointer was off
|
||||
already (the idle timeout or pointer_toggle with a button held): a helper built before
|
||||
releases stopped waking it would wake on one and connect the virtual controller
|
||||
during the game.
|
||||
|
||||
Not covered: gaze mode's held-back press (the helper's aim, before it becomes a real
|
||||
press) turns into a click on its release. The helper drops it without a click when it
|
||||
reads "hide" in the same loop as the release, as it normally does. If its socket was
|
||||
full, the rest of these wait in the queue (send), "hide" can land a loop later, and
|
||||
that press clicks once as the pause starts."""
|
||||
releases = [f"btn {driver} 0" for driver in sorted(self.driver_down)]
|
||||
self.driver_down.clear()
|
||||
if self.system_release is not None:
|
||||
self.send("btn system 0")
|
||||
releases.append("btn system 0")
|
||||
if self.claim_release is not None:
|
||||
self.send("btn a 0")
|
||||
releases.append("btn a 0")
|
||||
if self.scroll_until is not None:
|
||||
self.send("scroll 0 0")
|
||||
releases.append("scroll 0 0")
|
||||
for command in releases:
|
||||
self.send(command)
|
||||
self.system_at = self.system_release = self.claim_at = self.claim_release = self.scroll_until = None
|
||||
self.dx = self.dy = self.pending = 0
|
||||
self.gaze_awake_until = 0.0
|
||||
if self.active:
|
||||
if self.active or releases:
|
||||
self.send("hide")
|
||||
if self.active:
|
||||
self.active = False
|
||||
log("pointer off (paused)")
|
||||
|
||||
@@ -1019,9 +1055,14 @@ def main():
|
||||
|
||||
screens_down = set() # keys the desktop was told went down and not yet up (see reconcile_desktop_keys)
|
||||
|
||||
def to_screens(code, value):
|
||||
"""A key for the desktop screens (ft-screens decides whether it types)."""
|
||||
if value in (0, 1) and code < BTN_MISC:
|
||||
def to_screens(code, value, button=False):
|
||||
"""A key for the desktop screens (ft-screens decides whether it types): one below
|
||||
BTN_MISC, or with button, a mouse button (BTN_MOUSE..BTN_TASK), which ft-screens gives
|
||||
the screen the pointer is on. Nothing else (gamepad, joystick, digitizer buttons, keys
|
||||
from KEY_OK up), but the release of anything the desktop has down."""
|
||||
if value not in (0, 1):
|
||||
return
|
||||
if code < BTN_MISC or (button and BTN_MOUSE <= code <= BTN_TASK) or (not value and code in screens_down):
|
||||
try:
|
||||
screens_sock.sendto(f"key {code} {value}".encode(), SCREENS)
|
||||
except OSError:
|
||||
@@ -1281,9 +1322,12 @@ def main():
|
||||
vr_bind(time.monotonic()) # a helper that's already running keeps its buttons in step
|
||||
waiting = False # a keyboard's grab waits for its keys to come up
|
||||
# A relay that went away with a key down left it down on the desktop, where this one
|
||||
# never sent it: modifiers come up there now (a release of a key that isn't down is nothing).
|
||||
# never sent it: modifiers and mouse buttons come up there now (a release of a key that
|
||||
# isn't down is nothing).
|
||||
for code in sorted(MODIFIERS):
|
||||
to_screens(code, 0)
|
||||
for code in range(BTN_MOUSE, BTN_TASK + 1):
|
||||
to_screens(code, 0, button=True)
|
||||
while True:
|
||||
now = time.monotonic()
|
||||
pointer = state["pointer"]
|
||||
@@ -1372,6 +1416,12 @@ def main():
|
||||
volume.key(fd, code, value, now)
|
||||
continue
|
||||
if node.role == "volume":
|
||||
# A keyboard's media keys (its Consumer Control node) go to the desktop like a
|
||||
# pass-through keyboard's, and nowhere else: the node isn't grabbed, so gamescope
|
||||
# and SteamVR have them already. Not platform buttons: the headset's click button
|
||||
# is KEY_SELECT on gpio-keys (BUS_HOST). Nor a volume key a remap missed.
|
||||
if etype == EV_KEY and node.bus in (BUS_USB, BUS_BLUETOOTH) and code not in VOLUME_CODES:
|
||||
to_screens(code, value)
|
||||
continue
|
||||
if node.role != "pointer":
|
||||
# Observed only, unless typing goes to the desktop. Key combinations work on
|
||||
@@ -1406,7 +1456,11 @@ def main():
|
||||
continue
|
||||
target = mouse if code >= BTN_MISC else keyboard
|
||||
target.emit(etype, code, value)
|
||||
to_screens(code, value)
|
||||
# A mouse button goes to the desktop only when pointer mode passes it through
|
||||
# as a key (a side button for Back there). The rest, like every click with
|
||||
# POINTER=0 or paused, reach the desktop through a laser, if at all. (A
|
||||
# key-mapped button still goes to the virtual mouse too.)
|
||||
to_screens(code, value, button=bool(pointer))
|
||||
if value:
|
||||
node.held.add(code)
|
||||
else:
|
||||
|
||||
+151
-21
@@ -1,11 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline test of the input relay's key combinations and modifier taps.
|
||||
"""Offline test of the input relay's key combinations and modifier taps, and of which keys and
|
||||
mouse buttons reach the desktop (ft-screens).
|
||||
|
||||
Runs the relay's main() against a fake pass-through keyboard and a fake mouse (pipes), with
|
||||
every socket it sends to renamed, no uinput devices, no grabs, and ft-steam swapped for a
|
||||
logger. Pausing (game_pause.py) is a stub: no threads, state file, or services. Nothing reaches
|
||||
the live desktop, SteamVR, or the running relay, so it's safe next to them. Rules come from the
|
||||
test, not ~/.config/frametop-input.json.
|
||||
Runs the relay's main() against a fake pass-through keyboard, a fake mouse, and two fake nodes
|
||||
with volume keys, a keyboard's Consumer Control node and the headset's gpio-keys (pipes), with
|
||||
every socket it sends to renamed, no uinput devices (they record what they're sent), no grabs,
|
||||
no volume changes, and ft-steam swapped for a logger. Pausing (game_pause.py) is a stub: no
|
||||
threads, state file, or services. Nothing reaches the live desktop, SteamVR, or the running
|
||||
relay, so it's safe next to them. Rules come from the test, not ~/.config/frametop-input.json.
|
||||
|
||||
input/test/keys-test.py [RELAY] (default: input/input-relay.py next to this folder)
|
||||
"""
|
||||
@@ -72,38 +74,55 @@ with open(relay.FT_STEAM, "w") as f:
|
||||
os.chmod(relay.FT_STEAM, 0o755)
|
||||
|
||||
|
||||
class NoDevice:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
EMITTED = [] # (virtual device, code, value): what the virtual mouse and keyboard were sent
|
||||
VOLUME = [] # (code, value): volume keys the relay handled itself (no wpctl here)
|
||||
|
||||
def emit(self, *args):
|
||||
pass
|
||||
|
||||
class NoDevice:
|
||||
"""A virtual device that only records what it's sent."""
|
||||
|
||||
def __init__(self, name, *args, **kwargs):
|
||||
self.name = name.split()[-1] # mouse or keyboard
|
||||
|
||||
def emit(self, etype, code, value):
|
||||
EMITTED.append((self.name, code, value))
|
||||
|
||||
def sync(self):
|
||||
pass
|
||||
|
||||
|
||||
relay.Virtual = NoDevice
|
||||
relay.Volume.key = lambda self, fd, code, value, now: VOLUME.append((code, value))
|
||||
relay.log = lambda *args: None # the relay's own log
|
||||
bindings = {"now": None} # the rules' key_bindings; None: the relay's defaults
|
||||
devices = {"roles": {}, "buttons": {}} # the rules' "devices" roles and per-device "buttons"
|
||||
MOUSE_ID = "usb:0003:0004:test mouse" # the fake mouse's id (Node.id)
|
||||
|
||||
|
||||
def read_rules(path=None):
|
||||
rules = {"devices": {}, "buttons": {}, "controller_buttons": {}}
|
||||
rules = {"devices": {i: {"role": r} for i, r in devices["roles"].items()},
|
||||
"buttons": {i: dict(b) for i, b in devices["buttons"].items()}, "controller_buttons": {}}
|
||||
rules["key_bindings"] = dict(relay.DEFAULT_KEY_BINDINGS if bindings["now"] is None else bindings["now"])
|
||||
return rules
|
||||
|
||||
|
||||
relay.read_rules = read_rules
|
||||
relay.read_config = lambda path=None: {"POINTER": "0"}
|
||||
conf = {"POINTER": "0"} # ~/.config/frametop.conf, as the relay reads it
|
||||
relay.read_config = lambda path=None: dict(conf)
|
||||
|
||||
# The fake devices: /dev/input/event900 (keyboard) and event901 (mouse), each a pipe.
|
||||
# The fake devices, each a pipe: /dev/input/event900 (keyboard), event901 (mouse), event902 (the
|
||||
# keyboard's Consumer Control node: media and volume keys), event903 (the headset's gpio-keys).
|
||||
kb_r, kb_w = os.pipe()
|
||||
ms_r, ms_w = os.pipe()
|
||||
for fd in (kb_r, ms_r):
|
||||
cc_r, cc_w = os.pipe()
|
||||
gp_r, gp_w = os.pipe()
|
||||
for fd in (kb_r, ms_r, cc_r, gp_r):
|
||||
os.set_blocking(fd, False)
|
||||
FAKE = {"/dev/input/event900": kb_r, "/dev/input/event901": ms_r}
|
||||
HELD = {kb_r: set(), ms_r: set()} # what EVIOCGKEY says each holds
|
||||
FAKE = {"/dev/input/event900": kb_r, "/dev/input/event901": ms_r, "/dev/input/event902": cc_r,
|
||||
"/dev/input/event903": gp_r}
|
||||
READ_END = {kb_w: kb_r, ms_w: ms_r, cc_w: cc_r, gp_w: gp_r}
|
||||
HELD = {kb_r: set(), ms_r: set(), cc_r: set(), gp_r: set()} # what EVIOCGKEY says each holds
|
||||
BUS_HOST = 0x19
|
||||
|
||||
|
||||
class Inode:
|
||||
@@ -112,7 +131,7 @@ class Inode:
|
||||
|
||||
fake_os = type(os)("os")
|
||||
fake_os.__dict__.update(os.__dict__)
|
||||
fake_os.listdir = lambda path: ["event900", "event901"] if path == "/dev/input" else os.listdir(path)
|
||||
fake_os.listdir = lambda path: [p.split("/")[-1] for p in FAKE] if path == "/dev/input" else os.listdir(path)
|
||||
fake_os.stat = lambda path, *a, **k: Inode() if path in FAKE else os.stat(path, *a, **k)
|
||||
fake_os.access = lambda path, mode, *a, **k: path in FAKE or os.access(path, mode, *a, **k)
|
||||
relay.os = fake_os
|
||||
@@ -135,7 +154,17 @@ relay.fcntl = fake_fcntl
|
||||
def probe(path):
|
||||
if FAKE[path] == kb_r:
|
||||
return relay.Node(path, kb_r, "test keyboard", relay.BUS_USB, 1, 2, "", False, True)
|
||||
return relay.Node(path, ms_r, "test mouse", relay.BUS_USB, 3, 4, "", True, False)
|
||||
if FAKE[path] == ms_r:
|
||||
return relay.Node(path, ms_r, "test mouse", relay.BUS_USB, 3, 4, "", True, False)
|
||||
# Nodes with volume keys (role "volume"). take_volume does nothing with --no-grab, so they
|
||||
# come as if it had remapped their volume keys to the stand-ins.
|
||||
if FAKE[path] == cc_r:
|
||||
node = relay.Node(path, cc_r, "test keyboard Consumer Control", relay.BUS_USB, 1, 2, "", False, False,
|
||||
candidate=False)
|
||||
else:
|
||||
node = relay.Node(path, gp_r, "gpio-keys", BUS_HOST, 0, 0, "", False, False, candidate=False)
|
||||
node.remapped = True
|
||||
return node
|
||||
|
||||
|
||||
relay.probe = probe
|
||||
@@ -152,7 +181,7 @@ def send(fd, etype, code, value):
|
||||
|
||||
|
||||
def key(code, value, fd=kb_w):
|
||||
held = HELD[ms_r if fd == ms_w else kb_r]
|
||||
held = HELD[READ_END[fd]]
|
||||
(held.add if value else held.discard)(code)
|
||||
send(fd, relay.EV_KEY, code, value)
|
||||
|
||||
@@ -167,6 +196,14 @@ def typed():
|
||||
return got
|
||||
|
||||
|
||||
def emitted():
|
||||
"""What the virtual mouse and keyboard were sent since the last call."""
|
||||
got = []
|
||||
while EMITTED:
|
||||
got.append(EMITTED.pop(0))
|
||||
return got
|
||||
|
||||
|
||||
def use(key_bindings):
|
||||
bindings["now"] = key_bindings
|
||||
c = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
@@ -212,7 +249,8 @@ F24 = ["key 194 1", "key 194 0"]
|
||||
|
||||
def tests():
|
||||
time.sleep(1.5) # the relay's first device scan
|
||||
typed()
|
||||
check("a relay that starts releases the modifiers and mouse buttons on the desktop", typed(),
|
||||
[f"key {c} 0" for c in sorted(relay.MODIFIERS)] + [f"key {c} 0" for c in range(0x110, 0x118)])
|
||||
key(META, 1); key(META, 0)
|
||||
check("Meta tap (default): Steam menu", lines(steam_log), ["menu"])
|
||||
check("Meta tap: the desktop gets F24 before Meta's release", typed(), ["key 125 1"] + F24 + ["key 125 0"])
|
||||
@@ -237,6 +275,37 @@ def tests():
|
||||
check("Meta+J (default gaze click): no tap", lines(steam_log), ["menu", "menu"])
|
||||
check("Meta+J: F24, Meta up early, its real release dropped", typed(), ["key 125 1"] + F24 + ["key 125 0"])
|
||||
|
||||
# Mouse buttons reach the desktop only when pointer mode passes them through as keys.
|
||||
emitted()
|
||||
key(relay.BTN_LEFT, 1, ms_w); key(relay.BTN_LEFT, 0, ms_w); key(relay.BTN_SIDE, 1, ms_w); key(relay.BTN_SIDE, 0, ms_w)
|
||||
check("POINTER=0: clicks don't reach the desktop", typed(), [])
|
||||
check("POINTER=0: clicks go to the virtual mouse", emitted(),
|
||||
[("mouse", relay.BTN_LEFT, 1), ("mouse", relay.BTN_LEFT, 0), ("mouse", relay.BTN_SIDE, 1),
|
||||
("mouse", relay.BTN_SIDE, 0)])
|
||||
|
||||
# A keyboard's Consumer Control node isn't grabbed, so gamescope has its keys already: its
|
||||
# media keys go to the desktop and nowhere else, its volume keys to the relay's volume
|
||||
# handling only. The headset's gpio-keys (BUS_HOST) send nothing on.
|
||||
PLAYPAUSE, SELECT, VOLUP = 164, 353, relay.KEY_VOLUMEUP
|
||||
key(PLAYPAUSE, 1, cc_w); key(PLAYPAUSE, 0, cc_w)
|
||||
check("media key: reaches the desktop", typed(), ["key 164 1", "key 164 0"])
|
||||
check("media key: no virtual device", emitted(), [])
|
||||
standin = relay.VOLUME_STANDIN[VOLUP]
|
||||
key(standin, 1, cc_w); key(standin, 0, cc_w)
|
||||
check("volume key (its stand-in): the relay's volume handling only", (typed(), emitted(), VOLUME[:]),
|
||||
([], [], [(standin, 1), (standin, 0)]))
|
||||
VOLUME.clear()
|
||||
key(VOLUP, 1, cc_w); key(VOLUP, 0, cc_w)
|
||||
check("a volume key a remap missed reaches nothing", (typed(), emitted(), VOLUME[:]), ([], [], []))
|
||||
key(SELECT, 1, gp_w); key(SELECT, 0, gp_w)
|
||||
check("the headset's click button (gpio-keys, KEY_SELECT) reaches nothing", (typed(), emitted()), ([], []))
|
||||
key(PLAYPAUSE, 1, cc_w)
|
||||
time.sleep(1.2) # the relay's once-a-second check (reconcile_desktop_keys)
|
||||
check("a media key held for over a second stays down on the desktop", typed(), ["key 164 1"])
|
||||
HELD[cc_r].discard(PLAYPAUSE) # its node lets go, and the release never comes
|
||||
time.sleep(1.2)
|
||||
check("...and comes up there once its node doesn't hold it", typed(), ["key 164 0"])
|
||||
|
||||
use({"29+42+33": f"command:echo combo >> {cmd_log}", "125": "none"})
|
||||
key(CTRL, 1); key(SHIFT, 1); key(F, 1); key(F, 0); key(SHIFT, 0); key(CTRL, 0)
|
||||
check("Ctrl+Shift+F runs its command", lines(cmd_log), ["combo"])
|
||||
@@ -265,6 +334,67 @@ def tests():
|
||||
key(META, 1); key(J, 1); key(J, 0); key(META, 0)
|
||||
check("resumed: Meta+J is a combination again", typed(), ["key 125 1"] + F24 + ["key 125 0"])
|
||||
|
||||
# Pointer mode: a click held into a pause comes up as it starts, then the pointer hides
|
||||
# (stand_down); the release that comes during the pause reaches no one.
|
||||
helper = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
helper.bind(relay.HELPER)
|
||||
helper.settimeout(0.05)
|
||||
|
||||
def clicks():
|
||||
"""The helper's button commands and hides since the last call (not the claim pulse)."""
|
||||
got = []
|
||||
while True:
|
||||
try:
|
||||
m = helper.recv(256).decode()
|
||||
except socket.timeout:
|
||||
return got
|
||||
if m == "hide" or (m.startswith("btn ") and not m.startswith("btn a ")):
|
||||
got.append(m)
|
||||
|
||||
conf["POINTER"] = "1"
|
||||
use({})
|
||||
key(relay.BTN_LEFT, 1, ms_w)
|
||||
time.sleep(0.5) # the claim pulse, 0.3 s after the pointer wakes
|
||||
check("pointer mode: a held left button reaches the helper", clicks(), ["btn trigger 1"])
|
||||
pause("on")
|
||||
check("pause with the left button held: it comes up, then the pointer hides", clicks(),
|
||||
["btn trigger 0", "hide"])
|
||||
key(relay.BTN_LEFT, 0, ms_w)
|
||||
check("paused: its release reaches no one", clicks(), [])
|
||||
pause("off")
|
||||
key(relay.BTN_LEFT, 1, ms_w); key(relay.BTN_LEFT, 0, ms_w)
|
||||
check("resumed: a click goes to the helper again", clicks(), ["btn trigger 1", "btn trigger 0"])
|
||||
|
||||
# Pointer mode: a click is the helper's alone. A side button mapped to "key" (Back on the
|
||||
# desktop) goes to the desktop too, and its release still does after a pause starts; paused,
|
||||
# clicks go to the virtual mouse only.
|
||||
check("pointer mode: a click doesn't reach the desktop", typed(), [])
|
||||
emitted()
|
||||
devices["buttons"] = {MOUSE_ID: {str(relay.BTN_SIDE): "key"}}
|
||||
use({})
|
||||
key(relay.BTN_SIDE, 1, ms_w)
|
||||
check("pointer mode: a side button mapped to key reaches the desktop", typed(), [f"key {relay.BTN_SIDE} 1"])
|
||||
check("...and the virtual mouse, not the helper", (emitted(), clicks()), ([("mouse", relay.BTN_SIDE, 1)], []))
|
||||
pause("on")
|
||||
clicks()
|
||||
key(relay.BTN_SIDE, 0, ms_w)
|
||||
check("paused: its release still reaches the desktop", typed(), [f"key {relay.BTN_SIDE} 0"])
|
||||
emitted()
|
||||
key(relay.BTN_SIDE, 1, ms_w); key(relay.BTN_SIDE, 0, ms_w); key(relay.BTN_LEFT, 1, ms_w); key(relay.BTN_LEFT, 0, ms_w)
|
||||
check("paused: clicks don't reach the desktop", typed(), [])
|
||||
check("paused: clicks go to the virtual mouse", emitted(),
|
||||
[("mouse", relay.BTN_SIDE, 1), ("mouse", relay.BTN_SIDE, 0), ("mouse", relay.BTN_LEFT, 1),
|
||||
("mouse", relay.BTN_LEFT, 0)])
|
||||
pause("off")
|
||||
clicks()
|
||||
devices["roles"] = {MOUSE_ID: "passthrough"}
|
||||
use({})
|
||||
key(relay.BTN_LEFT, 1, ms_w); key(relay.BTN_LEFT, 0, ms_w); key(relay.BTN_SIDE, 1, ms_w); key(relay.BTN_SIDE, 0, ms_w)
|
||||
check("a pass-through mouse's clicks don't reach the desktop", typed(), [])
|
||||
check("...nor a virtual device or the helper", (emitted(), clicks()), ([], []))
|
||||
devices["roles"], devices["buttons"] = {}, {}
|
||||
conf["POINTER"] = "0"
|
||||
|
||||
use(None) # the defaults: Meta+Alt+Tab and Meta+Alt+Shift+Tab spin the panels (ft-screens)
|
||||
key(META, 1); key(ALT, 1); key(TAB, 1); key(TAB, 0); key(ALT, 0); key(META, 0)
|
||||
got = typed()
|
||||
|
||||
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline test of the relay's pointer standing down when Frametop pauses: a click still held
|
||||
when the pause starts (a mouse button, a mapped controller button, or a key combination mapped
|
||||
to left, right, middle or back) is released by stand_down, and "hide" follows the release, even
|
||||
when the pointer was off already. Gaze holds (gazekey, gazedrag, precision) are the helper's
|
||||
to end, so stand_down sends nothing for them. The pointer's socket is a recorder, so nothing
|
||||
reaches the helper, SteamVR, or the running relay.
|
||||
|
||||
input/test/pause-buttons-test.py [RELAY]
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
relay_path = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "..", "input-relay.py")
|
||||
tag = f"ft_pause_buttons_test_{os.getpid()}"
|
||||
src = open(relay_path).read().replace('"\\0frametop_relay"', f'"\\0{tag}_relay"')
|
||||
# macOS has no SOCK_NONBLOCK; the pointer's socket is replaced below anyway.
|
||||
src = src.replace("socket.SOCK_DGRAM | socket.SOCK_NONBLOCK", "socket.SOCK_DGRAM")
|
||||
relay = importlib.util.module_from_spec(importlib.util.spec_from_loader("relay", loader=None))
|
||||
relay.__file__ = os.path.abspath(relay_path)
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(relay_path)))
|
||||
exec(compile(src, relay_path, "exec"), relay.__dict__)
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def check(label, got, want):
|
||||
ok = got == want
|
||||
print(("ok " if ok else "FAIL ") + label + ("" if ok else f": got {got!r}, want {want!r}"), flush=True)
|
||||
if not ok:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""Stands in for the helper's socket: remembers every command, in order."""
|
||||
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
|
||||
def sendto(self, data, addr):
|
||||
self.sent.append(data.decode())
|
||||
|
||||
|
||||
def pointer():
|
||||
p = relay.Pointer(0.02, 3600.0)
|
||||
p.sock = Recorder()
|
||||
return p
|
||||
|
||||
|
||||
def since(p, mark):
|
||||
"""What the pointer sent the helper after mark (a length of p.sock.sent)."""
|
||||
return p.sock.sent[mark:]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the fix
|
||||
p = pointer()
|
||||
p.action("left", 1, 10.0) # a mouse click (or drag) held: btn trigger 1
|
||||
check("press reaches the helper", p.sock.sent, ["show", "recenter", "btn trigger 1"])
|
||||
mark = len(p.sock.sent)
|
||||
p.stand_down() # Frametop pauses while it's held
|
||||
check("stand_down releases the held button, then hides", since(p, mark), ["btn trigger 0", "hide"])
|
||||
|
||||
# The release that pausing drops later is already covered: the button is no longer down,
|
||||
# so a stray second stand_down sends nothing.
|
||||
mark = len(p.sock.sent)
|
||||
p.stand_down()
|
||||
check("a second stand_down sends nothing more", since(p, mark), [])
|
||||
|
||||
# Two buttons down at once: a left drag tilted with the right button
|
||||
p = pointer()
|
||||
p.action("left", 1, 10.0)
|
||||
p.action("right", 1, 10.5)
|
||||
mark = len(p.sock.sent)
|
||||
p.stand_down()
|
||||
check("both held buttons are released, then it hides", since(p, mark), ["btn b 0", "btn trigger 0", "hide"])
|
||||
|
||||
# A mapped controller button and a key combination take the same path as the mouse.
|
||||
p = pointer()
|
||||
p.action("middle", 1, 10.0, "right") # a controller button mapped to middle
|
||||
p.action("back", 1, 10.1, "keyboard") # a key combination mapped to back
|
||||
mark = len(p.sock.sent)
|
||||
p.stand_down()
|
||||
check("controller and key combination clicks are released too", since(p, mark),
|
||||
["btn joystick 0", "btn x 0", "hide"])
|
||||
|
||||
# ---------------------------------------------------------------- the pointer already off
|
||||
# A release with no "hide" after it would wake a helper that wakes on any btn, connecting the
|
||||
# virtual controller during the game.
|
||||
p = pointer()
|
||||
p.idle = 30.0
|
||||
p.action("left", 1, 10.0)
|
||||
for t in (10.3, 10.4, 41.0): # the claim pulse, then 30 s with no mouse input
|
||||
p.tick(t)
|
||||
check("held 30 s with no mouse input: the pointer goes off", (p.active, p.sock.sent[-1]), (False, "hide"))
|
||||
mark = len(p.sock.sent)
|
||||
p.stand_down()
|
||||
check("idle with a button held: the release, then hide", since(p, mark), ["btn trigger 0", "hide"])
|
||||
|
||||
p = pointer()
|
||||
p.action("left", 1, 10.0)
|
||||
p.action("pointer_toggle", 1, 10.5)
|
||||
mark = len(p.sock.sent)
|
||||
p.stand_down()
|
||||
check("pointer toggled off with a button held: the release, then hide", since(p, mark),
|
||||
["btn trigger 0", "hide"])
|
||||
|
||||
# ---------------------------------------------------------------- the ordinary path
|
||||
p = pointer()
|
||||
p.action("left", 1, 10.0)
|
||||
p.action("left", 0, 11.0) # released before the pause
|
||||
mark = len(p.sock.sent)
|
||||
p.stand_down()
|
||||
check("a button released before the pause: stand_down only hides", since(p, mark), ["hide"])
|
||||
|
||||
p = pointer()
|
||||
p.idle = 30.0
|
||||
p.action("left", 1, 10.0)
|
||||
p.action("left", 0, 11.0)
|
||||
for t in (10.3, 10.4, 42.0): # off by itself
|
||||
p.tick(t)
|
||||
mark = len(p.sock.sent)
|
||||
p.stand_down()
|
||||
check("nothing held and the pointer off: stand_down sends nothing", since(p, mark), [])
|
||||
|
||||
# Gaze holds go to the helper as their own commands, and it ends them when the pointer hides.
|
||||
p = pointer()
|
||||
p.action("gaze_left", 1, 10.0, "keyboard") # Meta+J held
|
||||
p.action("gaze_drag", 1, 10.1, "mouse")
|
||||
mark = len(p.sock.sent)
|
||||
p.stand_down()
|
||||
check("gaze holds: stand_down sends only hide (the helper ends them)", since(p, mark), ["hide"])
|
||||
|
||||
print()
|
||||
if failures:
|
||||
print(f"{len(failures)} failed: {', '.join(failures)}")
|
||||
sys.exit(1)
|
||||
print("all ok")
|
||||
+40
-14
@@ -4,22 +4,29 @@
|
||||
# eye tracker for it, and the Bluetooth fixes. Run it on the headset in a terminal, from this repo. It's safe to re-run,
|
||||
# for example after `git pull`. (Hand tracking, hands/, is deferred: it isn't offered here.)
|
||||
# (It also works from a PC over SSH; see "Developing from a PC" in the README.)
|
||||
# In a release (pack/install-release.sh), the programs come built from its image: this installs the
|
||||
# distrobox the release brings, makes the release's container from its image, and builds nothing.
|
||||
#
|
||||
# Usage: ./install.sh [--yes] [--no-bluetooth]
|
||||
# Usage: ./install.sh [--yes] [--no-eye-tracker] [--no-bluetooth | --bluetooth]
|
||||
# --yes don't ask; installs gaze mode, and our eye tracker if sudo can run without
|
||||
# a password prompt; skips the Bluetooth fixes and the SteamVR restart
|
||||
# --no-eye-tracker don't offer our own eye tracker
|
||||
# --no-bluetooth don't offer the Bluetooth fixes
|
||||
# --bluetooth install the Bluetooth fixes without asking (with --yes: if sudo can run
|
||||
# without a password prompt)
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
. "$root/scripts/_env.sh"
|
||||
|
||||
assume_yes=0 bluetooth=1
|
||||
assume_yes=0 bluetooth=1 eye_tracker=1
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--yes) assume_yes=1 ;;
|
||||
--no-bluetooth) bluetooth=0 ;;
|
||||
-h|--help) sed -n '2,12p' "$0"; exit 0 ;;
|
||||
--bluetooth) bluetooth=2 ;;
|
||||
--no-eye-tracker) eye_tracker=0 ;;
|
||||
-h|--help) sed -n '2,17p' "$0"; exit 0 ;;
|
||||
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
@@ -45,8 +52,12 @@ sudo_quiet() {
|
||||
grep -q '^steamos_root_pwd=.' "$REPO_ROOT/.env" 2>/dev/null && return 0
|
||||
on_frame 'sudo -n true' 2>/dev/null
|
||||
}
|
||||
# A release never builds (FRAME_RELEASE, scripts/_env.sh): its programs come in its image.
|
||||
build() { [ "$FRAME_RELEASE" = 1 ] || "$@"; }
|
||||
|
||||
if [ "$FRAME_LOCAL" = 1 ]; then
|
||||
if [ "$FRAME_RELEASE" = 1 ]; then
|
||||
echo "Installing Frametop $(sed -n 's/^VERSION=//p' "$root/.frametop-release") on this Steam Frame from $FRAME_REPO"
|
||||
elif [ "$FRAME_LOCAL" = 1 ]; then
|
||||
echo "Installing on this Steam Frame from $FRAME_REPO"
|
||||
else
|
||||
echo "Installing on $FRAME_HOST over SSH (repo copy at $FRAME_REPO)"
|
||||
@@ -60,6 +71,9 @@ fi
|
||||
step "1/10 distrobox (container tool, installed in your home folder)"
|
||||
if on_frame 'test -x ~/.local/bin/distrobox'; then
|
||||
echo "already installed: $(on_frame '~/.local/bin/distrobox version | head -1')"
|
||||
elif [ "$FRAME_RELEASE" = 1 ]; then
|
||||
# The release's image brings the distrobox it was tested with (pack/Containerfile).
|
||||
on_frame 'cd pack/build/distrobox && ./install --prefix ~/.local'
|
||||
else
|
||||
on_frame 'set -e; mkdir -p ~/dev/src
|
||||
# A tested release, so upstream changes cannot break new installs.
|
||||
@@ -67,32 +81,40 @@ else
|
||||
cd ~/dev/src/distrobox && ./install --prefix ~/.local'
|
||||
fi
|
||||
|
||||
step "2/10 build container (Fedora 44 'dev', about 1-2 GB the first time)"
|
||||
"$root/setup/dev-container.sh"
|
||||
if [ "$FRAME_RELEASE" = 1 ]; then
|
||||
step "2/10 Frametop's container (from this release's image)"
|
||||
"$root/pack/release-box.sh"
|
||||
else
|
||||
step "2/10 build container (Fedora 44 'dev', about 1-2 GB the first time)"
|
||||
"$root/setup/dev-container.sh"
|
||||
fi
|
||||
|
||||
step "3/10 input relay (keeps Bluetooth mice working in SteamVR, device roles, button maps)"
|
||||
"$root/desktops.sh" relay install
|
||||
|
||||
step "4/10 3D mouse: SteamVR driver"
|
||||
"$root/pointer/driver/build.sh"
|
||||
"$root/pointer/driver/install.sh" install 2>&1 | grep -v xdg-open
|
||||
build "$root/pointer/driver/build.sh"
|
||||
"$root/pointer/driver/install.sh" install
|
||||
|
||||
step "5/10 3D mouse: pointer helper service"
|
||||
"$root/pointer/helper/build.sh"
|
||||
build "$root/pointer/helper/build.sh"
|
||||
"$root/pointer/helper/run.sh" install
|
||||
|
||||
step "6/10 power service (turns the displays off while the headset isn't used, even on a stand)"
|
||||
"$root/power/build.sh"
|
||||
build "$root/power/build.sh"
|
||||
"$root/power/run.sh" install
|
||||
|
||||
step "7/10 multi-screen desktop (ft-screens), Frametop Input Settings, and Frametop Display Settings"
|
||||
"$root/screens/build.sh"
|
||||
step "7/10 multi-screen desktop (ft-screens), Frametop Input Settings, Display Settings, and Remote Displays"
|
||||
build "$root/screens/build.sh"
|
||||
build "$root/stream/build.sh" # ft-stream: remote displays (Frametop Remote Displays)
|
||||
"$root/desktops.sh" install >/dev/null
|
||||
"$root/input-settings/install.sh"
|
||||
"$root/display-settings/install.sh"
|
||||
"$root/remote-displays/install.sh"
|
||||
"$root/remote/install.sh"
|
||||
on_frame "sed -i 's/^POINTER=0/POINTER=1/' ~/.config/frametop.conf; grep -q '^POINTER=' ~/.config/frametop.conf || echo 'POINTER=1' >> ~/.config/frametop.conf"
|
||||
echo "the launcher's Desktop entry now opens the multi-screen desktop; 3D mouse on (POINTER=1 in ~/.config/frametop.conf)"
|
||||
"$root/scripts/conf-migrate.sh"
|
||||
|
||||
step "8/10 gaze mode (optional, experimental: the pointer goes where you look)"
|
||||
gaze=0
|
||||
@@ -108,9 +130,11 @@ fi
|
||||
step "9/10 our own eye tracker for gaze mode (recommended: more accurate than SteamVR's)"
|
||||
if [ "$gaze" = 0 ]; then
|
||||
echo "skipped: gaze mode isn't installed. Install it later with: gaze/tracker/install.sh"
|
||||
elif [ "$eye_tracker" = 0 ]; then
|
||||
echo "skipped. Install it later with: gaze/tracker/install.sh"
|
||||
elif [ "$assume_yes" = 1 ] && ! sudo_quiet; then
|
||||
echo "skipped: it needs your password (sudo), and --yes doesn't ask. Install it later with: gaze/tracker/install.sh"
|
||||
elif ask "Install our own eye tracker? Gaze mode then uses it instead of SteamVR's. Its frame grabber is a small system service, so it needs your password (sudo), and it downloads about 165 MB (numpy, OpenCV)." y; then
|
||||
elif ask "Install our own eye tracker? Gaze mode then uses it instead of SteamVR's. Its frame grabber is a small system service, so it needs your password (sudo)$([ "$FRAME_RELEASE" = 1 ] || echo ", and it downloads about 165 MB (numpy, OpenCV)")." y; then
|
||||
if "$root/gaze/tracker/install.sh" install; then
|
||||
# Configs made before GAZE_TRACKER=auto say steam, which keeps SteamVR's.
|
||||
tracker=$(on_frame "sed -n 's/^GAZE_TRACKER=\([a-z]*\).*/\1/p' ~/.config/frametop.conf | tail -1")
|
||||
@@ -132,7 +156,9 @@ else
|
||||
fi
|
||||
|
||||
step "10/10 Bluetooth fixes (optional; they let LE mice and keyboards like the Swiftpoint Z3 reconnect)"
|
||||
if [ "$bluetooth" = 1 ] && ask "Install the Bluetooth fixes? They need your password (sudo)." n; then
|
||||
if [ "$bluetooth" = 2 ] && [ "$assume_yes" = 1 ] && ! sudo_quiet; then
|
||||
echo "skipped: they need your password (sudo), and --yes doesn't ask. Install later with: setup/bluetooth/install.sh install"
|
||||
elif [ "$bluetooth" = 2 ] || { [ "$bluetooth" = 1 ] && ask "Install the Bluetooth fixes? They need your password (sudo)." n; }; then
|
||||
"$root/setup/bluetooth/install.sh" install
|
||||
else
|
||||
echo "skipped. Install later with: setup/bluetooth/install.sh install"
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# just — the runner for the actions that happen inside the image.
|
||||
# The CI smoke job runs these with: docker run ... IMAGE just test
|
||||
# Host-side image management (build, shell, update) lives in ./ft; these
|
||||
# recipes only ever run where the toolchain and the venv are: in the image.
|
||||
|
||||
# default: list the recipes
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# everything, strict (the recipes are the source of truth)
|
||||
test: test-python test-c test-bash
|
||||
|
||||
# the Python suites as a strict gate: every failure fails the run. The image's
|
||||
# python3 has to import the dnf Qt stack and the locked packages together;
|
||||
# without that check the Qt tests would only skip. session/test is not here:
|
||||
# test_accessibility.py needs the host's AT-SPI and PyGObject.
|
||||
test-python:
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
python3 -c 'import PySide6, numpy, cv2' || { echo "python3 can't import PySide6, numpy, and cv2"; exit 1; }
|
||||
status=0
|
||||
for t in input/test/*.py hands/tests/test_*.py hands/rec/tests/*.py gaze/test/*.py pack/test/*.py; do
|
||||
[ -f "$t" ] || continue
|
||||
if python3 "$t" >/dev/null; then echo "$t: ok"; else echo "$t: FAILED"; status=1; fi
|
||||
done
|
||||
pytest -q session/tests || status=1
|
||||
exit $status
|
||||
|
||||
# the C/C++ unit tests that need no model runtime: header-only logic with
|
||||
# made-up events. sides_test.cpp is NOT here: it pulls in ncnn (hands'
|
||||
# deferred heavy build) and stays with `make check` in the hands build
|
||||
# (hands/Makefile).
|
||||
test-c:
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
mkdir -p build/tests
|
||||
gcc -std=c11 -Wall -Wextra -Werror -I. screens/tests/controller-click-test.c \
|
||||
-lm -o build/tests/controller-click-test
|
||||
build/tests/controller-click-test && echo "screens/tests/controller-click-test.c: ok"
|
||||
gcc -std=c11 -Wall -Wextra -Werror screens/tests/relay-buttons-test.c \
|
||||
-o build/tests/relay-buttons-test
|
||||
build/tests/relay-buttons-test && echo "screens/tests/relay-buttons-test.c: ok"
|
||||
|
||||
# shell scripts: syntax-gate everything that ships. No git: the image has
|
||||
# none, and a mounted checkout can trip "dubious ownership" — find lists
|
||||
# what the repo actually ships. The count guard makes a silently short
|
||||
# list fail the gate instead of passing it.
|
||||
test-bash:
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
mapfile -t scripts < <(find . -name '*.sh' -type f \
|
||||
-not -path './.git/*' -not -path '*/build/*' | sort)
|
||||
[ "${#scripts[@]}" -ge 20 ] || { echo "only ${#scripts[@]} scripts found — the list is broken"; exit 1; }
|
||||
status=0
|
||||
for f in "${scripts[@]}" ft; do
|
||||
bash -n "$f" && echo "bash -n $f: ok" || status=1
|
||||
done
|
||||
exit $status
|
||||
|
||||
# lint the Python side (ruff from the locked dev group). Report only for now:
|
||||
# the codebase has pre-existing findings; making it a gate is its own cleanup.
|
||||
lint:
|
||||
-ruff check .
|
||||
|
||||
# the whole gate: lint, then tests
|
||||
check: lint test
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
# Reset Screen Layout's own program. Steam lists menu entries by program, so entries that
|
||||
# shared ft-layout (with different arguments) launched as one.
|
||||
exec "$(dirname "$(readlink -f "$0")")/ft-layout" apply "$@"
|
||||
exec "$(dirname "$(readlink -f "$0")")/ft-layout" reset "$@"
|
||||
+521
-8
@@ -36,7 +36,24 @@ you face (yaw only), like a recenter. It lives in ~/.config/frametop-layout.json
|
||||
visibility mode (ft-layout hide N)
|
||||
"pos": [x, y, z], "face": [yaw, pitch], "roll": 0, custom layout
|
||||
"rotation": "normal" | "left" | "right"}, ...], gamescope only
|
||||
"panel_size": [w, h]} gamescope: last measured panel size
|
||||
"panel_size": [w, h], gamescope: last measured panel size
|
||||
"hosts": [{"name": "desk-pc", "address": "192.168.1.20", ft-screens: other machines' displays
|
||||
"direct": ["10.35.78.20"], its Steam Link dongle on the Frame's hotspot
|
||||
"route": "auto", auto (the dongle when it answers) | network |
|
||||
dongle (only; ft-stream reads both)
|
||||
"displays": [{"id": "desk-pc-oled", (docs/remote-displays.md), each a panel
|
||||
"client": "frametop-1", streamed by ft-stream: its paired client,
|
||||
"app": "display:{GUID}", what it streams (display:DEVICE, monitor:
|
||||
a Vibepollo Remote Monitor, primary),
|
||||
"label": "OLED", "size": [5120, 1440], "fps": 60, "bitrate": 50000,
|
||||
"metres": 1.8, "curve": 0, "pin": ..., "hidden": true,
|
||||
"off": true, disconnected: no stream, no panel
|
||||
"pos": ..., "face": ..., "roll": 0}]}]} as a screen's
|
||||
Remote displays are screens 101 and up, in the order they're listed. Without a place of their
|
||||
own they go in a row above the screens. A profile keeps the ones connected when it was saved,
|
||||
like its apps: their places and hidden state by id (profiles[NAME]["remote"]). Opening it, or
|
||||
arranging while it's the one in use, connects each whose host answers and puts it there; the
|
||||
others stay as they are.
|
||||
Custom positions: x right, y up, -z forward from the head, in metres; face = the
|
||||
direction you look to see the screen's front straight on, in degrees, relative to your
|
||||
heading; roll = the panel turned about its front, counterclockwise as you see it.
|
||||
@@ -47,6 +64,9 @@ is top left, then left to right.
|
||||
Usage (on the Frame host; Frametop Display Settings calls it too):
|
||||
ft-layout apply [--wait SECONDS] arrange every screen; --wait is for desktop start:
|
||||
wait for the screens, skip if "auto" is off
|
||||
ft-layout reset a quick reset (Meta+Shift+R, a screen's reset button, a mapped
|
||||
button): open the profile in use again, as use NAME does, or
|
||||
else apply
|
||||
ft-layout capture save the current arrangement as the custom layout
|
||||
ft-layout save NAME save it as a named layout too, and use that; with the
|
||||
desktop's apps and hidden screens, as a profile
|
||||
@@ -70,7 +90,19 @@ Usage (on the Frame host; Frametop Display Settings calls it too):
|
||||
ft-layout hidden the screens hidden on their own
|
||||
ft-layout pin all|N left|right|head pin screens to a wrist or your head as they are;
|
||||
unpin all|N
|
||||
ft-layout remote list the remote displays, their numbers and their streams' state
|
||||
ft-layout remote monitors ADDRESS the host's displays (Vibepollo's /api/display-devices, JSON)
|
||||
ft-layout remote add HOST ADDRESS APP [--size WxH] [--fps F] [--bitrate KBPS] [--label TEXT]
|
||||
a display of a host (pairs its client with the host's token
|
||||
first) -> its id
|
||||
ft-layout remote set ID size=WxH|fps=F|bitrate=KBPS|label=TEXT|app=APP|metres=M ...
|
||||
ft-layout remote host NAME route=auto|network|dongle direct=ADDRESS[,ADDRESS]|none
|
||||
how a host's streams reach it; its running streams start over
|
||||
ft-layout remote connect ID... start their streams again (and keep them on)
|
||||
ft-layout remote disconnect ID... stop their streams and panels until connected again
|
||||
ft-layout remote remove ID stops it and unpairs its client (the host stays)
|
||||
"""
|
||||
import concurrent.futures
|
||||
import fcntl
|
||||
import json
|
||||
import math
|
||||
@@ -100,7 +132,12 @@ VISIBILITY = {"mode": "always", "wrist_angle": 60, "gesture_hand": "left", "gest
|
||||
SPATIAL = ("pos", "face", "roll", "metres", "curve", "pin") # what a named layout keeps of a screen
|
||||
DEFAULTS = {"auto": True, "mode": "preset",
|
||||
"preset": {"kind": "arc", "rows": 1, "distance": 2.0, "gap": 0.05, "height": 0.0},
|
||||
"screens": [], "panel_size": list(DEFAULT_PANEL)}
|
||||
"screens": [], "panel_size": list(DEFAULT_PANEL), "hosts": []}
|
||||
REMOTE_FIRST = 101 # ft-screens' remote screens (screens/remote.h)
|
||||
REMOTE_MAX = 16
|
||||
REMOTE_PIXELS_PER_METRE = 2400 # a new remote display's width in VR (5120 px: 2.1 m), at least 1 m
|
||||
WEB_UI_PORT = 47990 # Vibepollo's Web UI: a host answers there when it's up
|
||||
STREAM = os.path.join(REPO, "stream", "build", "ft-stream")
|
||||
|
||||
|
||||
def log(*args):
|
||||
@@ -432,9 +469,20 @@ def set_hidden(which, hidden):
|
||||
desktop runs."""
|
||||
layout = load_layout()
|
||||
n = screen_count(layout)
|
||||
picked = range(n) if which == "all" else [int(which) - 1] if which.isdigit() else []
|
||||
if not picked or not all(0 <= i < n for i in picked):
|
||||
raise RuntimeError(f"no screen {which} (1 to {n})")
|
||||
remotes = {num: d for num, _, d in remote_displays(layout)}
|
||||
if which == "all":
|
||||
picked, picked_remote = range(n), list(remotes)
|
||||
elif which.isdigit() and int(which) in remotes:
|
||||
picked, picked_remote = [], [int(which)]
|
||||
else:
|
||||
picked, picked_remote = ([int(which) - 1] if which.isdigit() else []), []
|
||||
if not (picked or picked_remote) or not all(0 <= i < n for i in picked):
|
||||
raise RuntimeError(f"no screen {which} (1 to {n}, or a remote display's number)")
|
||||
for num in picked_remote:
|
||||
if hidden:
|
||||
remotes[num]["hidden"] = True
|
||||
else:
|
||||
remotes[num].pop("hidden", None)
|
||||
screens = layout.setdefault("screens", [])
|
||||
while len(screens) < n:
|
||||
screens.append({})
|
||||
@@ -446,8 +494,8 @@ def set_hidden(which, hidden):
|
||||
save_layout(layout)
|
||||
try:
|
||||
sock = screens_socket()
|
||||
for i in picked:
|
||||
sock.ask(f"{'conceal' if hidden else 'reveal'} {i + 1}")
|
||||
for num in [i + 1 for i in picked] + picked_remote:
|
||||
sock.ask(f"{'conceal' if hidden else 'reveal'} {num}")
|
||||
except RuntimeError as e:
|
||||
log(f"saved; not applied now: {e}")
|
||||
|
||||
@@ -482,6 +530,8 @@ def apply_screens(wait=0):
|
||||
if time.time() >= deadline:
|
||||
raise
|
||||
time.sleep(1)
|
||||
start_remotes(sock, layout)
|
||||
save_layout(layout) # screen numbers given to new remote displays
|
||||
f = sock.ask("head").split()
|
||||
eye, heading = tuple(map(float, f[1:4])), float(f[4])
|
||||
send_visibility(sock, layout)
|
||||
@@ -501,6 +551,7 @@ def apply_screens(wait=0):
|
||||
except RuntimeError as e:
|
||||
log(f"screen {i + 1}: {e}") # that controller isn't on
|
||||
send_hidden(sock, layout)
|
||||
place_remotes(sock, layout, count, eye, heading)
|
||||
try:
|
||||
sock.ask("vrkeyboard close") # the keyboard, if open, goes too: a reset starts over
|
||||
except RuntimeError:
|
||||
@@ -527,6 +578,7 @@ def capture_screens():
|
||||
screens.append(entry)
|
||||
layout["screens"] = screens + layout.get("screens", [])[len(screens):]
|
||||
layout["mode"] = "custom"
|
||||
capture_remotes(sock, layout, eye, heading)
|
||||
save_layout(layout)
|
||||
return screens
|
||||
|
||||
@@ -534,7 +586,8 @@ def capture_screens():
|
||||
# ---------------------------------------------------------------- gamescope (dashboard panels)
|
||||
|
||||
def vrcmd(*args, timeout=10):
|
||||
env = dict(os.environ, LD_LIBRARY_PATH=os.path.dirname(VRCMD))
|
||||
# SteamVR's config folder: in the desktop, XDG_CONFIG_HOME is its own (docs/design.md).
|
||||
env = dict(os.environ, LD_LIBRARY_PATH=os.path.dirname(VRCMD), XDG_CONFIG_HOME=os.path.expanduser("~/.config"))
|
||||
try:
|
||||
return subprocess.run([VRCMD, *args], capture_output=True, text=True, timeout=timeout, env=env).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
@@ -644,6 +697,428 @@ def capture_gamescope():
|
||||
return screens
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- remote displays (docs/remote-displays.md)
|
||||
|
||||
def remote_displays(layout):
|
||||
"""[(number, host, display)]: each display's screen number (its "screen", kept so it
|
||||
doesn't move when another is removed; or the first free one)."""
|
||||
entries = [(h, d) for h in layout.get("hosts", []) for d in h.get("displays", [])]
|
||||
used, out = set(), []
|
||||
for h, d in entries:
|
||||
n = d.get("screen")
|
||||
if isinstance(n, int) and REMOTE_FIRST <= n < REMOTE_FIRST + REMOTE_MAX and n not in used:
|
||||
used.add(n)
|
||||
d["_kept"] = True
|
||||
for h, d in entries:
|
||||
if not d.pop("_kept", False):
|
||||
free = [k for k in range(REMOTE_FIRST, REMOTE_FIRST + REMOTE_MAX) if k not in used]
|
||||
if not free:
|
||||
continue
|
||||
d["screen"] = free[0]
|
||||
used.add(free[0])
|
||||
out.append((d["screen"], h, d))
|
||||
return out
|
||||
|
||||
|
||||
def find_display(layout, display_id):
|
||||
for n, host, d in remote_displays(layout):
|
||||
if d.get("id") == display_id:
|
||||
return n, host, d
|
||||
raise RuntimeError(f"no remote display {display_id!r}")
|
||||
|
||||
|
||||
def remote_size(d):
|
||||
"""A remote display's size in VR (width, height) in metres."""
|
||||
w, h = d.get("size", [2560, 1440])
|
||||
m = float(d.get("metres", max(1.0, w / REMOTE_PIXELS_PER_METRE)))
|
||||
return m, m * h / w
|
||||
|
||||
|
||||
def plan_remotes(layout, count, displays):
|
||||
"""The remote displays' poses in the head frame (as plan()): their own place, or else a
|
||||
row above the screens, hinged like the arc preset."""
|
||||
out = [None] * len(displays)
|
||||
for k, (_, _, d) in enumerate(displays):
|
||||
if "pos" in d:
|
||||
out[k] = {"pos": tuple(d["pos"]), "face": tuple(d.get("face", yaw_pitch(d["pos"]))),
|
||||
"roll": float(d.get("roll", 0))}
|
||||
free = [k for k in range(len(displays)) if out[k] is None and not displays[k][2].get("off")]
|
||||
if not free:
|
||||
return out
|
||||
p = layout["preset"]
|
||||
dist = max(0.3, float(p.get("distance", 2.0)))
|
||||
gap = max(0.0, float(p.get("gap", 0.05)))
|
||||
span = lambda m, at: 2 * math.degrees(math.atan(m / 2 / at))
|
||||
top = 0.0
|
||||
for i, t in enumerate(plan(layout, count)):
|
||||
x, y, z = t["pos"]
|
||||
at = max(0.3, math.sqrt(x * x + y * y + z * z))
|
||||
top = max(top, math.degrees(math.atan2(y, math.hypot(x, z))) + span(screen_size(layout, i)[1], at) / 2)
|
||||
sizes = [remote_size(displays[k][2]) for k in free]
|
||||
pitch = top + span(gap, dist) + max(span(h, dist) for _, h in sizes) / 2
|
||||
cp, sp = math.cos(math.radians(pitch)), math.sin(math.radians(pitch))
|
||||
for k, (x, z, yaw) in zip(free, _chain([w for w, _ in sizes], dist, gap)):
|
||||
out[k] = {"pos": (x * cp, math.hypot(x, z) * sp, z * cp), "face": (yaw, pitch), "roll": 0.0}
|
||||
return out
|
||||
|
||||
|
||||
def remotes_running(sock):
|
||||
"""ft-screens' remote screens, {number: state}; None from an ft-screens without them."""
|
||||
try:
|
||||
fields = sock.ask("remotes").split()[2:]
|
||||
except RuntimeError:
|
||||
return None
|
||||
return {int(e.split(":")[0]): e.split(":")[2] for e in fields}
|
||||
|
||||
|
||||
def start_remote(sock, n, host, d):
|
||||
"""Its stream (ft-screens starts it over only if the settings changed). ft-screens'
|
||||
reply: "ok restored" when its panel is back where it was before a disconnect."""
|
||||
w, h = d.get("size", [2560, 1440])
|
||||
label = f"{host.get('name') or host['address']}: {d.get('label') or d['id']}"
|
||||
return sock.ask(f"remote {n} start {d['client']} {host['address']} {d['app']} {int(w)}x{int(h)} "
|
||||
f"{int(d.get('fps', 60))} {int(d.get('bitrate', 0))} {remote_size(d)[0]:.3f} {label}")
|
||||
|
||||
|
||||
def place_remote(sock, n, d, t, eye, heading):
|
||||
center = tuple(e + v for e, v in zip(eye, turn_yaw(t["pos"], heading)))
|
||||
sock.ask(f"width {n} {remote_size(d)[0]:.4f}")
|
||||
sock.ask(f"curve {n} {float(d.get('curve', 0)):.3f}")
|
||||
sock.ask("place %d %.4f %.4f %.4f %.3f %.3f %.3f" % (n, *center, t["face"][0] + heading, t["face"][1], t["roll"]))
|
||||
pin = d.get("pin")
|
||||
if pin and len(pin.get("rel", [])) == 12:
|
||||
try:
|
||||
sock.ask(f"pin {n} {pin['hand']} " + " ".join(f"{v:.5f}" for v in pin["rel"]))
|
||||
except RuntimeError as e:
|
||||
log(f"remote display {d['id']}: {e}") # that controller isn't on
|
||||
sock.ask(f"{'conceal' if d.get('hidden') else 'reveal'} {n}")
|
||||
|
||||
|
||||
def host_answers(host, timeout=1.5):
|
||||
"""Whether a host's Vibepollo answers, at its address or its Steam Link dongle's (as its
|
||||
route allows)."""
|
||||
route = host.get("route", "auto")
|
||||
addresses = ([] if route == "dongle" else [host.get("address")]) + \
|
||||
([] if route == "network" else list(host.get("direct", [])))
|
||||
for address in filter(None, addresses):
|
||||
try:
|
||||
with socket.create_connection((address, WEB_UI_PORT), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def profile_remotes(layout, name):
|
||||
"""Remote displays the profile has a place for go there; the others stay where they are."""
|
||||
remote = layout.get("profiles", {}).get(name, {}).get("remote", {})
|
||||
for _, _, d in remote_displays(layout):
|
||||
if d.get("id") in remote:
|
||||
for k in SPATIAL:
|
||||
d.pop(k, None)
|
||||
d.update(json.loads(json.dumps({k: v for k, v in remote[d["id"]].items() if k in SPATIAL})))
|
||||
|
||||
|
||||
def open_remotes(layout, name):
|
||||
"""Connect the remote displays profile `name` was saved with (in the layout: apply starts
|
||||
their streams), each whose host answers now. A host that doesn't is skipped, and its
|
||||
displays stay disconnected."""
|
||||
remote = layout.get("profiles", {}).get(name, {}).get("remote", {})
|
||||
wanted = [(h, d) for _, h, d in remote_displays(layout) if d.get("id") in remote and d.get("off")]
|
||||
hosts = list({id(h): h for h, _ in wanted}.values())
|
||||
if not hosts:
|
||||
return
|
||||
with concurrent.futures.ThreadPoolExecutor(len(hosts)) as pool:
|
||||
up = dict(zip(map(id, hosts), pool.map(host_answers, hosts)))
|
||||
for h, d in wanted:
|
||||
if up[id(h)]:
|
||||
d.pop("off", None)
|
||||
log(f"remote display {d['id']}: connecting (profile {name!r})")
|
||||
else:
|
||||
log(f"remote display {d['id']}: {h.get('name') or h['address']} isn't answering; skipped")
|
||||
|
||||
|
||||
def start_remotes(sock, layout, only=None):
|
||||
"""Start the remote displays' streams (or just the ids in `only`; ft-screens leaves one
|
||||
running with the same settings alone), and stop the streams of ones no longer listed or
|
||||
disconnected ("off"). They start without a head pose too: placing them waits for one
|
||||
(place_remotes)."""
|
||||
displays = remote_displays(layout)
|
||||
running = remotes_running(sock)
|
||||
if running is None:
|
||||
if displays:
|
||||
log("remote displays: this ft-screens can't show them (build it again)")
|
||||
return False
|
||||
if only is None:
|
||||
for n in set(running) - {n for n, _, d in displays if not d.get("off")}:
|
||||
sock.ask(f"remote {n} stop")
|
||||
for n, host, d in displays:
|
||||
if d.get("off"):
|
||||
continue
|
||||
if only is None or d.get("id") in only:
|
||||
try:
|
||||
start_remote(sock, n, host, d)
|
||||
except RuntimeError as e:
|
||||
log(f"remote display {d.get('id')}: {e}")
|
||||
return True
|
||||
|
||||
|
||||
def place_remotes(sock, layout, count, eye, heading, only=None):
|
||||
displays = remote_displays(layout)
|
||||
for (n, host, d), t in zip(displays, plan_remotes(layout, count, displays)):
|
||||
if d.get("off"):
|
||||
continue # disconnected: no panel
|
||||
if only is None or d.get("id") in only:
|
||||
try:
|
||||
place_remote(sock, n, d, t, eye, heading)
|
||||
except RuntimeError as e:
|
||||
log(f"remote display {d.get('id')}: {e}")
|
||||
on = [d for _, _, d in displays if not d.get("off") and (only is None or d.get("id") in only)]
|
||||
if on:
|
||||
log(f"arranged {len(on)} remote display(s)")
|
||||
|
||||
|
||||
def capture_remotes(sock, layout, eye, heading):
|
||||
"""Where the remote displays are now (each one running) into their entries."""
|
||||
for n, _, d in remote_displays(layout):
|
||||
try:
|
||||
g = parse_get(sock.ask(f"get {n}"))
|
||||
except RuntimeError:
|
||||
continue # not running
|
||||
d.update(relative_pose(g["center"], g["x"], g["z"], eye, heading))
|
||||
d["metres"] = round(g["metres"], 4)
|
||||
d["curve"] = round(g["curve"], 3)
|
||||
d.pop("pin", None)
|
||||
if "rel" in g:
|
||||
d["pin"] = {"hand": g["hand"], "rel": g["rel"]}
|
||||
|
||||
|
||||
def in_container():
|
||||
return os.path.exists("/run/.containerenv")
|
||||
|
||||
|
||||
def run_stream(*args, timeout=60):
|
||||
"""ft-stream (it runs in the dev container, as ft-screens does)."""
|
||||
cmd = [STREAM, *args]
|
||||
if not in_container():
|
||||
cmd = [os.path.expanduser("~/.local/bin/distrobox"), "enter", "dev", "--", *cmd]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
return r.returncode, r.stdout, r.stderr
|
||||
|
||||
|
||||
def slug(text):
|
||||
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") or "display"
|
||||
|
||||
|
||||
def remote_anchor(sock, layout):
|
||||
"""(eye, heading) to place remote displays from: the head, or with no head pose (the
|
||||
headset off), where the last arrangement was made from, worked out from where screen 1
|
||||
is and where the layout puts it."""
|
||||
try:
|
||||
f = sock.ask("head").split()
|
||||
return tuple(map(float, f[1:4])), float(f[4])
|
||||
except RuntimeError:
|
||||
g = parse_get(sock.ask("get 1"))
|
||||
if g["hand"] != "none":
|
||||
raise RuntimeError("no head pose, and screen 1 is pinned")
|
||||
t = plan(layout, screen_count(layout))[0]
|
||||
heading = yaw_pitch(tuple(-c for c in g["z"]))[0] - t["face"][0]
|
||||
return tuple(c - v for c, v in zip(g["center"], turn_yaw(t["pos"], heading))), heading
|
||||
|
||||
|
||||
def start_remotes_now(layout, only):
|
||||
"""Start and place remote displays now, if the desktop runs."""
|
||||
try:
|
||||
sock = screens_socket()
|
||||
if not start_remotes(sock, layout, only):
|
||||
return
|
||||
eye, heading = remote_anchor(sock, layout)
|
||||
place_remotes(sock, layout, screen_count(layout), eye, heading, only=only)
|
||||
except RuntimeError as e:
|
||||
log(f"not placed now: {e}")
|
||||
|
||||
|
||||
def remote_command(args):
|
||||
"""ft-layout remote list|monitors|add|set|connect|disconnect|remove (see the usage)."""
|
||||
what = args[0] if args else ""
|
||||
if what == "list" and len(args) == 1:
|
||||
layout = load_layout()
|
||||
try:
|
||||
running = remotes_running(screens_socket()) or {}
|
||||
except RuntimeError:
|
||||
running = {}
|
||||
for n, host, d in remote_displays(layout):
|
||||
w, h = d.get("size", [2560, 1440])
|
||||
state = "disconnected" if d.get("off") else running.get(n, "off")
|
||||
state += f" route {host.get('route', 'auto')}"
|
||||
print(f"{n} {d['id']} {host.get('name')} {host['address']} {d['app']} {w}x{h}@{d.get('fps', 60)} "
|
||||
f"{state}{' hidden' if d.get('hidden') else ''} {d.get('label', '')}")
|
||||
return
|
||||
if what == "host" and len(args) >= 3:
|
||||
layout = load_layout()
|
||||
host = next((h for h in layout.get("hosts", []) if h.get("name") == args[1]), None)
|
||||
if host is None:
|
||||
raise RuntimeError(f"no host called {args[1]!r}")
|
||||
for kv in args[2:]:
|
||||
k, _, v = kv.partition("=")
|
||||
if k == "route" and v in ("auto", "network", "dongle"):
|
||||
host["route"] = v
|
||||
elif k == "direct" and (v == "none" or re.fullmatch(r"[A-Za-z0-9.:-]+(,[A-Za-z0-9.:-]+)*", v)):
|
||||
host["direct"] = [] if v == "none" else v.split(",")
|
||||
else:
|
||||
raise RuntimeError(f"remote host: {kv!r}? (route=auto|network|dongle direct=ADDRESS[,ADDRESS]|none)")
|
||||
save_layout(layout)
|
||||
# Its streams start over the new way, their panels where they are (ft-screens keeps
|
||||
# their places across a stop in the same run).
|
||||
try:
|
||||
sock = screens_socket()
|
||||
running = remotes_running(sock) or {}
|
||||
for n, h, d in remote_displays(layout):
|
||||
if h is host and n in running and not d.get("off"):
|
||||
sock.ask(f"remote {n} stop")
|
||||
start_remote(sock, n, h, d)
|
||||
log(f"{d['id']}: starting over")
|
||||
except RuntimeError as e:
|
||||
log(f"saved; not applied now: {e}")
|
||||
return
|
||||
if what in ("connect", "disconnect") and len(args) >= 2:
|
||||
layout = load_layout()
|
||||
found = [find_display(layout, i) for i in args[1:]]
|
||||
for _, _, d in found:
|
||||
if what == "connect":
|
||||
d.pop("off", None)
|
||||
else:
|
||||
d["off"] = True
|
||||
save_layout(layout)
|
||||
if what == "connect":
|
||||
# Back where it was if ft-screens still knows (this run); else placed like the
|
||||
# others, from where you look now.
|
||||
try:
|
||||
sock = screens_socket()
|
||||
unplaced = {d["id"] for n, host, d in found if start_remote(sock, n, host, d) != "ok restored"}
|
||||
except RuntimeError as e:
|
||||
log(f"saved; not started now: {e}")
|
||||
return
|
||||
for _, _, d in found:
|
||||
log(f"connected {d['id']}" + ("" if d["id"] in unplaced else " (where it was)"))
|
||||
if unplaced:
|
||||
try:
|
||||
eye, heading = remote_anchor(sock, layout)
|
||||
place_remotes(sock, layout, screen_count(layout), eye, heading, only=unplaced)
|
||||
except RuntimeError as e:
|
||||
log(f"not placed now: {e}")
|
||||
return
|
||||
try:
|
||||
sock = screens_socket()
|
||||
for n, _, d in found:
|
||||
sock.ask(f"remote {n} stop")
|
||||
log(f"disconnected {d['id']}")
|
||||
except RuntimeError as e:
|
||||
log(f"saved; not stopped now: {e}")
|
||||
return
|
||||
if what == "monitors" and len(args) == 2:
|
||||
code, out, err = run_stream("monitors", args[1])
|
||||
if code:
|
||||
raise RuntimeError(err.strip() or out.strip() or "ft-stream monitors failed")
|
||||
print(out.strip())
|
||||
return
|
||||
if what == "add" and len(args) >= 4:
|
||||
host_name, address, app = args[1], args[2], args[3]
|
||||
opts = dict(zip(args[4::2], args[5::2]))
|
||||
unknown = set(opts) - {"--size", "--fps", "--bitrate", "--label", "--client"}
|
||||
if unknown or len(args[4:]) % 2:
|
||||
raise RuntimeError("remote add: options are --size WxH --fps F --bitrate KBPS --label TEXT --client NAME")
|
||||
if not re.fullmatch(r"[A-Za-z0-9.:-]+", address) or not re.fullmatch(r"[A-Za-z0-9{}._:-]+", app):
|
||||
raise RuntimeError("remote add: the address or app has other characters")
|
||||
layout = load_layout()
|
||||
displays = remote_displays(layout)
|
||||
if len(displays) >= REMOTE_MAX:
|
||||
raise RuntimeError(f"at most {REMOTE_MAX} remote displays")
|
||||
label = opts.get("--label") or ("Virtual" if app == "monitor" else app.split(":", 1)[-1])
|
||||
ids = {d["id"] for _, _, d in displays}
|
||||
display_id = base = slug(f"{host_name}-{label}")
|
||||
for k in range(2, 100):
|
||||
if display_id not in ids:
|
||||
break
|
||||
display_id = f"{base}-{k}"
|
||||
client = opts.get("--client") or f"frametop-{display_id}"
|
||||
if not re.fullmatch(r"[A-Za-z0-9._-]+", client):
|
||||
raise RuntimeError("remote add: the client name has other characters")
|
||||
size = [2560, 1440]
|
||||
if "--size" in opts:
|
||||
size = [int(v) for v in opts["--size"].lower().split("x")]
|
||||
elif app.startswith("display:"): # the monitor's own resolution
|
||||
code, out, _ = run_stream("monitors", address)
|
||||
try:
|
||||
for m in (json.loads(out[out.index("["):]) if code == 0 else []):
|
||||
if m.get("device_id") == app[8:]:
|
||||
res = m["info"]["resolution"]
|
||||
size = [int(res["width"]), int(res["height"])]
|
||||
except (ValueError, KeyError, TypeError):
|
||||
pass
|
||||
code, out, err = run_stream("pair", address, "--id", client, timeout=30)
|
||||
if code:
|
||||
raise RuntimeError(f"pairing {client} with {address} failed: {(err or out).strip()}")
|
||||
log(out.strip().splitlines()[-1] if out.strip() else f"paired {client}")
|
||||
host = next((h for h in layout.setdefault("hosts", []) if h.get("name") == host_name), None)
|
||||
if host is None:
|
||||
host = {"name": host_name, "address": address, "displays": []}
|
||||
layout["hosts"].append(host)
|
||||
host["address"] = address
|
||||
host["displays"].append({"id": display_id, "client": client, "app": app, "label": label, "size": size,
|
||||
"fps": int(opts.get("--fps", 60)), "bitrate": int(opts.get("--bitrate", 0)),
|
||||
"metres": round(max(1.0, size[0] / REMOTE_PIXELS_PER_METRE), 3)})
|
||||
remote_displays(layout) # gives it a screen number
|
||||
save_layout(layout)
|
||||
print(display_id)
|
||||
start_remotes_now(layout, {display_id})
|
||||
return
|
||||
if what == "set" and len(args) >= 3:
|
||||
layout = load_layout()
|
||||
n, host, d = find_display(layout, args[1])
|
||||
for kv in args[2:]:
|
||||
k, _, v = kv.partition("=")
|
||||
if k == "size" and re.fullmatch(r"\d+x\d+", v.lower()):
|
||||
d["size"] = [int(x) for x in v.lower().split("x")]
|
||||
elif k in ("fps", "bitrate") and v.isdigit():
|
||||
d[k] = int(v)
|
||||
elif k == "label" and v:
|
||||
d["label"] = v
|
||||
elif k == "metres" and re.fullmatch(r"\d+(\.\d+)?", v) and 0.15 <= float(v) <= 12:
|
||||
d["metres"] = float(v)
|
||||
elif k == "app" and re.fullmatch(r"[A-Za-z0-9{}._:-]+", v):
|
||||
d["app"] = v
|
||||
else:
|
||||
raise RuntimeError(f"remote set: {kv!r}? (size=WxH fps=F bitrate=KBPS label=TEXT app=APP metres=M)")
|
||||
save_layout(layout)
|
||||
if d.get("off"):
|
||||
return # disconnected: the new settings are for its next connection
|
||||
try:
|
||||
start_remote(screens_socket(), n, host, d) # starts over with the new settings
|
||||
except RuntimeError as e:
|
||||
log(f"saved; not applied now: {e}")
|
||||
return
|
||||
if what == "remove" and len(args) == 2:
|
||||
layout = load_layout()
|
||||
n, host, d = find_display(layout, args[1])
|
||||
try:
|
||||
screens_socket().ask(f"remote {n} stop")
|
||||
time.sleep(2) # its stream releases a Remote Monitor on the way out
|
||||
except RuntimeError:
|
||||
pass
|
||||
host["displays"].remove(d) # the host stays (Remote Displays removes hosts)
|
||||
for profile in layout.get("profiles", {}).values():
|
||||
profile.get("remote", {}).pop(d["id"], None)
|
||||
save_layout(layout)
|
||||
code, out, err = run_stream("unpair", host["address"], "--id", d["client"], timeout=30)
|
||||
said = (out or err).strip().splitlines()
|
||||
log(f"removed {d['id']}; {said[-1] if said else f'unpair exited {code}'}")
|
||||
return
|
||||
raise RuntimeError("remote list | monitors ADDRESS | add HOST ADDRESS APP [options] | set ID KEY=VALUE... | "
|
||||
"host NAME route=...|direct=... | connect ID... | disconnect ID... | remove ID")
|
||||
|
||||
|
||||
def apply(wait=0):
|
||||
return apply_screens(wait) if backend() == "screens" else apply_gamescope(wait)
|
||||
|
||||
@@ -679,6 +1154,14 @@ def save_named(layout, name):
|
||||
if not screens:
|
||||
raise RuntimeError("nothing to save: no arrangement captured")
|
||||
layout.setdefault("layouts", {})[name] = [{k: s[k] for k in SPATIAL if k in s} for s in screens]
|
||||
# The remote displays connected now, like the apps open now.
|
||||
remote = {d["id"]: {k: d[k] for k in SPATIAL if k in d} for _, _, d in remote_displays(layout)
|
||||
if not d.get("off") and "pos" in d}
|
||||
profile = layout.setdefault("profiles", {}).setdefault(name, {})
|
||||
if remote:
|
||||
profile["remote"] = remote
|
||||
else:
|
||||
profile.pop("remote", None)
|
||||
layout["mode"], layout["active"] = "custom", name
|
||||
return name
|
||||
|
||||
@@ -703,6 +1186,7 @@ def use_named(layout, name):
|
||||
elif "pos" not in screens[i]:
|
||||
screens[i].update({"pos": list(preset[i]["pos"]), "face": list(preset[i]["face"]),
|
||||
"roll": preset[i]["roll"]})
|
||||
profile_remotes(layout, name)
|
||||
layout["mode"], layout["active"] = "custom", name
|
||||
|
||||
|
||||
@@ -757,6 +1241,10 @@ def capture_profile(layout, name):
|
||||
hidden = [i + 1 for i in range(screen_count(layout)) if screen_entry(layout, i).get("hidden")]
|
||||
profile = layout.setdefault("profiles", {}).setdefault(name, {})
|
||||
profile["hidden"] = hidden
|
||||
remote = profile.get("remote", {}) # the ones connected (save_named)
|
||||
for _, _, d in remote_displays(layout):
|
||||
if d["id"] in remote:
|
||||
remote[d["id"]]["hidden"] = bool(d.get("hidden"))
|
||||
reply = ask_float("windows")
|
||||
if reply and reply.startswith("ok "):
|
||||
profile["windows"] = json.loads(reply[3:])
|
||||
@@ -779,6 +1267,13 @@ def use_hidden(layout, name):
|
||||
screens[i]["hidden"] = True
|
||||
else:
|
||||
screens[i].pop("hidden", None)
|
||||
remote = profile.get("remote", {})
|
||||
for _, _, d in remote_displays(layout):
|
||||
if "hidden" in remote.get(d.get("id"), {}):
|
||||
if remote[d["id"]]["hidden"]:
|
||||
d["hidden"] = True
|
||||
else:
|
||||
d.pop("hidden", None)
|
||||
|
||||
|
||||
def open_apps(name, wait=0):
|
||||
@@ -1085,6 +1580,7 @@ def main(argv):
|
||||
return main([argv[0], "apply"] + argv[2:])
|
||||
use_named(layout, name)
|
||||
use_hidden(layout, name)
|
||||
open_remotes(layout, name)
|
||||
save_layout(layout)
|
||||
log(f"starting in profile {name!r}")
|
||||
wait = float(argv[argv.index("--wait") + 1]) if "--wait" in argv else 60
|
||||
@@ -1101,6 +1597,12 @@ def main(argv):
|
||||
else:
|
||||
log(f"kwin: {last}")
|
||||
open_apps(name, wait=90) # ft-floatd starts with Plasma
|
||||
elif cmd == "reset":
|
||||
layout = load_layout()
|
||||
name = layout.get("active") if layout.get("mode") == "custom" else None
|
||||
return main([argv[0], "use", name] if name in layout.get("layouts", {}) else [argv[0], "apply"])
|
||||
elif cmd == "remote":
|
||||
remote_command(argv[2:])
|
||||
elif cmd in ("pin", "unpin") and len(argv) >= 3:
|
||||
log(screens_socket().ask(" ".join(argv[1:])))
|
||||
kwin_follow() # pinned screens go last
|
||||
@@ -1123,9 +1625,19 @@ def main(argv):
|
||||
time.sleep(1)
|
||||
send_visibility(sock, load_layout())
|
||||
send_hidden(sock, load_layout())
|
||||
layout = load_layout()
|
||||
if start_remotes(sock, layout): # streams start, arranged or not
|
||||
save_layout(layout)
|
||||
except RuntimeError as e:
|
||||
log(f"visibility: {e}")
|
||||
else:
|
||||
layout = load_layout()
|
||||
name = layout.get("active") if layout.get("mode") == "custom" else None
|
||||
if name in layout.get("profiles", {}):
|
||||
# Arranged as the profile in use: its remote displays too.
|
||||
profile_remotes(layout, name)
|
||||
open_remotes(layout, name)
|
||||
save_layout(layout)
|
||||
apply(wait)
|
||||
if not wait:
|
||||
kwin_follow()
|
||||
@@ -1158,6 +1670,7 @@ def main(argv):
|
||||
layout = load_layout()
|
||||
use_named(layout, argv[2])
|
||||
use_hidden(layout, argv[2])
|
||||
open_remotes(layout, argv[2])
|
||||
save_layout(layout)
|
||||
log(f"using layout {argv[2]!r}")
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Frametop runtime image (see pack/README.md).
|
||||
#
|
||||
# One image that holds everything Frametop needs: the frozen toolchain and C
|
||||
# libraries, its native binaries, and its locked Python environment. A given
|
||||
# image tag always behaves the same, on a PC and on the Frame.
|
||||
#
|
||||
# setup/dev-container.sh stays the source of truth for the package list on the
|
||||
# Frame's dev container; the list below freezes the runtime-relevant subset of
|
||||
# it plus the toolchain (the binaries are built in this image). Keep the two in
|
||||
# sync on purpose: dev-container.sh documents *why* each package is there.
|
||||
FROM registry.fedoraproject.org/fedora-toolbox:44@sha256:034cb7c472038e2d879ddc19568106a1342aa24403f0d641a0f73dda638707ad
|
||||
|
||||
# Toolchain (the binaries are built in here) and the libraries they link.
|
||||
RUN dnf -y install --setopt=install_weak_deps=False \
|
||||
gcc gcc-c++ make pkgconf-pkg-config curl unzip just git cmake ninja-build \
|
||||
pipewire-devel libxkbcommon-devel libinput-devel systemd-devel dbus-devel \
|
||||
libdrm-devel mesa-libgbm-devel wayland-devel vulkan-loader-devel \
|
||||
vulkan-headers plasma-wayland-protocols wlroots-devel pixman-devel \
|
||||
libstdc++-static glibc-static jsoncpp-devel zstd \
|
||||
# remote displays (stream/): moonlight-embedded's libgamestream and
|
||||
# moonlight-common-c, and the host's sound (Opus, PulseAudio)
|
||||
openssl-devel libcurl-devel expat-devel libuuid-devel json-devel \
|
||||
opus-devel pulseaudio-libs-devel \
|
||||
# Frametop Input Settings (Kirigami, PySide6) and remote desktop (krdp):
|
||||
# the KDE/Qt stack has no PyPI wheels, so it stays a dnf package
|
||||
python3-pyside6 kf6-kirigami kf6-qqc2-desktop-style qt6-qtwayland \
|
||||
breeze-icon-theme plasma-breeze krdp freerdp tigervnc-x11-server xrandr \
|
||||
&& dnf -y remove 'glibc-langpack-*' \
|
||||
&& dnf -y install glibc-langpack-en \
|
||||
&& dnf clean all \
|
||||
&& rm -rf /usr/share/doc/* /usr/share/man/* /usr/share/info/* \
|
||||
/usr/share/wallpapers
|
||||
|
||||
# uv, pinned and checked. Python dependencies come from the committed uv.lock,
|
||||
# not from whatever PyPI serves on build day.
|
||||
RUN curl -fsSL https://github.com/astral-sh/uv/releases/download/0.12.23/uv-aarch64-unknown-linux-gnu.tar.gz \
|
||||
-o /tmp/uv.tar.gz \
|
||||
&& echo "6524bd338177ed50d035d39354e12545e993bbeba2ecbddf0480c5b3a81d313f /tmp/uv.tar.gz" | sha256sum -c \
|
||||
&& tar -xzf /tmp/uv.tar.gz --strip-components=1 -C /usr/local/bin \
|
||||
&& rm /tmp/uv.tar.gz
|
||||
|
||||
# OpenVR's API library for linking, from the SDK tag the build scripts pin
|
||||
# (scripts/openvr.sh fetches the headers). The image has no SteamVR, so the
|
||||
# binaries link this copy; their rpath names SteamVR's folder first, so on the
|
||||
# Frame they load SteamVR's own libopenvr_api, as the on-device builds do.
|
||||
RUN install -d /opt/frametop/bin /opt/frametop/lib \
|
||||
&& curl -fsSL https://github.com/ValveSoftware/openvr/raw/v2.15.6/bin/linuxarm64/libopenvr_api.so \
|
||||
-o /opt/frametop/lib/libopenvr_api.so \
|
||||
&& echo "cc3671d24dd23fb61494e8a8327cf4fe7f638a60508ff7225984a03f2f5947e9 /opt/frametop/lib/libopenvr_api.so" | sha256sum -c
|
||||
|
||||
# Python: Fedora's own python3 (the digest-pinned base freezes it), with the
|
||||
# locked packages in a venv on top. The venv sees the system site-packages,
|
||||
# because PySide6 and Kirigami come from dnf for this python3: one interpreter
|
||||
# imports both, as in the dev container on the Frame. Rebuilt only when the
|
||||
# lockfile changes, so source edits below reuse this layer.
|
||||
# uv sync --frozen --group tracker: the default dev group (pytest/ruff for CI's
|
||||
# in-image tests) plus the tracker stack; the types group (mypy) stays out.
|
||||
# --no-cache keeps uv's download cache out of the image.
|
||||
ENV UV_PROJECT_ENVIRONMENT=/opt/frametop/venv UV_PYTHON=/usr/bin/python3 UV_PYTHON_DOWNLOADS=never
|
||||
COPY pyproject.toml uv.lock .python-version /src/frametop/
|
||||
RUN uv venv --system-site-packages /opt/frametop/venv \
|
||||
&& cd /src/frametop && uv sync --frozen --group tracker --no-cache \
|
||||
&& /opt/frametop/venv/bin/python -c 'import PySide6, numpy, cv2'
|
||||
|
||||
# The sources, then the binaries, built by the same build scripts as on the
|
||||
# Frame (FRAME_IN_BOX=1: they run here instead of entering the dev container).
|
||||
# Each program stays in its build/ folder in /src/frametop and is copied to
|
||||
# /opt/frametop. Hand tracking is deferred exactly as in install.sh: it builds
|
||||
# ncnn from source and isn't needed until hands/run.sh install runs.
|
||||
COPY . /src/frametop
|
||||
WORKDIR /src/frametop
|
||||
ENV FRAME_IN_BOX=1 OPENVR_LIB=/opt/frametop/lib
|
||||
RUN screens/build.sh \
|
||||
&& install -t /opt/frametop/bin screens/build/ft-screens screens/build/ft-handtest
|
||||
RUN pointer/helper/build.sh && pointer/driver/build.sh \
|
||||
&& install -t /opt/frametop/bin pointer/helper/build/ft-pointer \
|
||||
&& install -t /opt/frametop/lib pointer/driver/build/driver_ft_pointer.so
|
||||
RUN gaze/build.sh \
|
||||
&& install -t /opt/frametop/bin gaze/build/ft-gaze gaze/build/ft-gazepanel
|
||||
RUN power/build.sh \
|
||||
&& install -t /opt/frametop/bin power/build/ft-powerd
|
||||
RUN stream/build.sh \
|
||||
&& install -t /opt/frametop/bin stream/build/ft-stream
|
||||
# Our own eye tracker: ft-eyegrab (it runs on the host, as root) and ft-eyes'
|
||||
# build/venv, which here only points at the venv above.
|
||||
RUN gaze/tracker/build.sh \
|
||||
&& install -t /opt/frametop/bin gaze/tracker/build/ft-eyegrab
|
||||
|
||||
# A release installs from this image (pack/install-release.sh): it copies
|
||||
# /src/frametop, built, to the Frame for its host side (services, the driver,
|
||||
# the desktop's scripts), and its install.sh installs this distrobox (1.8.2.5,
|
||||
# as a source install clones it, here by commit) to run the image as the
|
||||
# container those programs start in (scripts/in-box).
|
||||
RUN git init -q pack/build/distrobox \
|
||||
&& cd pack/build/distrobox \
|
||||
&& git fetch -q --depth 1 https://github.com/89luca89/distrobox.git 40c3cd724faa434aeb0a23e28776665b92de68bd \
|
||||
&& git checkout -q FETCH_HEAD \
|
||||
&& rm -rf .git
|
||||
# Programs built against OpenVR look for SteamVR's library here first. In the
|
||||
# container on the Frame, distrobox mounts the host at /run/host; elsewhere the
|
||||
# link leads nowhere and they use /opt/frametop/lib's.
|
||||
RUN ln -s /run/host/opt/steamvr /opt/steamvr
|
||||
|
||||
# The default environment: Frametop's binaries and the venv's python3 (Fedora's
|
||||
# interpreter, with the locked packages and the dnf Qt stack) first.
|
||||
# sleep infinity as the default command keeps the image usable with distrobox
|
||||
# create, like the toolbox base.
|
||||
ENV PATH=/opt/frametop/venv/bin:/opt/frametop/bin:/usr/local/bin:/usr/bin:/usr/sbin
|
||||
CMD ["sleep", "infinity"]
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
# Frametop as an image
|
||||
|
||||
The Frame's runtime is already a container: the dev container (Fedora toolbox
|
||||
in distrobox) is where everything builds and runs, and `setup/dev-container.sh`
|
||||
is its recipe. The recipe is the weak point — nothing in it is pinned. Rebuild
|
||||
the container tomorrow and you get a different Fedora, a different wlroots, a
|
||||
different glibc. This directory turns the container from a recipe into an
|
||||
**artifact**: an OCI image whose every input is frozen.
|
||||
|
||||
## What the image contains
|
||||
|
||||
`Containerfile` builds one image, `frametop`, from three frozen inputs:
|
||||
|
||||
1. **The base image by digest** — `fedora-toolbox:44` pinned to the exact
|
||||
image, not the floating tag. This freezes the C world: glibc, wlroots 0.20,
|
||||
the Qt/KDE stack.
|
||||
2. **Python from `uv.lock`** — the committed lockfile pins every Python
|
||||
dependency (pytest, ruff, the tracker's NumPy/OpenCV stack) to exact
|
||||
versions with aarch64 wheels, installed into `/opt/frametop/venv`. The venv
|
||||
sits on Fedora's own `python3`, which the base digest freezes, and sees the
|
||||
system site-packages: PySide6 and Kirigami come from dnf for that
|
||||
interpreter, so one `python3` imports both, as in the dev container.
|
||||
3. **OpenVR from the pinned public tag** — `v2.15.6`, in
|
||||
`scripts/openvr.sh`. The build scripts fetch its headers and check their
|
||||
sha256. The image has no SteamVR, so it links the SDK's prebuilt
|
||||
`libopenvr_api.so` (in `/opt/frametop/lib`, also checked); the binaries'
|
||||
rpath names SteamVR's folder first, so on the Frame they load SteamVR's
|
||||
own library, as the on-device builds do.
|
||||
|
||||
The native binaries (`ft-screens`, `ft-pointer`, the `ft_pointer` driver,
|
||||
`ft-gaze`, `ft-gazepanel`, `ft-powerd`) are built by the components' own
|
||||
`build.sh` scripts, run inside the image with `FRAME_IN_BOX=1`, so there is
|
||||
one build recipe for the image and the Frame. They stay in their `build/`
|
||||
folders under `/src/frametop` and are copied to `/opt/frametop`. Hand
|
||||
tracking stays deferred exactly as in `install.sh` (ncnn is a heavy,
|
||||
separately triggered build).
|
||||
|
||||
What deliberately stays outside the image for now: the **host payload** — the
|
||||
SteamVR driver registration (`vrpathreg`), ft-camd's file capabilities
|
||||
(setcap), systemd units, the KWin script. Those need the SteamOS host, and
|
||||
they are what `install.sh` does today. Packaging them into a checksummed
|
||||
payload tarball is the next slice.
|
||||
|
||||
## The `ft` wrapper
|
||||
|
||||
Everything that runs Frametop goes through the wrapper at the repo root, so
|
||||
the integration points live in one place:
|
||||
|
||||
```
|
||||
ft dev build # build the image from this repo (docker or podman)
|
||||
ft update # pull the published (versioned) image, pin its digest
|
||||
ft dev shell # interactive shell, repo at /src/frametop
|
||||
ft ft-screens ... # run any program from /opt/frametop
|
||||
ft dev test # the Python test suites inside the image
|
||||
ft clean # remove frametop's leftovers only (see the store section)
|
||||
```
|
||||
|
||||
`FT_IMAGE` overrides the image reference (repo mode defaults to the locally
|
||||
built `frametop:local`). Development commands live behind `ft dev` so an
|
||||
installed copy — which has no repo to build from — refuses them. Containers
|
||||
run through the wrapper are named `frametop-<program>-<pid>`. Runs get no
|
||||
access to the host beyond the repo mount: how Frametop's programs run on the
|
||||
Frame is still open (see [design.md](design.md)).
|
||||
|
||||
### No :latest on a headset
|
||||
|
||||
A moving tag has no place on a headset: it cannot be reproduced in a bug
|
||||
report and cannot be rolled back. The wrapper enforces this for what gets
|
||||
**deployed** — installed mode refuses to run without a pinned reference, and
|
||||
both the update source and the pinned reference are rejected if they say
|
||||
`:latest`. Local development can use any tag it likes (`FT_IMAGE` is never
|
||||
second-guessed in repo mode).
|
||||
|
||||
How pinning works:
|
||||
|
||||
- `install.sh` (next slice) writes a **version tag** to
|
||||
`~/.config/frametop/published` — the only place updates come from.
|
||||
- `ft update` pulls that reference and then writes the **digest**
|
||||
(`repo@sha256:...`) to `~/.config/frametop/image`. Every later run, and
|
||||
every bug report, names exactly that image.
|
||||
- Rollback is editing one file: put the previous digest back into
|
||||
`~/.config/frametop/image`. The old image is still in the local store
|
||||
(remove it with `ft clean` when you are done with it).
|
||||
|
||||
Releases (below) don't use these: a release is a file, its image is pinned
|
||||
by ID in `.frametop-release`, and you roll back by running the previous
|
||||
release's `install.sh`.
|
||||
|
||||
## The shared podman store
|
||||
|
||||
On SteamOS, rootless podman has **one container/image store**, and Frametop
|
||||
shares it with Valve's Android layer: Lepton names its containers
|
||||
`lepton-<context>`, and they live in the same store as ours. Two rules
|
||||
follow, and both are enforced or documented rather than hoped for:
|
||||
|
||||
1. **Frametop never touches anything it does not own.** Containers get stable
|
||||
`frametop-<program>` names; `ft clean` deletes only containers matching
|
||||
`frametop-*` and only images whose reference names frametop. It never runs
|
||||
store-wide commands.
|
||||
2. **Nobody should run store-wide podman commands on a Frame.**
|
||||
`podman rm -a`, `podman rmi -a`, and `podman system prune` would delete
|
||||
Valve's containers and images too. Use `ft clean` for Frametop's share of
|
||||
the store and leave the rest of it to Steam.
|
||||
|
||||
## Network path for pulls
|
||||
|
||||
`ft update` (and install) contact the registry over the Frame's normal
|
||||
Wi-Fi client interface (`wlan0`), not the 6 GHz streaming link to the PC
|
||||
dongle. The image is roughly 1–1.5 GB compressed — pulls ride the home
|
||||
network, so a weak Wi-Fi link is the bottleneck, not the streaming antenna.
|
||||
|
||||
## Storage
|
||||
|
||||
The image replaces the build toolchain, not adds to it: today's distrobox dev
|
||||
container with its dnf history is the heavyweight; the pulled image carries
|
||||
only the runtime plus the frozen build inputs (~1–1.5 GB compressed, a few GB
|
||||
unpacked, in `~/.local/share/containers`). Once install.sh switches to the
|
||||
image, the dev container is only needed for development and can be removed
|
||||
from user machines. `ft clean` reclaims space from superseded frametop
|
||||
images.
|
||||
|
||||
## Running on the Frame
|
||||
|
||||
The programs need much more of the host than a plain `podman run` gives them;
|
||||
[design.md](design.md#the-runtime-on-the-frame) lists what, the device
|
||||
findings so far, and the options. What's built so far is option A, a
|
||||
distrobox made from the image, the same kind of container as the dev
|
||||
container. The headset trial (step 2 below) decides.
|
||||
|
||||
Every program an installed Frametop runs in its container goes through
|
||||
`scripts/in-box`: the pointer and power services, the gaze service's ft-gaze,
|
||||
ft-eyes and calibration panel, the desktop's ft-screens, the remote desktop,
|
||||
and the settings apps. A source install runs them in `dev`; a release names
|
||||
its own container in `.frametop-release`.
|
||||
|
||||
## Releases
|
||||
|
||||
A release is one file, **Frametop.zip** (about 1.1 GB), attached to a GitHub
|
||||
release: the image as an OCI archive (`frametop-image.tar`, from `podman save`),
|
||||
`frametop-release.json` (version, commit, the archive's sha256, the image's
|
||||
ID, and the SteamOS table), the installer `install-release.sh`, and the
|
||||
install window from `framedrop/installer`. No container registry. It installs
|
||||
three ways, all through the same installer:
|
||||
|
||||
- FrameDrop on a PC copies the zip's folder to the headset and adds "Frametop"
|
||||
to the library; Play opens the install window (`framedrop/README.md`).
|
||||
- Unpacked on the headset, `Frametop/frametop-install.sh` opens the same
|
||||
window.
|
||||
- `get.sh --release` downloads the zip from GitHub (the newest stable release,
|
||||
or `--experimental`, `--version V`, `--zip FILE`) and runs
|
||||
`install-release.sh` in the terminal.
|
||||
|
||||
`install-release.sh`:
|
||||
|
||||
1. Checks this SteamOS build (`BUILD_ID` in `/etc/os-release`) against the
|
||||
SteamOS table, `steamos.json`: the newest one, from main on GitHub, when it
|
||||
can fetch it, else the copy in the release. Tested: on. Not tested yet: a
|
||||
warning, and a question (`--yes` goes on). Broken for this release: it stops
|
||||
and names the release that fixes it (`--any-steamos` goes on). A broken build
|
||||
counts from its `from` release up to its `fixed_in`, so a release that needs
|
||||
a newer SteamOS refuses an older one, and the other way round.
|
||||
2. Checks the archive's sha256 (a damaged copy stops it, exit status 3, and
|
||||
`get.sh` downloads again), loads it into podman, checks the image's ID, and
|
||||
tags it `localhost/frametop:VERSION`.
|
||||
3. Copies the image's `/src/frametop` (the repo at that commit, built) to
|
||||
`~/.local/share/frametop/releases/VERSION` with a `.frametop-release`
|
||||
(version, commit, channel, image, ID, and the container's name, `frametop-`
|
||||
and the ID's start), and runs that copy's `install.sh`.
|
||||
4. In a release, `install.sh` builds nothing: it installs the distrobox the
|
||||
image brings, makes the release's container from the image
|
||||
(`release-box.sh`, which checks the ID again), and installs the services
|
||||
from the release's folder. Each release has its own container, so
|
||||
installing one doesn't stop the one running.
|
||||
5. The release installed before stays; running its `install.sh` goes back to
|
||||
it. Older ones are removed, with their containers and images.
|
||||
`uninstall.sh` removes them all.
|
||||
|
||||
Every update is a full 1.1 GB download, since the zip holds the whole image
|
||||
(user decision, 2026-10-07: one file, no registry). The `ft` wrapper's
|
||||
installed mode (`ft update`, `~/.config/frametop/published` and `image`) pulls
|
||||
from a registry, so releases don't use it.
|
||||
|
||||
## CI
|
||||
|
||||
`.github/workflows/release.yml` runs on Depot's arm64 runners
|
||||
(`depot-ubuntu-24.04-arm-4`), only for a `v*` tag or by hand: no pushes or pull
|
||||
requests, since the runners are paid and a fork's pull request would run on
|
||||
them. It builds the image with podman (`ft dev build`, as on the Frame), runs
|
||||
the test gate inside it (`ft dev test`: Python, C, and shell), checks what a
|
||||
release installs from the image, and builds Frametop.zip with
|
||||
`framedrop/build.sh --image`. A tag makes a draft GitHub release with the zip,
|
||||
its FrameDrop manifest, `frametop-release.json`, and `SHA256SUMS`, as a
|
||||
prerelease when the tag has a `-` (`v0.3.0-exp.1`); someone publishes it. A
|
||||
manual run keeps the zip as an artifact for a week. The repo needs Depot's
|
||||
GitHub app for the runner label to work.
|
||||
|
||||
## The longer arc
|
||||
|
||||
The image only pays off when it makes installing Frametop easier, so none of
|
||||
this ships until the whole path works:
|
||||
|
||||
1. Image, wrapper, and CI (this directory).
|
||||
2. A headset trial: Frametop's services run from the image (the runtime
|
||||
question above), next to an install time measured against today's
|
||||
on-device build. Go or no-go here.
|
||||
3. A release pipeline: a tag builds the image, tests it, and attaches
|
||||
Frametop.zip to a draft GitHub release (CI above). The image carries the
|
||||
host side too (its `/src/frametop`), so there's no separate tarball.
|
||||
4. FrameDrop, the unpacked zip, and `get.sh --release` install a release
|
||||
(Releases above). The source install stays for development.
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
# Frametop packaging: the OCI image as the product
|
||||
|
||||
This is the design rationale for packaging Frametop as a CI-built OCI image.
|
||||
[pack/README.md](README.md) is the practical reference (what the image
|
||||
contains, the mount matrix, CI); this file explains why it is built this way,
|
||||
what it solves, and how it is meant to be used.
|
||||
|
||||
## The problem today
|
||||
|
||||
Frametop on the Frame is three worlds that today get assembled *on the user's
|
||||
headset* at install time:
|
||||
|
||||
- **The C world**: six native binaries (`ft-screens`, `ft-pointer`, `ft-powerd`,
|
||||
`ft-gaze`, and friends) built against Qt, OpenVR, and wlroots.
|
||||
- **The Python world**: the session, gaze, and settings code, with a NumPy /
|
||||
OpenCV / PySide6 stack.
|
||||
- **The host payload**: the SteamVR driver, ft-camd, systemd units, desktop
|
||||
files, the KWin script — the parts that must live on the SteamOS host.
|
||||
|
||||
The path there is `get.sh` → `install.sh` → `setup/dev-container.sh`: a
|
||||
distrobox environment is set up, packages are installed via dnf, binaries are
|
||||
compiled, Python dependencies are fetched. That design has structural costs:
|
||||
|
||||
1. **Every install is a build.** The first install downloads 1–2 GB and
|
||||
compiles on the device. A flaky mirror, a renamed dnf package, or a failing
|
||||
build step fails installation — individually, for every user, at the worst
|
||||
possible moment (first contact with the project).
|
||||
2. **No two installations are alike.** Unpinned dnf and pip installs mean user
|
||||
A has one OpenCV and user B another. Bug reports become "works on my
|
||||
headset" stories that cannot be reproduced.
|
||||
3. **SteamOS updates hit the host boundary.** The container's toolchain lives
|
||||
in the home folder and survives an update — but every update replaces
|
||||
SteamVR, KWin, and gamescope, and anything in the container that points at
|
||||
a host path, interface, or quirk can break. `scripts/update-check.py`
|
||||
tracks exactly these dependencies. Packaging does not remove this risk —
|
||||
the host boundary stays the host boundary — but it shrinks it to what it
|
||||
genuinely is: with the toolchain frozen in the image there is no on-device
|
||||
build left to rot, and revalidating against a new SteamOS means one CI run
|
||||
instead of every headset finding out individually.
|
||||
4. **Using and developing are coupled.** A user needs a build ecosystem on
|
||||
their headset to *run* Frametop. Conversely, a developer tests in an
|
||||
environment no user shares.
|
||||
|
||||
## The design
|
||||
|
||||
> **The OCI image is the product. CI builds it, users pull it, `ft` is the
|
||||
> only interface in between.**
|
||||
|
||||
```
|
||||
CI (GitHub Actions, native arm64) Frame / PC
|
||||
┌──────────────────────────────┐ ┌─────────────────────────────┐
|
||||
│ pack/Containerfile │ push → │ ghcr.io/<org>/frametop │
|
||||
│ · toolbox base, digest-pinned│ │ ↓ podman pull │
|
||||
│ · uv sync --frozen (lockfile)│ │ ~/.local/bin/ft │
|
||||
│ · OpenVR tag, stb pinned │ │ · runs programs │
|
||||
│ · six binaries compiled │ │ · owns the mounts │
|
||||
│ · smoke-tested in CI │ │ · ft update = pull │
|
||||
└──────────────────────────────┘ └─────────────────────────────┘
|
||||
```
|
||||
|
||||
The `ft` wrapper holds the image-reference resolution (pinning, updates,
|
||||
cleanup) and the development commands. How the programs themselves run from
|
||||
the image on the Frame is still open: see "The runtime on the Frame" below.
|
||||
|
||||
## What it solves, point by point
|
||||
|
||||
| Today | With the image |
|
||||
| --- | --- |
|
||||
| Install = build on the device | `podman pull`. Minutes, no compilers, no dnf |
|
||||
| Every environment drifts | Every user runs the *exact* CI environment |
|
||||
| SteamOS updates rot the on-device build | No on-device build left; what an update can still break is the host boundary — revalidated once in CI instead of per headset |
|
||||
| Users need a developer ecosystem | Users need podman (SteamOS ships it) and the host payload |
|
||||
| "Works on my headset" | A bug report names an image tag; the developer reproduces it in `ft dev shell` within minutes |
|
||||
| Shipping fixes | `ft update`. The user never compiles anything |
|
||||
|
||||
And the subtler win: **decoupled failure domains.** When Frametop breaks on a
|
||||
device, it is now either (a) the image — in which case it breaks identically
|
||||
for everyone and reproducibly in CI — or (b) one of the small, documented host
|
||||
boundaries. Today (a) and (b) are one soup.
|
||||
|
||||
## Why OCI — and not uv alone, not Flatpak
|
||||
|
||||
**Why not "just uv"?** uv (lockfile + frozen sync) solves the Python world
|
||||
properly, and we use it exactly that way *inside* the image. But Frametop is
|
||||
more than Python: the six C binaries, Qt, OpenVR, wlroots, the compile step
|
||||
itself. uv freezes no compiler, no dnf package, no `libopenvr_api`. uv alone
|
||||
still leaves the user compiling on the device with a drifting C toolchain —
|
||||
the largest source of divergence untouched.
|
||||
|
||||
**Why not Flatpak?** Flatpak would be the more "native" answer for desktop
|
||||
apps on SteamOS, and for the settings GUIs it is not absurd in the long run.
|
||||
But Frametop is not a collection of desktop apps. It is a set of daemons that
|
||||
live in SteamVR, on Wayland sockets, on `/dev/shm`, and on localhost sockets —
|
||||
precisely what Flatpak sandboxing turns into an adventure of `--talk-*` and
|
||||
`--filesystem` holes. One would spend the effort knocking holes in the sandbox
|
||||
until it is no longer a sandbox, while still maintaining a separate build
|
||||
system (flatpak-builder, manifests, a runtime dependency) that does the same
|
||||
environment-freezing work again, in Flatpak currency. podman, by contrast,
|
||||
ships with SteamOS (distrobox, which `install.sh` adds, runs on it), and OCI is
|
||||
the one artifact format that CI, GHCR, and local development all speak
|
||||
natively.
|
||||
|
||||
**Why not "distrobox, but pinned"?** That is what we have today — the dev
|
||||
container. But a distrobox container is *state on the device* (dnf
|
||||
transactions accumulating over months, drift), not an *artifact*. The
|
||||
transition is exactly the point: a container you maintain becomes an image you
|
||||
replace. `podman pull` is idempotent; a container filesystem with six months
|
||||
of history is not. (A distrobox *created from* the pinned image, and created
|
||||
again on every update, is a different thing: it holds no state. It is one of
|
||||
the runtime options below.)
|
||||
|
||||
## How it is used
|
||||
|
||||
**Developer (PC, from a checkout):**
|
||||
|
||||
```
|
||||
./ft dev build # build the image from the checkout
|
||||
./ft dev test # the test suites inside the image (strict)
|
||||
./ft dev shell # interactive shell, repo at /src/frametop
|
||||
./ft ft-screens # run a program
|
||||
```
|
||||
|
||||
Repo mode defaults to the locally built `frametop:local` and mounts the
|
||||
checkout at `/src/frametop`.
|
||||
|
||||
**User (Frame, once the install path exists):**
|
||||
|
||||
```
|
||||
ft update # pull the published image, pin its digest
|
||||
```
|
||||
|
||||
No repo on the device, no build. The image reference resolves `FT_IMAGE` →
|
||||
`~/.config/frametop/image` (written by the installer, so installs pin what was
|
||||
installed) → the published image. Development commands live behind `ft dev`
|
||||
and are refused in installed mode — a user should not reach the build world by
|
||||
accident, and an installed wrapper has no checkout to build from anyway.
|
||||
|
||||
**Container naming.** Containers run through the wrapper are named
|
||||
`frametop-<program>-<pid>`: `podman ps` names the program, two runs of one
|
||||
program don't replace each other, and `ft clean` finds the leftovers of
|
||||
crashed runs by the prefix.
|
||||
|
||||
## What the tests do
|
||||
|
||||
Two levels, both running *inside the built image*:
|
||||
|
||||
- **`just test` (strict)** — the Python suites, the header-only C tests, and
|
||||
a syntax check of every shell script, inside the image. Any failing suite
|
||||
fails the run, and `python3` must import the dnf Qt stack and the locked
|
||||
packages together, so Qt tests can't pass by skipping. This is the real
|
||||
gain over "CI runs pytest on the runner": the tests run in exactly the
|
||||
environment the user receives. What is green is green *in the product*.
|
||||
- **The CI smoke job** — pulls the built image and checks it from the outside:
|
||||
the binaries exist, the venv is intact, programs execute and answer. This
|
||||
catches broken layers, missing files, and architecture mistakes.
|
||||
|
||||
`just lint` (report-only while the pre-existing ruff findings are worked down)
|
||||
is the on-ramp to strict linting later.
|
||||
|
||||
## The runtime on the Frame
|
||||
|
||||
Open. Today the programs that run in a container run in the `dev` distrobox,
|
||||
which is privileged, shares the host's PID, network, and IPC namespaces,
|
||||
mounts `/dev`, `/sys`, `/tmp`, `/run/user/<uid>`, and the home folder, and
|
||||
keeps the user's groups (`run.oci.keep_original_groups`). The programs rely on
|
||||
that:
|
||||
|
||||
| Program | Needs from the host |
|
||||
| --- | --- |
|
||||
| ft-screens | `/dev/dri/renderD128` (GBM), `XDG_RUNTIME_DIR` (its Wayland socket, for KWin on the host) |
|
||||
| ft-powerd | `/dev/input` (use), the backlight in `/sys`, writable through the `video` group, `~/.config/frametop.conf`, `~/.cache/frametop` (the brightness to put back after a crash) |
|
||||
| ft-pointer | `~/.config/frametop.conf`, `/opt/steamvr` (it runs `vrcmd`) |
|
||||
| ft-gaze, ft-gazepanel | `/dev/shm` (SteamVR's `eye-server.mmap`), `/dev/dri` |
|
||||
| Settings apps | the Wayland socket and session bus, `distrobox-host-exec` (they run `systemctl --user` on the host) |
|
||||
| All of them | the host network namespace: they talk over abstract sockets (`@ft_screens`, `@ft_pointer`, ...), and the host's datagram queue length (`net.unix.max_dgram_qlen`, 512 from systemd; a container's own namespace starts at 10, and the input relay's burst of releases then loses its last ones, which leaves buttons held) |
|
||||
|
||||
OpenVR clients need more, found on the device with a containerized ft-powerd
|
||||
(SteamOS 0.4.3, SteamVR 2.18.2): the path registry `~/.config/openvr`, also at
|
||||
the absolute `/home/steamos/...` paths it names; SteamVR's IPC control file in
|
||||
`/tmp` (with a private `/tmp`, `VR_Init` fails with `Init_Internal` 124);
|
||||
`HOME` set explicitly; and no `--user`, since rootless podman maps the
|
||||
container's root to the desktop user and a forced uid breaks that mapping.
|
||||
|
||||
A `podman run` with a hand-picked list of mounts (the wrapper's first
|
||||
`FT_FRAME=1` mode) got ft-powerd connected to SteamVR, but it had no
|
||||
`/dev/dri`, `/dev/input`, writable `/sys`, host groups, config files, or
|
||||
`XDG_RUNTIME_DIR`, so the programs couldn't do their jobs. Two ways give them
|
||||
what the dev container gives them:
|
||||
|
||||
1. **A distrobox created from the pinned image.** The image keeps `sleep
|
||||
infinity` as its command for this. It gets every mount, group, and
|
||||
namespace above with no list to maintain; the units keep `distrobox
|
||||
enter` and point at `/opt/frametop/bin`. An update creates the box again
|
||||
from the new digest, so it holds no state. Its first start runs
|
||||
distrobox's own setup, which once made installs over SSH stop at a sudo
|
||||
prompt (issue #9).
|
||||
2. **Quadlet units** (podman 5.5 on SteamOS ships the generator) with the
|
||||
same flags as distrobox: privileged, host PID, network, and IPC, the same
|
||||
mounts, the user's groups. No distrobox setup step, and systemd tracks the
|
||||
container itself rather than a `podman` client.
|
||||
|
||||
Either way the image adds no isolation (the dev container has none either).
|
||||
What it adds is a pinned environment that was built and tested before it
|
||||
reached the headset. Starting a program costs about the same: on the Frame a
|
||||
`podman run` starts in about 0.23 s, `distrobox enter` in about 0.35 s.
|
||||
Rootless storage lands in `~/.local/share/containers`, shared with Valve's
|
||||
`lepton-*` containers (see README.md).
|
||||
|
||||
## What `install.sh` does in this world
|
||||
|
||||
1. `podman pull` the image by digest, write the reference to
|
||||
`~/.config/frametop/image`
|
||||
2. copy the wrapper to `~/.local/bin/ft` (already installed-mode capable)
|
||||
3. set up the runtime (a distrobox from the image, or Quadlet units) and
|
||||
install the units, pointing at `/opt/frametop`
|
||||
4. the host payload from the same release: SteamVR driver registration
|
||||
(`vrpathreg`), the KWin script, desktop files, and the optional parts that
|
||||
need sudo (ft-camd's capabilities, the eye tracker's frame grabber, the
|
||||
Bluetooth fixes)
|
||||
|
||||
`get.sh` stays the front door, and the FrameDrop package runs the same steps;
|
||||
the difference is that step 1 ships the frozen image instead of building on
|
||||
the device. The image and the host payload come from one tagged commit.
|
||||
|
||||
## Open decisions
|
||||
|
||||
1. **Tagging**: decided — `:latest` is refused by the wrapper (see
|
||||
pack/README.md, "Never :latest"). Releases cut version tags; `ft update`
|
||||
pins the digest of whatever version tag `install.sh` recorded. What
|
||||
remains open is only the cadence: a tag per release vs. per CI build.
|
||||
2. **Registry home**: `ghcr.io/deejanuz/frametop`, published from `main` and
|
||||
`v*` tags only.
|
||||
3. **Pull without auth**: depends on package visibility; install.sh can pin
|
||||
the reference either way.
|
||||
4. **Settings apps**: currently dnf-provided (PySide6/Kirigami) and run via
|
||||
the image. Whether the GUIs migrate toward Flatpak/host packages later is left
|
||||
open deliberately.
|
||||
5. **Host payload distribution**: payload tarball + `get.sh` as artifact
|
||||
installer is the current proposal.
|
||||
6. **Runtime on the Frame**: a distrobox from the image, or Quadlet units
|
||||
(see above). Decided by a headset trial, which also measures the install
|
||||
time against today's on-device build.
|
||||
Executable
+234
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install the Frametop release in this folder: a GitHub release's Frametop.zip, unpacked by
|
||||
# FrameDrop (into ~/devkit-game/Frametop), by hand, or by get.sh --release. Next to this script
|
||||
# are frametop-image.tar (Frametop, built, as a container image) and frametop-release.json (its
|
||||
# version, commit, the image file's sha256 and the image's ID, and the SteamOS table). Nothing
|
||||
# builds on the headset, and nothing else is downloaded.
|
||||
#
|
||||
# 1. It checks this SteamOS build against the SteamOS table: Frametop's newest from GitHub
|
||||
# (pack/steamos.json on main) when it can get it, else the one in the release. Tested: on.
|
||||
# Not tested yet: it says so and asks (--yes goes on). Broken for this release: it stops,
|
||||
# and names the release that fixes it (--any-steamos goes on anyway).
|
||||
# 2. It checks the image file's sha256, and loads it into podman as localhost/frametop:VERSION.
|
||||
# 3. It copies the release's files out of the image to ~/.local/share/frametop/releases/VERSION
|
||||
# and runs their install.sh, which makes the release's container and builds nothing.
|
||||
# 4. The release installed before stays: running its install.sh goes back to it. Older ones
|
||||
# are removed, with their containers and images.
|
||||
#
|
||||
# Usage: install-release.sh [--yes] [--any-steamos] [--unpack-only] [--dir DIR]
|
||||
# [--no-eye-tracker] [--no-bluetooth | --bluetooth]
|
||||
# --unpack-only stop after step 2 and the copy: don't run install.sh
|
||||
# --dir DIR where the releases go (default ~/.local/share/frametop/releases)
|
||||
# The rest go to install.sh. FRAMETOP_STEAMOS_TABLE names another table to fetch (a URL), or
|
||||
# "none" to use only the release's. Exit status 3: the image file is damaged.
|
||||
set -euo pipefail
|
||||
|
||||
here=$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)
|
||||
TABLE_URL=${FRAMETOP_STEAMOS_TABLE:-https://raw.githubusercontent.com/DeeJanuz/frametop/main/pack/steamos.json}
|
||||
|
||||
# check_release RELEASE_JSON TABLE_JSON BUILD_ID: is the release usable, and is it for this
|
||||
# SteamOS build? Prints seven lines: status (tested, untested, or broken), version, commit,
|
||||
# channel, the image file's sha256, the image's ID, and a note for the user.
|
||||
check_release() {
|
||||
python3 - "$@" <<'EOF'
|
||||
import json, re, sys
|
||||
|
||||
release_path, table_path, build = sys.argv[1:4]
|
||||
def fail(msg):
|
||||
print(f"the release's frametop-release.json isn't usable: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
with open(release_path) as f:
|
||||
r = json.load(f)
|
||||
except (OSError, ValueError) as e:
|
||||
fail(str(e))
|
||||
if not isinstance(r, dict) or r.get("schema") != "frametop.release/v1":
|
||||
fail("not frametop.release/v1")
|
||||
image = r.get("image") if isinstance(r.get("image"), dict) else {}
|
||||
fields = {
|
||||
"version": (r.get("version"), r"[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}"),
|
||||
"commit": (r.get("commit"), r"[0-9a-f]{7,40}"),
|
||||
"channel": (r.get("channel", ""), r"(stable|experimental|)"),
|
||||
"image sha256": (image.get("sha256"), r"[0-9a-f]{64}"),
|
||||
"image id": (image.get("id"), r"[0-9a-f]{64}"),
|
||||
}
|
||||
for name, (value, pattern) in fields.items():
|
||||
if not isinstance(value, str) or not re.fullmatch(pattern, value):
|
||||
fail(f"bad {name}: {str(value)[:80]!r}")
|
||||
version = r["version"]
|
||||
|
||||
def base(v):
|
||||
"""0.3.0-exp.1 -> (0, 3, 0): the release it leads up to."""
|
||||
return tuple(int(p) for p in re.findall(r"\d+", str(v).split("-")[0].split("+")[0]))
|
||||
|
||||
table = r.get("steamos")
|
||||
if table_path: # Frametop's newest table, when it could be fetched
|
||||
try:
|
||||
with open(table_path) as f:
|
||||
newer = json.load(f)
|
||||
if isinstance(newer, dict) and isinstance(newer.get("tested"), list):
|
||||
table = newer
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
table = table if isinstance(table, dict) else {}
|
||||
|
||||
def entries(kind):
|
||||
listed = table.get(kind)
|
||||
return [e for e in listed if isinstance(e, dict)] if isinstance(listed, list) else []
|
||||
|
||||
def oneline(s):
|
||||
return " ".join(str(s).split())[:300]
|
||||
|
||||
broken = [b for b in entries("broken") if b.get("build") == build
|
||||
and (not b.get("from") or base(version) >= base(b["from"]))
|
||||
and (not b.get("fixed_in") or base(version) < base(b["fixed_in"]))]
|
||||
tested = [t for t in entries("tested") if t.get("build") == build]
|
||||
if broken:
|
||||
status = "broken"
|
||||
note = oneline(broken[0].get("reason", "it doesn't work on this SteamOS build"))
|
||||
if broken[0].get("fixed_in"):
|
||||
note += f" (fixed in Frametop {oneline(broken[0]['fixed_in'])})"
|
||||
elif tested:
|
||||
status, note = "tested", ""
|
||||
else:
|
||||
status = "untested"
|
||||
others = entries("tested")[-3:]
|
||||
note = ("tested on " + ", ".join(f"SteamOS {e.get('version', '?')} (build {e.get('build', '?')})"
|
||||
for e in others)) if others else "not tested on any SteamOS build yet"
|
||||
for line in (status, version, r["commit"], r["channel"], image["sha256"], image["id"], oneline(note)):
|
||||
print(line)
|
||||
EOF
|
||||
}
|
||||
|
||||
main() {
|
||||
local yes=0 any=0 unpack_only=0 base=$HOME/.local/share/frametop/releases tty=0 answer
|
||||
local pass=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
--yes) yes=1; pass+=("$1") ;;
|
||||
--any-steamos) any=1 ;;
|
||||
--unpack-only) unpack_only=1 ;;
|
||||
--dir) base=${2:?--dir needs a folder}; shift ;;
|
||||
--no-eye-tracker|--no-bluetooth|--bluetooth) pass+=("$1") ;;
|
||||
-h|--help) sed -n '2,23p' "$0"; return 0 ;;
|
||||
*) echo "unknown option: $1" >&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 "Frametop installs on a Steam Frame (SteamOS, VR variant)." >&2
|
||||
return 1
|
||||
fi
|
||||
for f in frametop-release.json frametop-image.tar; do
|
||||
[ -f "$here/$f" ] || { echo "$here/$f is missing: unpack the whole Frametop.zip" >&2; return 1; }
|
||||
done
|
||||
{ : </dev/tty; } 2>/dev/null && tty=1
|
||||
# podman's, even from a terminal in a VR desktop (its session has its own)
|
||||
export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
||||
export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus
|
||||
|
||||
local build osver tmp status version commit channel sha id note
|
||||
build=$(sed -n 's/^BUILD_ID=//p' /etc/os-release | tr -d '"')
|
||||
osver=$(sed -n 's/^VERSION_ID=//p' /etc/os-release | tr -d '"')
|
||||
tmp=$(mktemp)
|
||||
if [ "$TABLE_URL" = none ] || ! curl -fsS --max-time 8 --proto '=https' "$TABLE_URL" -o "$tmp" 2>/dev/null; then
|
||||
: >"$tmp" # offline: the release's own table
|
||||
fi
|
||||
{ read -r status; read -r version; read -r commit; read -r channel; read -r sha; read -r id; read -r note; } \
|
||||
< <(check_release "$here/frametop-release.json" "$([ -s "$tmp" ] && echo "$tmp")" "$build") ||
|
||||
{ rm -f "$tmp"; return 1; }
|
||||
rm -f "$tmp"
|
||||
[ -n "${id:-}" ] || return 1
|
||||
|
||||
case $status in
|
||||
tested) echo "Frametop $version: tested on this SteamOS ($osver, build $build)" ;;
|
||||
untested)
|
||||
echo "Frametop $version hasn't been tested on this SteamOS yet ($osver, build $build); $note."
|
||||
echo "It usually works: SteamOS updates rarely change what Frametop uses. If something's"
|
||||
echo "wrong afterwards, scripts/doctor.sh in the release's folder says what changed."
|
||||
if [ "$yes" = 0 ]; then
|
||||
[ "$tty" = 1 ] || { echo "No terminal to ask in: add --yes to install it anyway." >&2; return 1; }
|
||||
read -r -p "Install it anyway? [Y/n] " answer </dev/tty || answer=
|
||||
[[ ${answer:-y} =~ ^[Yy] ]] || return 1
|
||||
fi ;;
|
||||
broken)
|
||||
echo "Frametop $version doesn't work on this SteamOS ($osver, build $build): $note." >&2
|
||||
if [ "$any" = 0 ]; then
|
||||
echo "Nothing installed. --any-steamos installs it anyway." >&2
|
||||
return 1
|
||||
fi ;;
|
||||
esac
|
||||
|
||||
local image=localhost/frametop:$version box=frametop-${id:0:12} dest=$base/$version
|
||||
if [ -f "$dest/.frametop-release" ] && ! grep -qxF "IMAGE_ID=$id" "$dest/.frametop-release"; then
|
||||
echo "$dest holds another build of Frametop $version. Move it away, then run this again." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '\n\033[1m== 0/10 loading Frametop %s (a minute or two)\033[0m\n' "$version"
|
||||
if [ "$(podman image inspect -f '{{.Id}}' "$image" 2>/dev/null)" = "$id" ]; then
|
||||
echo "already loaded"
|
||||
else
|
||||
local free
|
||||
free=$(df -P -BG "$HOME" | awk 'NR == 2 { sub("G", "", $4); print $4 }')
|
||||
if [ "${free:-0}" -lt 5 ]; then
|
||||
echo "Only ${free:-0} GB free in your home folder; Frametop needs about 4 GB. Free some space first." >&2
|
||||
return 1
|
||||
fi
|
||||
echo "checking the image file"
|
||||
echo "$sha $here/frametop-image.tar" | sha256sum -c --status ||
|
||||
{ echo "frametop-image.tar is damaged (its sha256 doesn't match): download Frametop.zip again" >&2; return 3; }
|
||||
podman load -q -i "$here/frametop-image.tar" >/dev/null
|
||||
podman image exists "$id" ||
|
||||
{ echo "the image file didn't load as the image frametop-release.json names" >&2; return 1; }
|
||||
podman tag "$id" "$image"
|
||||
fi
|
||||
|
||||
if [ ! -f "$dest/.frametop-release" ]; then
|
||||
echo "copying the release's files to $dest"
|
||||
mkdir -p "$base"
|
||||
rm -rf "$dest.new"
|
||||
mkdir "$dest.new"
|
||||
local copied=0
|
||||
podman rm -f "frametop-copy-$$" >/dev/null 2>&1 || true
|
||||
podman create --name "frametop-copy-$$" "$image" true >/dev/null &&
|
||||
podman cp "frametop-copy-$$:/src/frametop/." "$dest.new/" || copied=1
|
||||
podman rm -f "frametop-copy-$$" >/dev/null 2>&1 || true
|
||||
[ "$copied" = 0 ] || { rm -rf "$dest.new"; echo "couldn't copy the release's files out of its image" >&2; return 1; }
|
||||
printf 'VERSION=%s\nCOMMIT=%s\nCHANNEL=%s\nIMAGE=%s\nIMAGE_ID=%s\nBOX=%s\n' \
|
||||
"$version" "$commit" "$channel" "$image" "$id" "$box" >"$dest.new/.frametop-release"
|
||||
mv "$dest.new" "$dest"
|
||||
fi
|
||||
echo "Frametop $version (${commit:0:7}) in $dest"
|
||||
|
||||
if [ "$unpack_only" = 1 ]; then
|
||||
echo "Install with: $dest/install.sh"
|
||||
return 0
|
||||
fi
|
||||
if [ "$tty" = 1 ]; then
|
||||
"$dest/install.sh" ${pass[@]+"${pass[@]}"} </dev/tty
|
||||
else
|
||||
"$dest/install.sh" ${pass[@]+"${pass[@]}"} </dev/null
|
||||
fi
|
||||
|
||||
# This release is now "current"; the one before stays, to go back to. Older ones go, with
|
||||
# their containers and images (only Frametop's: frametop-... and localhost/frametop:...).
|
||||
local prev= d old_image old_box
|
||||
[ -L "$base/current" ] && prev=$(readlink "$base/current")
|
||||
ln -sfn "$version" "$base/current"
|
||||
for d in "$base"/*/; do
|
||||
d=${d%/}
|
||||
[ -L "$d" ] && continue # "current" itself, a link to the release just installed
|
||||
[ -f "$d/.frametop-release" ] || continue
|
||||
case ${d##*/} in "$version"|"$prev") continue ;; esac
|
||||
old_image=$(sed -n 's/^IMAGE=//p' "$d/.frametop-release")
|
||||
old_box=$(sed -n 's/^BOX=//p' "$d/.frametop-release")
|
||||
echo "removing Frametop ${d##*/}, two releases back"
|
||||
if [[ $old_box =~ ^frametop-[A-Za-z0-9_.-]+$ ]]; then podman rm -f "$old_box" >/dev/null 2>&1 || true; fi
|
||||
if [[ $old_image == localhost/frametop:* ]]; then podman rmi "$old_image" >/dev/null 2>&1 || true; fi
|
||||
rm -rf "$d"
|
||||
done
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs on the Frame host, from install.sh in a release (pack/install-release.sh): make the
|
||||
# release's container from its image, as setup/dev-container.sh makes "dev" for a source
|
||||
# install. Both come from .frametop-release: IMAGE (localhost/frametop:VERSION, loaded from the
|
||||
# release's image file already), IMAGE_ID (checked), and BOX, the container's name, which
|
||||
# scripts/in-box runs the release's programs in. Each release gets a container of its own, so
|
||||
# installing one never stops the programs of the one running now; install-release.sh removes
|
||||
# the containers of releases it removes.
|
||||
# Usage: pack/release-box.sh
|
||||
set -euo pipefail
|
||||
tree=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
field() { sed -n "s/^$1=//p" "$tree/.frametop-release" | tail -1; }
|
||||
image=$(field IMAGE)
|
||||
id=$(field IMAGE_ID)
|
||||
box=$(field BOX)
|
||||
[[ $image == localhost/frametop:* ]] && [[ $id =~ ^[0-9a-f]{64}$ ]] && [[ $box =~ ^frametop-[A-Za-z0-9_.-]+$ ]] ||
|
||||
{ echo "$tree/.frametop-release doesn't name a localhost/frametop image, its ID, and a frametop-... container" >&2; exit 1; }
|
||||
export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
||||
[ "$(podman image inspect -f '{{.Id}}' "$image" 2>/dev/null)" = "$id" ] ||
|
||||
{ echo "$image isn't the release's image (load it with the release's install-release.sh)" >&2; exit 1; }
|
||||
if podman container exists "$box"; then
|
||||
echo "the $box container is there already"
|
||||
else
|
||||
echo "creating the $box container"
|
||||
# --no-entry: no "enter this container" entry among the apps (Launch a program lists them).
|
||||
"$HOME/.local/bin/distrobox" create --yes --no-entry --name "$box" --image "$image"
|
||||
fi
|
||||
"$tree/scripts/container-up.sh" "$box"
|
||||
echo "$box ready: $image"
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Write frametop-release.json, for a release's Frametop.zip (framedrop/build.sh --image):
|
||||
the version, the commit, the channel, the image file's sha256, the image's ID (its config's
|
||||
digest: podman's ID for it once loaded), and the SteamOS table, pack/steamos.json.
|
||||
pack/install-release.sh reads it.
|
||||
|
||||
pack/release-info.py --image-file frametop-image.tar --version 0.3.0 --commit SHA \\
|
||||
[--channel stable|experimental] > frametop-release.json
|
||||
|
||||
The image file is an OCI archive (podman save --format oci-archive). The channel defaults to
|
||||
experimental for a version with a "-" (0.3.0-exp.1), else stable.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def image_id(path):
|
||||
"""The config digest of the one image in an OCI archive."""
|
||||
with tarfile.open(path) as tar:
|
||||
def blob(digest):
|
||||
algo, _, hexd = digest.partition(":")
|
||||
return json.load(tar.extractfile(f"blobs/{algo}/{hexd}"))
|
||||
index = json.load(tar.extractfile("index.json"))
|
||||
manifests = index.get("manifests", [])
|
||||
if len(manifests) != 1:
|
||||
raise SystemExit(f"{path}: {len(manifests)} images in it, not one")
|
||||
manifest = blob(manifests[0]["digest"])
|
||||
return manifest["config"]["digest"].partition(":")[2]
|
||||
|
||||
|
||||
def sha256(path):
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
while chunk := f.read(1 << 20):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
ap.add_argument("--image-file", required=True, type=Path)
|
||||
ap.add_argument("--version", required=True)
|
||||
ap.add_argument("--commit", required=True)
|
||||
ap.add_argument("--channel", choices=("stable", "experimental"))
|
||||
ap.add_argument("--steamos", type=Path, default=HERE / "steamos.json")
|
||||
a = ap.parse_args()
|
||||
if not re.fullmatch(r"[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}", a.version):
|
||||
ap.error(f"--version is malformed: {a.version!r}")
|
||||
if not re.fullmatch(r"[0-9a-f]{7,40}", a.commit):
|
||||
ap.error(f"--commit is malformed: {a.commit!r}")
|
||||
table = json.loads(a.steamos.read_text())
|
||||
json.dump({
|
||||
"schema": "frametop.release/v1",
|
||||
"version": a.version,
|
||||
"commit": a.commit,
|
||||
"channel": a.channel or ("experimental" if "-" in a.version else "stable"),
|
||||
"image": {"file": a.image_file.name, "sha256": sha256(a.image_file), "id": image_id(a.image_file)},
|
||||
"steamos": {"tested": table.get("tested", []), "broken": table.get("broken", [])},
|
||||
}, sys.stdout, indent=2)
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,11 @@
|
||||
## Install
|
||||
|
||||
On a Steam Frame (SteamOS, VR variant). Download **Frametop.zip** below (about 1.1 GB), then either:
|
||||
|
||||
- **With [FrameDrop](https://framedropvr.com) on a PC:** drop Frametop.zip on your paired headset. In the headset, open Frametop in your library and press Play.
|
||||
- **On the headset:** unpack Frametop.zip (in the desktop's Dolphin or Konsole) and run `Frametop/frametop-install.sh`.
|
||||
- **In a terminal on the headset:** `curl -fsSL https://deejanuz.github.io/frametop/get.sh | bash -s -- --release`
|
||||
|
||||
A window asks what to install, and your SteamOS password for the two optional parts that need it (our own eye tracker and the Bluetooth fixes); the password is only used for this install. Nothing is compiled on the headset. Restart SteamVR once afterwards.
|
||||
|
||||
Before installing, the installer checks your SteamOS build against the builds Frametop was tested on, and stops if this release is known not to work there.
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"about": "The SteamOS builds Frametop has been tested on, and the ones it's known to break on. Every release's Frametop.zip carries a copy (pack/release-info.py), and pack/install-release.sh checks this SteamOS build against the newest one, this file on main, when it can fetch it. Add a build once Frametop passed the headset tests on it (HEADSET-TESTS.md; scripts/doctor.sh --mark-good prints the versions). A broken build names the release that fixes it (fixed_in), or the first release that needs something the build lacks (from), or both: the releases in between refuse to install on it and name the fix.",
|
||||
"tested": [
|
||||
{
|
||||
"build": "20260922.6101926",
|
||||
"version": "0.3.0",
|
||||
"branch": "stable",
|
||||
"steamvr": "2.17.10",
|
||||
"frametop": "0.2.1",
|
||||
"date": "2026-10-05"
|
||||
}
|
||||
],
|
||||
"broken": []
|
||||
}
|
||||
Executable
+129
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline test of a release's SteamOS check and its frametop-release.json: install-release.sh's
|
||||
check_release (the script without its last line, which would install), on files
|
||||
pack/release-info.py writes from a small made-up OCI archive. No network, podman, or install.
|
||||
|
||||
pack/test/release-test.py
|
||||
"""
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.join(HERE, "..", "..")
|
||||
TMP = tempfile.mkdtemp(prefix="ft-release-test-")
|
||||
SCRIPT = Path(REPO, "pack", "install-release.sh").read_text().rsplit('\nmain "$@"', 1)[0]
|
||||
|
||||
OURS = "20260922.6101926" # tested
|
||||
NEW = "20261007.6180005" # in no table
|
||||
failures = []
|
||||
|
||||
|
||||
def check(label, got, want):
|
||||
ok = got == want
|
||||
print(("ok " if ok else "FAIL ") + label + ("" if ok else f": got {got!r}, want {want!r}"), flush=True)
|
||||
if not ok:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
def write(name, obj):
|
||||
path = os.path.join(TMP, name)
|
||||
with open(path, "w") as f:
|
||||
f.write(obj if isinstance(obj, str) else json.dumps(obj))
|
||||
return path
|
||||
|
||||
|
||||
def oci_archive(path, config=b'{"architecture":"arm64"}'):
|
||||
"""An OCI archive with one image: index.json -> manifest -> config."""
|
||||
def digest(b):
|
||||
return hashlib.sha256(b).hexdigest()
|
||||
manifest = json.dumps({"schemaVersion": 2, "config": {"digest": "sha256:" + digest(config)}, "layers": []}).encode()
|
||||
index = json.dumps({"schemaVersion": 2, "manifests": [{"digest": "sha256:" + digest(manifest)}]}).encode()
|
||||
with tarfile.open(path, "w") as tar:
|
||||
for name, data in (("index.json", index), (f"blobs/sha256/{digest(manifest)}", manifest),
|
||||
(f"blobs/sha256/{digest(config)}", config)):
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = len(data)
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
return digest(config)
|
||||
|
||||
|
||||
def info(version, steamos, *extra):
|
||||
table = write("steamos.json", steamos)
|
||||
r = subprocess.run([sys.executable, os.path.join(REPO, "pack", "release-info.py"), "--image-file", IMAGE,
|
||||
"--version", version, "--commit", "1234567", "--steamos", table, *extra],
|
||||
capture_output=True, text=True)
|
||||
return r.returncode, r.stdout
|
||||
|
||||
|
||||
def pick(release, build, live=""):
|
||||
r = subprocess.run(["bash", "-c", SCRIPT + '\ncheck_release "$@"', "install-release.sh", release, live, build],
|
||||
capture_output=True, text=True)
|
||||
return r.returncode, r.stdout.splitlines()
|
||||
|
||||
|
||||
IMAGE = os.path.join(TMP, "frametop-image.tar")
|
||||
ID = oci_archive(IMAGE)
|
||||
SHA = hashlib.sha256(Path(IMAGE).read_bytes()).hexdigest()
|
||||
tested = [{"build": OURS, "version": "0.3.0", "steamvr": "2.17.10"}]
|
||||
|
||||
code, out = info("0.3.0", {"tested": tested, "broken": []})
|
||||
check("release-info.py writes it", code, 0)
|
||||
rel = json.loads(out)
|
||||
check("with the image file's sha256 and the image's ID", (rel["image"]["sha256"], rel["image"]["id"]), (SHA, ID))
|
||||
check("a version without a \"-\" is stable", rel["channel"], "stable")
|
||||
check("an experimental one", json.loads(info("0.3.0-exp.1", {"tested": tested})[1])["channel"], "experimental")
|
||||
check("release-info.py refuses a malformed version", info("../0.3", {"tested": tested})[0], 2)
|
||||
two = os.path.join(TMP, "two.tar")
|
||||
with tarfile.open(two, "w") as tar:
|
||||
data = json.dumps({"manifests": [{"digest": "sha256:a"}, {"digest": "sha256:b"}]}).encode()
|
||||
t = tarfile.TarInfo("index.json")
|
||||
t.size = len(data)
|
||||
tar.addfile(t, io.BytesIO(data))
|
||||
r = subprocess.run([sys.executable, os.path.join(REPO, "pack", "release-info.py"), "--image-file", two,
|
||||
"--version", "1.0", "--commit", "1234567"], capture_output=True, text=True)
|
||||
check("and an archive with two images", r.returncode != 0, True)
|
||||
|
||||
stable = write("stable.json", out)
|
||||
code, out = pick(stable, OURS)
|
||||
check("a tested build: tested", (code, out[0], out[1]), (0, "tested", "0.3.0"))
|
||||
check("and the image's sha256 and ID come through", out[4:6], [SHA, ID])
|
||||
code, out = pick(stable, NEW)
|
||||
check("a build in no table: untested", out[0], "untested")
|
||||
check("which says where it was tested", out[6], "tested on SteamOS 0.3.0 (build 20260922.6101926)")
|
||||
|
||||
broken = {"tested": tested, "broken": [{"build": NEW, "reason": "the 3D mouse\nhas no laser", "fixed_in": "0.3.1"}]}
|
||||
code, out = pick(write("b.json", info("0.3.0", broken)[1]), NEW)
|
||||
check("a build broken for this release: broken", out[0], "broken")
|
||||
check("with the reason on one line, and the fix", out[6], "the 3D mouse has no laser (fixed in Frametop 0.3.1)")
|
||||
code, out = pick(write("b2.json", info("0.3.1-exp.2", broken)[1]), NEW)
|
||||
check("the release with the fix isn't broken there", out[0], "untested")
|
||||
|
||||
needs = {"tested": tested, "broken": [{"build": OURS, "reason": "needs SteamVR 2.18", "from": "0.4.0"}]}
|
||||
check("a break from a later release doesn't count for this one", pick(write("n1.json", info("0.3.0", needs)[1]), OURS)[1][0],
|
||||
"tested")
|
||||
check("it counts from that release on, over tested", pick(write("n2.json", info("0.4.0", needs)[1]), OURS)[1][0], "broken")
|
||||
|
||||
live = write("live.json", {"tested": tested + [{"build": NEW, "version": "0.5.5"}], "broken": []})
|
||||
check("the newest table from GitHub counts over the release's", pick(stable, NEW, live)[1][0], "tested")
|
||||
check("a table that isn't one is ignored", pick(stable, NEW, write("junk.json", "<html>"))[1][0], "untested")
|
||||
live_broken = write("live-broken.json", {"tested": tested, "broken": [{"build": OURS, "reason": "x", "fixed_in": "0.3.1"}]})
|
||||
check("and it can say a build broke after the release came out", pick(stable, OURS, live_broken)[1][0], "broken")
|
||||
|
||||
good = json.loads(Path(stable).read_text())
|
||||
for label, change in (("a version with a slash", {"version": "../1.0"}),
|
||||
("a commit that isn't hex", {"commit": "$(reboot)"}),
|
||||
("an image ID that isn't one", {"image": {"sha256": SHA, "id": "latest"}}),
|
||||
("no image sha256", {"image": {"id": ID}}),
|
||||
("another schema", {"schema": "frametop.release/v0"})):
|
||||
check(f"a release file with {label} fails", pick(write("bad.json", {**good, **change}), OURS), (1, []))
|
||||
check("a release file that isn't JSON fails", pick(write("notjson.json", "{"), OURS), (1, []))
|
||||
|
||||
print("FAILED: " + ", ".join(failures) if failures else "all passed", flush=True)
|
||||
sys.exit(1 if failures else 0)
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
# The runtime trial (HEADSET-TESTS.md, "Frametop from the image"): switch this Frame's Frametop
|
||||
# to a release, installed from its Frametop.zip, and back to what was there.
|
||||
# pack/trial.sh on DIR # DIR: the unpacked zip's Frametop folder (install-release.sh in it)
|
||||
# pack/trial.sh desktop # restart the VR desktop from the release (ft-screens in its container)
|
||||
# pack/trial.sh status # what runs where
|
||||
# pack/trial.sh off # put it all back
|
||||
# on saves what the release's install.sh replaces to ~/.local/state/frametop-trial/saved: the
|
||||
# frametop-* user units with their drop-ins and .wants links, the launcher and menu entries, the
|
||||
# SteamVR driver's folder and registry, frametop.conf, and the desktop's shortcuts file. It
|
||||
# moves the drop-ins aside (they would override the release's units), runs the release's
|
||||
# install-release.sh --yes --no-eye-tracker --no-bluetooth (no sudo: the installed eye grabber
|
||||
# stays), and restarts SteamVR so it loads the release's driver and services.
|
||||
# off removes those files and puts the saved ones back, stops the release's container, and
|
||||
# restarts SteamVR. The release stays in ~/.local/share/frametop/releases for another try.
|
||||
# Restarting SteamVR closes everything open in VR.
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
|
||||
self=$(readlink -f "$0")
|
||||
state=$HOME/.local/state/frametop-trial
|
||||
saved=$state/saved
|
||||
export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
||||
export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus
|
||||
|
||||
# What install.sh changes (pack/README.md, Releases), relative to the home folder, as globs.
|
||||
paths() {
|
||||
cat <<'EOF'
|
||||
.config/systemd/user/frametop-*.service
|
||||
.config/systemd/user/frametop-*.service.d
|
||||
.config/systemd/user/*.wants/frametop-*
|
||||
.local/share/applications/deckard-nested-desktop.desktop
|
||||
.local/share/applications/native-deckard-nested-desktop.desktop
|
||||
.local/share/applications/ft-*.desktop
|
||||
.local/share/applications/frametop-profile-*.desktop
|
||||
.local/share/frametop/ft_pointer
|
||||
.config/openvr/openvrpaths.vrpath
|
||||
.config/frametop.conf
|
||||
.config/frametop/kglobalshortcutsrc
|
||||
EOF
|
||||
}
|
||||
|
||||
restart_steamvr() {
|
||||
echo "restarting SteamVR"
|
||||
systemctl --user restart steamvr.service
|
||||
for _ in $(seq 60); do
|
||||
systemctl --user -q is-active frametop-pointer.service && break
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
case ${1:-status} in
|
||||
on)
|
||||
src=$(cd "${2:?usage: pack/trial.sh on DIR (the Frametop folder of the unpacked zip)}" && pwd)
|
||||
[ -x "$src/install-release.sh" ] || { echo "$src/install-release.sh isn't there" >&2; exit 1; }
|
||||
[ -e "$saved" ] && { echo "a trial is on already: pack/trial.sh off first" >&2; exit 1; }
|
||||
mkdir -p "$saved"
|
||||
cd "$HOME"
|
||||
while read -r glob; do
|
||||
for p in $glob; do
|
||||
[ -e "$p" ] || [ -L "$p" ] || continue # a plain name stays as is when it's missing
|
||||
cp -a --parents "$p" "$saved/"
|
||||
done
|
||||
done < <(paths)
|
||||
echo "saved $(find "$saved" -mindepth 1 -not -type d | wc -l) files in $saved"
|
||||
rm -rf .config/systemd/user/frametop-*.service.d
|
||||
systemctl --user daemon-reload
|
||||
"$src/install-release.sh" --yes --no-eye-tracker --no-bluetooth
|
||||
restart_steamvr
|
||||
"$self" status ;;
|
||||
desktop)
|
||||
release=$HOME/.local/share/frametop/releases/current
|
||||
[ -e "$saved" ] && [ -x "$release/desktops.sh" ] || { echo "no trial on" >&2; exit 1; }
|
||||
"$release/desktops.sh" restart ;;
|
||||
off)
|
||||
[ -d "$saved" ] || { echo "no trial on (nothing saved in $saved)" >&2; exit 1; }
|
||||
cd "$HOME"
|
||||
while read -r glob; do
|
||||
for p in $glob; do rm -rf "$p"; done
|
||||
done < <(paths)
|
||||
cp -a "$saved/." "$HOME/"
|
||||
systemctl --user daemon-reload
|
||||
restart_steamvr # first: the release's programs run in its container until SteamVR stops them
|
||||
pkill -x ft-screens || true # the VR desktop from the release, if it's still up
|
||||
for box in $(podman ps --format '{{.Names}}' | grep -E '^frametop-[0-9a-f]{12}$' || true); do
|
||||
echo "stopping $box"
|
||||
podman stop -t 5 "$box" >/dev/null || echo "couldn't stop $box (podman stop $box)" >&2
|
||||
done
|
||||
mv "$saved" "$state/restored-$(date +%Y%m%d-%H%M%S)"
|
||||
echo "back as before; restart the VR desktop from Launch a program to leave the release's"
|
||||
"$self" status ;;
|
||||
status)
|
||||
[ -d "$saved" ] && echo "trial: ON (saved in $saved)" || echo "trial: off"
|
||||
for u in frametop-input-relay frametop-pointer frametop-power frametop-gaze frametop-desktop; do
|
||||
printf '%-22s %-9s %s\n' "$u" "$(systemctl --user is-active $u.service)" \
|
||||
"$(systemctl --user show -P ExecStart $u.service | grep -o 'argv\[\]=[^;]*' | cut -c8- | cut -c1-110)"
|
||||
done
|
||||
for name in ft-pointer ft-powerd ft-gaze ft-eyes ft-screens; do
|
||||
if [ "$name" = ft-eyes ]; then pids=$(pgrep -f "[/]ft-eyes " || true); else pids=$(pgrep -x "$name" || true); fi
|
||||
for pid in $pids; do
|
||||
id=$(grep -o 'libpod-[0-9a-f]\{12\}' "/proc/$pid/cgroup" 2>/dev/null | head -1 | cut -c8-) || id=
|
||||
box=host
|
||||
[ -n "$id" ] && box=$(podman ps --filter "id=$id" --format '{{.Names}}' 2>/dev/null || echo "$id")
|
||||
printf '%-11s pid %-7s in %-22s %s\n' "$name" "$pid" "$box" \
|
||||
"$(tr '\0' ' ' </proc/$pid/cmdline 2>/dev/null | grep -o '/home/[^ ]*' | head -1 || true)"
|
||||
done
|
||||
done
|
||||
readlink -f "$HOME/.local/share/frametop/releases/current" 2>/dev/null | sed 's/^/release: /' || true ;;
|
||||
*) sed -n '2,6p' "$0" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -9,9 +9,10 @@ root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
"$root/scripts/sync.sh" >/dev/null
|
||||
exec "$root/scripts/frame.sh" -C pointer/driver 'set -e
|
||||
mkdir -p build
|
||||
. ../../scripts/openvr.sh
|
||||
g++ -std=c++17 -O2 -fPIC -shared -fvisibility=hidden -fno-math-errno -Wall -Wno-unused-parameter \
|
||||
-static-libstdc++ -static-libgcc -Wl,--exclude-libs,ALL \
|
||||
-I/opt/steamvr/tools/hellovr_vulkan_linux/src/openvr/headers \
|
||||
$OPENVR_CFLAGS \
|
||||
-o build/driver_ft_pointer.so driver_ft_pointer.cpp -lpthread
|
||||
max=$(objdump -T build/driver_ft_pointer.so | grep -oE "GLIBC_[0-9.]+" | sort -uV | tail -1)
|
||||
echo "built build/driver_ft_pointer.so, newest glibc symbol: $max"
|
||||
|
||||
@@ -14,6 +14,10 @@ frame="$root/scripts/frame.sh"
|
||||
src=$FRAME_REPO/pointer/driver
|
||||
dest='$HOME/.local/share/frametop/ft_pointer' # expanded on the Frame, in the commands below
|
||||
reg='/opt/steamvr/bin/linuxarm64/vrpathreg'
|
||||
# SteamVR's tools with SteamVR's config folder and libraries. A terminal in the Frametop desktop
|
||||
# has XDG_CONFIG_HOME=~/.config/frametop (session/frametop-session.sh), where vrpathreg finds no
|
||||
# registry, so it made one there that SteamVR never reads.
|
||||
steamvr='env XDG_CONFIG_HOME=$HOME/.config LD_LIBRARY_PATH=/opt/steamvr/bin/linuxarm64' # expanded on the Frame
|
||||
|
||||
case ${1:-install} in
|
||||
install)
|
||||
@@ -21,6 +25,11 @@ case ${1:-install} in
|
||||
# Replacing the files of a driver SteamVR has loaded leaves its input bindings in a bad
|
||||
# state (the 3D mouse no longer gets the laser) until SteamVR restarts, so an unchanged
|
||||
# driver is left alone.
|
||||
# A registry an earlier install made in the desktop's config folder (see steamvr above) has
|
||||
# no SteamVR in it, and hid SteamVR from OpenVR programs started in the desktop
|
||||
# (VRInitError_Init_InstallationNotFound): it goes.
|
||||
# vrpathreg runs 'xdg-open vrmonitor://driverinstalled' for SteamVR's desktop monitor, which
|
||||
# the Frame doesn't have, so the desktop said "could not read file": a stand-in takes it.
|
||||
"$frame" --host "set -e; test -f $src/build/driver_ft_pointer.so
|
||||
rm -rf $dest.new; mkdir -p $dest.new/bin/linuxarm64
|
||||
cp -r $src/ft_pointer/. $dest.new/
|
||||
@@ -31,15 +40,22 @@ else
|
||||
rm -rf $dest; mv $dest.new $dest
|
||||
echo 'installed; restart SteamVR to load it'
|
||||
fi
|
||||
LD_LIBRARY_PATH=/opt/steamvr/bin/linuxarm64 $reg adddriver $dest
|
||||
LD_LIBRARY_PATH=/opt/steamvr/bin/linuxarm64 $reg show | grep -A3 -i 'external'" ;;
|
||||
stray=\$HOME/.config/frametop/openvr/openvrpaths.vrpath
|
||||
if grep -qs '\"jsonid\" : \"vrpathreg\"' \$stray && grep -qsE '\"runtime\"[[:space:]]*:[[:space:]]*null' \$stray; then
|
||||
rm -f \$stray; rmdir \$HOME/.config/frametop/openvr 2>/dev/null || true
|
||||
echo 'removed a SteamVR path registry an earlier install left in ~/.config/frametop/openvr'
|
||||
fi
|
||||
noopen=\$(mktemp -d); trap 'rm -rf \$noopen' EXIT
|
||||
printf '#!/bin/sh\nexit 0\n' > \$noopen/xdg-open; chmod +x \$noopen/xdg-open
|
||||
PATH=\$noopen:\$PATH $steamvr $reg adddriver $dest
|
||||
$steamvr $reg show | grep -A3 -i 'external'" ;;
|
||||
uninstall)
|
||||
"$frame" --host "LD_LIBRARY_PATH=/opt/steamvr/bin/linuxarm64 $reg removedriver $dest; rm -rf $dest; echo 'removed; restart SteamVR to unload it'" ;;
|
||||
"$frame" --host "$steamvr $reg removedriver $dest; rm -rf $dest; echo 'removed; restart SteamVR to unload it'" ;;
|
||||
send)
|
||||
"$frame" --host "python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX,socket.SOCK_DGRAM); s.sendto(sys.argv[1].encode(), \"\\0ft_pointer\")' $(printf %q "${2:?command}")" ;;
|
||||
probe) "$frame" -C pointer/probe 'LD_LIBRARY_PATH=/opt/steamvr/bin/linuxarm64 ./build/vrprobe' ;;
|
||||
probe) "$frame" -C pointer/probe "$steamvr ./build/vrprobe" ;;
|
||||
aimhere)
|
||||
read -r yaw pitch < <("$frame" -C pointer/probe 'LD_LIBRARY_PATH=/opt/steamvr/bin/linuxarm64 ./build/vrprobe' | sed -n 's/^head yaw = \([-0-9.]*\) pitch = \([-0-9.]*\)$/\1 \2/p')
|
||||
read -r yaw pitch < <("$frame" -C pointer/probe "$steamvr ./build/vrprobe" | sed -n 's/^head yaw = \([-0-9.]*\) pitch = \([-0-9.]*\)$/\1 \2/p')
|
||||
[ -n "${yaw:-}" ] || { echo "head pose not valid (headset off?)" >&2; exit 1; }
|
||||
"$0" send "aim $yaw $pitch" && echo "aimed at yaw $yaw pitch $pitch" ;;
|
||||
log) "$frame" --host "grep -iE 'ft_pointer' ~/.local/share/Steam/logs/vrserver.txt | tail -n ${2:-20}" ;;
|
||||
|
||||
@@ -4,6 +4,7 @@ set -euo pipefail
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
"$root/scripts/sync.sh" >/dev/null
|
||||
exec "$root/scripts/frame.sh" -C pointer/helper 'set -e; mkdir -p build
|
||||
g++ -std=c++17 -O2 -Wall -Wno-unused-parameter -I/opt/steamvr/tools/hellovr_vulkan_linux/src/openvr/headers -I../common \
|
||||
-o build/ft-pointer ft-pointer.cpp -L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64 -lpthread
|
||||
. ../../scripts/openvr.sh
|
||||
g++ -std=c++17 -O2 -Wall -Wno-unused-parameter $OPENVR_CFLAGS -I../common \
|
||||
-o build/ft-pointer ft-pointer.cpp $OPENVR_LIBS -lpthread
|
||||
echo "built build/ft-pointer"'
|
||||
@@ -8,13 +8,11 @@ PartOf=steamvr.service
|
||||
Requisite=steamvr.service
|
||||
|
||||
[Service]
|
||||
# Runs in the dev container (built there against its libraries). The helper process
|
||||
# lives in the container, so clean it up explicitly around distrobox enter.
|
||||
# Start the container in a scope of its own first: started by this service (distrobox enter
|
||||
# does that on demand), stopping the service would stop the container and all in it.
|
||||
ExecStartPre=-@REPO@/scripts/container-up.sh
|
||||
# Runs in the container it was built in (the dev container, or a release's: scripts/in-box,
|
||||
# which starts it in a scope of its own, so stopping this service can't stop it). The helper
|
||||
# process lives in the container, so clean it up explicitly around it.
|
||||
ExecStartPre=-/usr/bin/pkill -x ft-pointer
|
||||
ExecStart=%h/.local/bin/distrobox enter dev -- @REPO@/pointer/helper/build/ft-pointer
|
||||
ExecStart=@REPO@/scripts/in-box @REPO@/pointer/helper/build/ft-pointer
|
||||
ExecStopPost=-/usr/bin/pkill -x ft-pointer
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
@@ -479,11 +479,11 @@ std::string ExeDir() {
|
||||
return p.substr(0, p.rfind('/'));
|
||||
}
|
||||
|
||||
// One of ft-screens' panels showing a desktop: a screen (frametop.screen.N), a floating
|
||||
// window (frametop.float.N), or a floating window's popup (frametop.float.N.sub.K), not a
|
||||
// control of theirs.
|
||||
// One of ft-screens' panels showing a desktop: a screen (frametop.screen.N), another
|
||||
// machine's display (frametop.remote.N), a floating window (frametop.float.N), or a floating
|
||||
// window's popup (frametop.float.N.sub.K), not a control of theirs.
|
||||
bool FramePanel(const std::string &key) {
|
||||
for (const char *prefix : {"frametop.screen.", "frametop.float."}) {
|
||||
for (const char *prefix : {"frametop.screen.", "frametop.remote.", "frametop.float."}) {
|
||||
if (key.rfind(prefix, 0) != 0) continue;
|
||||
const std::string rest = key.substr(std::strlen(prefix));
|
||||
const size_t dot = rest.find('.');
|
||||
@@ -1563,8 +1563,19 @@ int main() {
|
||||
// (Moves were taken first, above.)
|
||||
const bool mouseInput = std::strncmp(buf, "btn", 3) == 0 || std::strncmp(buf, "scroll", 6) == 0;
|
||||
if (mouseInput) lastMouse = Clock::now();
|
||||
// Any mouse input wakes the pointer (after a controller took over, or a helper restart).
|
||||
if (!active && mouseInput) wake(Clock::now());
|
||||
// Mouse input wakes the pointer (after a controller took over, or a helper restart),
|
||||
// but a release doesn't (a button up, a scroll back to 0 0): the relay sends those with
|
||||
// the pointer off when Frametop pauses for a game (input-relay.py stand_down), and
|
||||
// waking would connect the virtual controller during the game. The release is still
|
||||
// handled below (it ends its press, or goes to the driver), so no button stays down.
|
||||
{
|
||||
char name[16];
|
||||
int v = 1;
|
||||
double sx = 1, sy = 1;
|
||||
const bool release = (std::sscanf(buf, "btn %15s %d", name, &v) == 2 && v == 0) ||
|
||||
(std::sscanf(buf, "scroll %lf %lf", &sx, &sy) == 2 && sx == 0 && sy == 0);
|
||||
if (!active && mouseInput && !release) wake(Clock::now());
|
||||
}
|
||||
char key[128];
|
||||
double px, py, pz, pyaw, ppitch, proll = 0, pgrab = -1;
|
||||
if (std::sscanf(buf, "grabprobe %127s", key) == 1) {
|
||||
@@ -2394,7 +2405,11 @@ int main() {
|
||||
|
||||
// A held-back press (see the top): held still long enough, it's a real press (a drag);
|
||||
// released, it's a click where the pointer is now (this frame's pose has gone out).
|
||||
if (!active) aimHeld = aimRight = clickPress = aimHand = confirmLesson = false; // released meanwhile: nothing to click
|
||||
// Released meanwhile: nothing to click, and the click it was due (pressRight: a right one) is
|
||||
// forgotten too, or the next press after the pointer wakes would go out as a right click.
|
||||
// A "hide" read a loop after the release is too late: the click has gone out by then
|
||||
// (input-relay.py stand_down).
|
||||
if (!active) aimHeld = aimRight = clickPress = pressRight = aimHand = confirmLesson = false;
|
||||
if (aimHeld && !aimHand && nudgeMoved < 0.2 && tnow - aimSince >= std::chrono::duration<double>(gazeHold)) {
|
||||
aimHeld = false;
|
||||
gazeBack = true;
|
||||
|
||||
+3
-2
@@ -4,6 +4,7 @@ set -euo pipefail
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
"$root/scripts/sync.sh" >/dev/null
|
||||
exec "$root/scripts/frame.sh" -C power 'set -e; mkdir -p build
|
||||
g++ -std=c++17 -O2 -Wall -Wno-unused-parameter -I/opt/steamvr/tools/hellovr_vulkan_linux/src/openvr/headers \
|
||||
-o build/ft-powerd ft-powerd.cpp -L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64
|
||||
. ../scripts/openvr.sh
|
||||
g++ -std=c++17 -O2 -Wall -Wno-unused-parameter $OPENVR_CFLAGS \
|
||||
-o build/ft-powerd ft-powerd.cpp $OPENVR_LIBS
|
||||
echo "built build/ft-powerd"'
|
||||
@@ -7,12 +7,10 @@ After=steamvr.service
|
||||
PartOf=steamvr.service
|
||||
|
||||
[Service]
|
||||
# Runs in the dev container (built there against its libraries). Start the container in a
|
||||
# scope of its own first (see scripts/container-up.sh), and clean up the process explicitly
|
||||
# around distrobox enter.
|
||||
ExecStartPre=-@REPO@/scripts/container-up.sh
|
||||
# Runs in the container it was built in (the dev container, or a release's: scripts/in-box,
|
||||
# which starts it in a scope of its own). Clean up the process explicitly around it.
|
||||
ExecStartPre=-/usr/bin/pkill -x ft-powerd
|
||||
ExecStart=%h/.local/bin/distrobox enter dev -- @REPO@/power/build/ft-powerd
|
||||
ExecStart=@REPO@/scripts/in-box @REPO@/power/build/ft-powerd
|
||||
# SIGTERM makes ft-powerd turn the displays back on before it exits.
|
||||
ExecStopPost=-/usr/bin/pkill -x ft-powerd
|
||||
Restart=on-failure
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
[project]
|
||||
name = "frametop"
|
||||
version = "0.1.0"
|
||||
description = "Desktops in VR on the Steam Frame"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
# Frametop's Python dependencies, locked in uv.lock. The Frame's runtime apps
|
||||
# (input settings uses Kirigami) still come from the container's dnf packages;
|
||||
# these groups cover everything uv can pin: dev tooling and the hand tracker's
|
||||
# NumPy/OpenCV stack (Fedora's python3-opencv pulls in over a gigabyte).
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8",
|
||||
"ruff>=0.14",
|
||||
"pyflakes>=3.4",
|
||||
]
|
||||
# type checking is dev tooling outside the image: the image installs the
|
||||
# default groups (dev for CI's pytest/ruff, tracker for the hand tracker)
|
||||
# but not this one
|
||||
types = [
|
||||
"mypy>=1.19",
|
||||
]
|
||||
tracker = [
|
||||
"numpy>=2.2",
|
||||
"opencv-python-headless>=4.11",
|
||||
"huggingface_hub>=0.34",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# Launch Frametop Remote Displays from a Plasma session on the Frame host.
|
||||
# The app runs in Frametop's container (PySide6 and Kirigami come from Fedora there), as
|
||||
# Display Settings does (see display-settings/ft-display-settings for the buses).
|
||||
here=$(cd "$(dirname "$(readlink -f "$0")")" && pwd)
|
||||
wl=${WAYLAND_DISPLAY:-wayland-0}
|
||||
case $wl in /*) ;; *) wl="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/$wl" ;; esac
|
||||
session_bus=${DBUS_SESSION_BUS_ADDRESS:-}
|
||||
export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
||||
export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus
|
||||
exec "$here/../scripts/in-box" env WAYLAND_DISPLAY="$wl" DISPLAY="${DISPLAY:-}" \
|
||||
XAUTHORITY="${XAUTHORITY:-}" DBUS_SESSION_BUS_ADDRESS="$session_bus" \
|
||||
QT_QPA_PLATFORM="wayland;xcb" \
|
||||
python3 "$here/ft_remote_displays.py" "$@"
|
||||
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Frametop Remote Displays
|
||||
GenericName=Other computers' monitors in VR
|
||||
Comment=Connect to computers running Vibepollo and show their monitors as Frametop screens
|
||||
Exec=@REPO@/remote-displays/ft-remote-displays
|
||||
Icon=network-workgroup
|
||||
Categories=Settings;HardwareSettings;Network;
|
||||
Keywords=remote;display;monitor;moonlight;vibepollo;sunshine;stream;pc;frametop;
|
||||
Executable
+525
@@ -0,0 +1,525 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Frametop Remote Displays: other computers' monitors as Frametop screens, streamed with
|
||||
Moonlight's protocol from Vibepollo (docs/remote-displays.md).
|
||||
|
||||
A Kirigami (QML) app with a Python backend, like Frametop Display Settings. It runs in
|
||||
the dev container:
|
||||
- Hosts: computers running Vibepollo, found on the network (they announce _nvstream._tcp
|
||||
over mDNS; avahi-browse on the host) or typed in. You sign in once with the host's Web UI
|
||||
user name and password: Frametop asks it for an API token that can do only what it needs
|
||||
(SCOPES), keeps that in ~/.local/share/frametop-stream/hosts/ADDRESS.token, readable only
|
||||
by you, and forgets the password. The Web UI's certificate is self-signed, so the one seen
|
||||
then is pinned (ADDRESS.pin, its public key's SHA-256): ft-stream and later sign-ins
|
||||
refuse another. Whether each host answers on its Web UI port is checked now and then.
|
||||
- Displays: a host's existing monitor, or a virtual one at any size, each its own stream
|
||||
(ft-layout remote add pairs a client for it). Each one connects and disconnects on its
|
||||
own, or all of a host's at once; a disconnected one keeps its place and settings.
|
||||
Its stream's resolution, frame rate and bitrate, its width in VR, and whether it shows.
|
||||
- Connection, per host with a Steam Link dongle on the Frame's hotspot (found with it on
|
||||
the network, or with Find): auto (the dongle when it answers, else the network), network
|
||||
only, or dongle only. Find looks for the host among the hotspot's clients (the same
|
||||
<uniqueid> in their serverinfo as at its own address). Hosts without one use the network.
|
||||
Where the displays are in VR is kept like the screens' (move them there; Display Settings'
|
||||
Save as profile keeps it). Anything that touches the streams runs layout/ft-layout on the
|
||||
host; the streams' state comes from ft-screens (@ft_screens: "remotes", "remote N info").
|
||||
Launch with remote-displays/ft-remote-displays (host wrapper).
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
from PySide6.QtCore import Property, QObject, QProcess, Qt, QTimer, QUrl, Signal, Slot
|
||||
from PySide6.QtGui import QGuiApplication, QIcon
|
||||
from PySide6.QtQml import QQmlApplicationEngine
|
||||
from PySide6.QtQuickControls2 import QQuickStyle
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
LAYOUT_DIR = os.path.join(HERE, "..", "layout")
|
||||
sys.path.insert(0, LAYOUT_DIR)
|
||||
import ft_layout # noqa: E402
|
||||
|
||||
FT_LAYOUT = os.path.join(LAYOUT_DIR, "ft-layout")
|
||||
FT_SCREENS = "\0ft_screens"
|
||||
TOKEN_DIR = os.path.expanduser("~/.local/share/frametop-stream/hosts") # each host's API token (ft-stream)
|
||||
WEB_UI_PORT = 47990 # Vibepollo's Web UI, where ft-stream pairs and lists monitors
|
||||
STREAM_RATES = [30, 60, 72, 90, 120]
|
||||
# What Frametop's API token may do: pair its displays' clients (submit their PINs), list the
|
||||
# clients and set their permissions, and list the host's monitors.
|
||||
SCOPES = [{"path": "/api/pin", "methods": ["POST"]}, {"path": "/api/clients/list", "methods": ["GET"]},
|
||||
{"path": "/api/clients/update", "methods": ["POST"]}, {"path": "/api/display-devices", "methods": ["GET"]}]
|
||||
NAME_RE = r"[A-Za-z0-9][A-Za-z0-9 ._-]{0,39}"
|
||||
RESOLUTIONS = [(1920, 1080, ""), (2560, 1440, ""), (3840, 2160, "4K"), (2560, 1080, "ultrawide"),
|
||||
(3440, 1440, "ultrawide"), (5120, 1440, "super ultrawide"), (1920, 1200, "16:10"),
|
||||
(2560, 1600, "16:10"), (1080, 1920, "portrait"), (1440, 2560, "portrait")]
|
||||
|
||||
|
||||
def host_command(*cmd):
|
||||
"""argv to run a command on the SteamOS host (we live in the dev container).
|
||||
|
||||
distrobox-host-exec reaches the host through the user's real session bus; inside the
|
||||
desktop our DBUS_SESSION_BUS_ADDRESS is the nested session's private one, where it
|
||||
fails (exit 127, silently)."""
|
||||
if not shutil.which("distrobox-host-exec"):
|
||||
return list(cmd)
|
||||
bus = f"unix:path=/run/user/{os.getuid()}/bus"
|
||||
return ["env", f"DBUS_SESSION_BUS_ADDRESS={bus}", "distrobox-host-exec"] + list(cmd)
|
||||
|
||||
|
||||
def token_path(address):
|
||||
return os.path.join(TOKEN_DIR, f"{address}.token")
|
||||
|
||||
|
||||
def pin_path(address):
|
||||
return os.path.join(TOKEN_DIR, f"{address}.pin")
|
||||
|
||||
|
||||
def write_private(path, text):
|
||||
os.makedirs(TOKEN_DIR, mode=0o700, exist_ok=True)
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(text + "\n")
|
||||
|
||||
|
||||
def spki_pin(der):
|
||||
"""A certificate's public key pin as curl takes it (CURLOPT_PINNEDPUBLICKEY)."""
|
||||
pem = subprocess.run(["openssl", "x509", "-inform", "der", "-pubkey", "-noout"], input=der,
|
||||
capture_output=True, check=True).stdout
|
||||
key = subprocess.run(["openssl", "pkey", "-pubin", "-outform", "der"], input=pem,
|
||||
capture_output=True, check=True).stdout
|
||||
return "sha256//" + base64.b64encode(hashlib.sha256(key).digest()).decode()
|
||||
|
||||
|
||||
def parse_avahi(text):
|
||||
"""avahi-browse -rpt _nvstream._tcp -> [{name, address, dongle}]: a host's address on the
|
||||
network, and on the Frame's hotspot (wlanap: its Steam Link dongle), if it's there."""
|
||||
unescape = lambda v: re.sub(r"\\(\d{3})", lambda m: chr(int(m.group(1))), v).replace("\\.", ".")
|
||||
found = {}
|
||||
for line in text.splitlines():
|
||||
f = line.split(";")
|
||||
if len(f) < 9 or f[0] != "=" or f[2] != "IPv4" or f[1].startswith("tailscale"):
|
||||
continue
|
||||
name = unescape(f[3])
|
||||
h = found.setdefault(name, {"name": name, "address": "", "dongle": ""})
|
||||
key = "dongle" if f[1] == "wlanap" else "address"
|
||||
h[key] = h[key] or f[7]
|
||||
out = []
|
||||
for h in found.values():
|
||||
if not h["address"]: # only on the hotspot
|
||||
h["address"], h["dongle"] = h["dongle"], ""
|
||||
out.append(h)
|
||||
return sorted(out, key=lambda h: h["name"].lower())
|
||||
|
||||
|
||||
class Backend(QObject):
|
||||
changed = Signal()
|
||||
busyChanged = Signal()
|
||||
message = Signal(str, bool) # text, is error
|
||||
monitorsReady = Signal(str, "QVariantList", str) # a host's address, its monitors, an error
|
||||
dongleFound = Signal(str, str, str) # a host's name, its dongle's address ("" none), what happened
|
||||
hostsFound = Signal("QVariantList") # computers on the network: [{name, address, dongle, added}]
|
||||
signedIn = Signal(str, bool, str) # a host's name, whether it worked, what went wrong
|
||||
_signInDone = Signal(str, str, str, str, str, str) # name, address, dongle, token, pin, error
|
||||
_reached = Signal(str, bool) # a host's address, whether its Web UI answered
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
self._sock.bind("") # an abstract address ft-screens can reply to
|
||||
self._sock.settimeout(1.0)
|
||||
self._running = False
|
||||
self._states = {} # remote screen number -> its stream's state ("lost can't connect")
|
||||
self._online = {} # host address -> True/False (None: not checked yet)
|
||||
self._queue = [] # ft-layout runs waiting their turn: (label, args)
|
||||
self._proc = None
|
||||
self._busy = ""
|
||||
self._reached.connect(self._host_reached, Qt.QueuedConnection)
|
||||
self._signInDone.connect(self._sign_in_done, Qt.QueuedConnection)
|
||||
self.poll = QTimer(interval=2000, timeout=self._check)
|
||||
self.poll.start()
|
||||
self.ping = QTimer(interval=15000, timeout=self._ping_hosts)
|
||||
self.ping.start()
|
||||
self._check()
|
||||
self._ping_hosts()
|
||||
|
||||
# --- state ---
|
||||
def _ask(self, text):
|
||||
"""Request/reply to ft-screens; None if it isn't running."""
|
||||
try:
|
||||
self._sock.sendto(text.encode(), FT_SCREENS)
|
||||
return self._sock.recv(4096).decode()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
def _check(self):
|
||||
running = os.path.exists(f"/run/user/{os.getuid()}/frametop/wayland-0")
|
||||
states = {}
|
||||
reply = self._ask("remotes") if running else None
|
||||
if reply and reply.startswith("ok"):
|
||||
for e in reply.split()[2:]:
|
||||
n, state = int(e.split(":")[0]), e.split(":")[2]
|
||||
if state in ("lost", "live"): # why ("lost can't connect"), or which way ("live via dongle")
|
||||
info = self._ask(f"remote {n} info") or ""
|
||||
state = info[3:].strip() if info.startswith("ok ") else state
|
||||
states[n] = state
|
||||
if running != self._running or states != self._states:
|
||||
self._running, self._states = running, states
|
||||
self.changed.emit()
|
||||
|
||||
def _ping_hosts(self):
|
||||
for h in ft_layout.load_layout().get("hosts", []):
|
||||
address = h.get("address", "")
|
||||
if address:
|
||||
threading.Thread(target=self._ping, args=(address,), daemon=True).start()
|
||||
|
||||
def _ping(self, address):
|
||||
try:
|
||||
with socket.create_connection((address, WEB_UI_PORT), timeout=2):
|
||||
ok = True
|
||||
except OSError:
|
||||
ok = False
|
||||
self._reached.emit(address, ok)
|
||||
|
||||
def _host_reached(self, address, ok):
|
||||
if self._online.get(address) != ok:
|
||||
self._online[address] = ok
|
||||
self.changed.emit()
|
||||
|
||||
@Property(bool, notify=changed)
|
||||
def desktopRunning(self):
|
||||
return self._running
|
||||
|
||||
@Property(str, notify=busyChanged)
|
||||
def busy(self):
|
||||
return self._busy
|
||||
|
||||
@Property("QVariantList", constant=True)
|
||||
def streamRates(self):
|
||||
return STREAM_RATES
|
||||
|
||||
@Property("QVariantList", constant=True)
|
||||
def resolutions(self):
|
||||
return [{"text": f"{w} × {h}" + (f" ({t})" if t else ""), "width": w, "height": h} for w, h, t in RESOLUTIONS]
|
||||
|
||||
@Property("QVariantList", notify=changed)
|
||||
def hosts(self):
|
||||
"""Each host, whether it answers, and its displays with their streams' state."""
|
||||
layout = ft_layout.load_layout()
|
||||
numbers = {d["id"]: n for n, _, d in ft_layout.remote_displays(layout)}
|
||||
out = []
|
||||
for h in layout.get("hosts", []):
|
||||
displays = []
|
||||
for d in h.get("displays", []):
|
||||
n = numbers.get(d.get("id"), 0)
|
||||
w, hh = d.get("size", [2560, 1440])
|
||||
if d.get("off"):
|
||||
state = "disconnected"
|
||||
elif not self._running:
|
||||
state = "desktop off"
|
||||
else:
|
||||
state = self._states.get(n, "not running")
|
||||
displays.append({"id": d["id"], "number": n, "label": d.get("label") or d["id"], "app": d["app"],
|
||||
"virtual": d["app"] == "monitor", "width": int(w), "height": int(hh),
|
||||
"fps": int(d.get("fps", 60)), "bitrate": int(d.get("bitrate", 0)),
|
||||
"metres": ft_layout.remote_size(d)[0], "shown": not d.get("hidden"),
|
||||
"connected": not d.get("off"), "state": state})
|
||||
address = h.get("address", "")
|
||||
out.append({"name": h.get("name", ""), "address": address, "hasToken": os.path.exists(token_path(address)),
|
||||
"online": self._online.get(address), "route": h.get("route", "auto"),
|
||||
"direct": ", ".join(h.get("direct", [])), "displays": displays})
|
||||
return out
|
||||
|
||||
# --- hosts ---
|
||||
@Slot()
|
||||
def discoverHosts(self):
|
||||
"""Vibepollo (and Sunshine) computers on the network (answer: hostsFound)."""
|
||||
def work():
|
||||
try:
|
||||
r = subprocess.run(host_command("avahi-browse", "-rpt", "_nvstream._tcp"), capture_output=True,
|
||||
text=True, timeout=15)
|
||||
found = parse_avahi(r.stdout)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
found = []
|
||||
known = {h.get("address") for h in ft_layout.load_layout().get("hosts", [])}
|
||||
for h in found:
|
||||
h["added"] = h["address"] in known
|
||||
self.hostsFound.emit(found)
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
@Slot(str, str, str, str, str)
|
||||
def signIn(self, name, address, dongle, user, password):
|
||||
"""Asks the host for Frametop's API token with its Web UI login (answer: signedIn).
|
||||
Over its dongle when it has one that answers, so the password skips the router."""
|
||||
name, address, dongle = " ".join(name.split()), address.strip(), dongle.strip()
|
||||
if not re.fullmatch(NAME_RE, name):
|
||||
return self.signedIn.emit(name, False, "A host's name needs 1 to 40 letters, digits, spaces, dots or dashes")
|
||||
if not re.fullmatch(r"[A-Za-z0-9.:-]{1,64}", address) or (dongle and not re.fullmatch(r"[0-9.]{7,15}", dongle)):
|
||||
return self.signedIn.emit(name, False, "The address is a host name or an IP address")
|
||||
layout = ft_layout.load_layout()
|
||||
if any(h.get("name") == name and h.get("address") != address for h in layout.get("hosts", [])):
|
||||
return self.signedIn.emit(name, False, f"There's already a host called {name}")
|
||||
|
||||
def work():
|
||||
known = None
|
||||
try:
|
||||
with open(pin_path(address)) as f:
|
||||
known = f.read().strip() or None
|
||||
except OSError:
|
||||
pass
|
||||
error = ""
|
||||
for target in ([dongle] if dongle else []) + [address]:
|
||||
try:
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname, ctx.verify_mode = False, ssl.CERT_NONE # self-signed: pinned below
|
||||
conn = http.client.HTTPSConnection(target, WEB_UI_PORT, timeout=15, context=ctx)
|
||||
conn.connect()
|
||||
pin = spki_pin(conn.sock.getpeercert(binary_form=True))
|
||||
if known and pin != known:
|
||||
conn.close()
|
||||
return self._signInDone.emit(name, address, dongle, "", "", "Its certificate isn't the one "
|
||||
"seen when it was added. If Vibepollo was installed again, "
|
||||
"remove the host and add it again.")
|
||||
basic = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
conn.request("POST", "/api/token", json.dumps({"scopes": SCOPES}),
|
||||
{"Authorization": "Basic " + basic, "Content-Type": "application/json"})
|
||||
r = conn.getresponse()
|
||||
text = r.read().decode(errors="replace")
|
||||
conn.close()
|
||||
if r.status == 401:
|
||||
return self._signInDone.emit(name, address, dongle, "", "", "Wrong user name or password")
|
||||
token = json.loads(text).get("token") if r.status == 200 else None
|
||||
if not token:
|
||||
return self._signInDone.emit(name, address, dongle, "", "",
|
||||
f"It didn't make a token ({r.status} {text[:120]})")
|
||||
return self._signInDone.emit(name, address, dongle, token, pin, "")
|
||||
except (OSError, ValueError, ssl.SSLError, http.client.HTTPException,
|
||||
subprocess.SubprocessError) as e:
|
||||
error = str(e) or type(e).__name__
|
||||
self._signInDone.emit(name, address, dongle, "", "", f"Couldn't reach it: {error}")
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _sign_in_done(self, name, address, dongle, token, pin, error):
|
||||
if error:
|
||||
return self.signedIn.emit(name, False, error)
|
||||
write_private(token_path(address), token)
|
||||
write_private(pin_path(address), pin)
|
||||
layout = ft_layout.load_layout()
|
||||
host = next((h for h in layout.setdefault("hosts", []) if h.get("address") == address), None)
|
||||
if host is None:
|
||||
host = {"name": name, "address": address, "displays": []}
|
||||
layout["hosts"].append(host)
|
||||
if dongle and dongle not in host.get("direct", []):
|
||||
host["direct"] = [dongle] + host.get("direct", [])
|
||||
ft_layout.save_layout(layout)
|
||||
self.changed.emit()
|
||||
threading.Thread(target=self._ping, args=(address,), daemon=True).start()
|
||||
self.signedIn.emit(host["name"], True, "")
|
||||
|
||||
@Slot(str)
|
||||
def removeHost(self, name):
|
||||
layout = ft_layout.load_layout()
|
||||
host = next((h for h in layout.get("hosts", []) if h.get("name") == name), None)
|
||||
if host is None:
|
||||
return
|
||||
if host.get("displays"):
|
||||
return self.message.emit("Remove its displays first", True)
|
||||
layout["hosts"].remove(host)
|
||||
ft_layout.save_layout(layout)
|
||||
if not any(h.get("address") == host.get("address") for h in layout["hosts"]):
|
||||
for path in (token_path(host.get("address", "")), pin_path(host.get("address", ""))):
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
self.changed.emit()
|
||||
|
||||
@Slot(str, bool)
|
||||
def setHostConnected(self, name, on):
|
||||
"""All of a host's displays at once."""
|
||||
layout = ft_layout.load_layout()
|
||||
host = next((h for h in layout.get("hosts", []) if h.get("name") == name), None)
|
||||
ids = [d["id"] for d in (host or {}).get("displays", []) if bool(d.get("off")) == on]
|
||||
if ids:
|
||||
self._run(f"{'Connecting' if on else 'Disconnecting'} {name}", "remote", "connect" if on else "disconnect", *ids)
|
||||
|
||||
@Slot(str, str)
|
||||
def setRoute(self, name, route):
|
||||
self._run(f"Connecting {name} {dict(auto='either way', network='over the network', dongle='over the dongle')[route]}",
|
||||
"remote", "host", name, f"route={route}")
|
||||
|
||||
@Slot(str, str)
|
||||
def setDirect(self, name, text):
|
||||
addresses = [a for a in re.split(r"[\s,]+", text.strip()) if a]
|
||||
if any(not re.fullmatch(r"[A-Za-z0-9.:-]{1,64}", a) for a in addresses):
|
||||
return self.message.emit("The dongle's address is an IP address", True)
|
||||
self._run(f"Setting {name}'s dongle", "remote", "host", name, "direct=" + (",".join(addresses) or "none"))
|
||||
|
||||
@staticmethod
|
||||
def _host_id(address):
|
||||
"""A host's <uniqueid> from its serverinfo (Vibepollo can take seconds to answer)."""
|
||||
url = f"http://{address}:{47989}/serverinfo?uniqueid=0123456789ABCDEF&uuid={uuid.uuid4()}"
|
||||
with urllib.request.urlopen(url, timeout=12) as r:
|
||||
m = re.search(r"<uniqueid>([^<]+)</uniqueid>", r.read().decode(errors="replace"))
|
||||
return m.group(1) if m else None
|
||||
|
||||
@Slot(str)
|
||||
def findDongle(self, name):
|
||||
"""The host among the Frame hotspot's clients (answer: dongleFound)."""
|
||||
host = next((h for h in ft_layout.load_layout().get("hosts", []) if h.get("name") == name), None)
|
||||
if host is None:
|
||||
return
|
||||
|
||||
def work():
|
||||
try:
|
||||
want = self._host_id(host["address"])
|
||||
if not want:
|
||||
return self.dongleFound.emit(name, "", "it didn't say who it is at its own address")
|
||||
with open("/proc/net/arp") as f: # IP, HW type, flags, MAC, mask, device
|
||||
rows = [line.split() for line in f.read().splitlines()[1:]]
|
||||
candidates = [r[0] for r in rows if len(r) >= 6 and r[5] == "wlanap" and r[2] != "0x0"]
|
||||
for ip in candidates:
|
||||
try:
|
||||
if self._host_id(ip) == want:
|
||||
return self.dongleFound.emit(name, ip, "")
|
||||
except OSError:
|
||||
continue
|
||||
self.dongleFound.emit(name, "", "it isn't on the Frame's hotspot" if candidates else
|
||||
"nothing is on the Frame's hotspot (is Steam Link's dongle paired?)")
|
||||
except OSError as e:
|
||||
self.dongleFound.emit(name, "", str(e))
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
# --- displays ---
|
||||
@Slot(str)
|
||||
def listMonitors(self, address):
|
||||
"""The host's monitors, from its Web UI API (answer: monitorsReady)."""
|
||||
def work():
|
||||
monitors, error = [], ""
|
||||
try:
|
||||
code, out, err = ft_layout.run_stream("monitors", address, timeout=30)
|
||||
if code:
|
||||
error = (err or out).strip().splitlines()[-1] if (err or out).strip() else f"exit {code}"
|
||||
else:
|
||||
for m in json.loads(out[out.index("["):]):
|
||||
info = m.get("info") or {}
|
||||
res = info.get("resolution") or {}
|
||||
virtual = (m.get("edid") or {}).get("manufacturer_id") == "SDD" or \
|
||||
str(m.get("friendly_name", "")).startswith("frametop")
|
||||
monitors.append({"app": "display:" + m.get("device_id", ""),
|
||||
"name": m.get("friendly_name") or m.get("display_name") or "?",
|
||||
"width": int(res.get("width", 0)), "height": int(res.get("height", 0)),
|
||||
"primary": bool(info.get("primary")), "active": bool(info),
|
||||
"virtual": virtual})
|
||||
except Exception as e: # noqa: BLE001 (shown to the user, not raised in a thread)
|
||||
error = str(e)
|
||||
self.monitorsReady.emit(address, monitors, error)
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
@Slot(str, str, str, int, int, int, int)
|
||||
def addDisplay(self, host, app, label, width, height, fps, bitrate):
|
||||
"""Pairs a client for it with the host's token and starts its stream (ft-layout)."""
|
||||
layout = ft_layout.load_layout()
|
||||
h = next((x for x in layout.get("hosts", []) if x.get("name") == host), None)
|
||||
if h is None:
|
||||
return self.message.emit(f"No host called {host}", True)
|
||||
label = " ".join(label.split()) or ("Virtual" if app == "monitor" else "Display")
|
||||
self._run(f"Adding {label} from {host}", "remote", "add", host, h["address"], app, "--label", label,
|
||||
"--size", f"{width}x{height}", "--fps", str(fps), "--bitrate", str(bitrate))
|
||||
|
||||
@Slot(str, bool)
|
||||
def setConnected(self, display_id, on):
|
||||
self._run("Connecting" if on else "Disconnecting", "remote", "connect" if on else "disconnect", display_id)
|
||||
|
||||
@Slot(str, int, int, int, int)
|
||||
def setStream(self, display_id, width, height, fps, bitrate):
|
||||
"""Its stream's resolution, frame rate and bitrate (it starts over)."""
|
||||
self._run("Changing the stream", "remote", "set", display_id, f"size={width}x{height}", f"fps={fps}",
|
||||
f"bitrate={bitrate}")
|
||||
|
||||
def _edit(self, display_id, fn):
|
||||
layout = ft_layout.load_layout()
|
||||
n, _, d = ft_layout.find_display(layout, display_id)
|
||||
fn(d)
|
||||
ft_layout.save_layout(layout)
|
||||
self.changed.emit()
|
||||
return n, d
|
||||
|
||||
@Slot(str, float)
|
||||
def setMetres(self, display_id, m):
|
||||
n, d = self._edit(display_id, lambda d: d.__setitem__("metres", round(m, 3)))
|
||||
if self._running and not d.get("off"):
|
||||
self._ask(f"width {n} {m:.3f}")
|
||||
|
||||
@Slot(str, bool)
|
||||
def setShown(self, display_id, shown):
|
||||
n, d = self._edit(display_id, lambda d: d.pop("hidden", None) if shown else d.__setitem__("hidden", True))
|
||||
if self._running and not d.get("off"):
|
||||
self._ask(f"{'reveal' if shown else 'conceal'} {n}")
|
||||
|
||||
@Slot(str)
|
||||
def removeDisplay(self, display_id):
|
||||
self._run("Removing the display", "remote", "remove", display_id)
|
||||
|
||||
# --- ft-layout on the host, one at a time ---
|
||||
def _run(self, label, *args):
|
||||
self._queue.append((label, args))
|
||||
if self._proc is None:
|
||||
self._next()
|
||||
|
||||
def _next(self):
|
||||
if not self._queue:
|
||||
return
|
||||
label, args = self._queue.pop(0)
|
||||
self._busy = label
|
||||
self.busyChanged.emit()
|
||||
proc = QProcess(self)
|
||||
proc.setProcessChannelMode(QProcess.MergedChannels)
|
||||
argv = host_command(os.path.abspath(FT_LAYOUT), *args)
|
||||
proc.finished.connect(lambda code, _status: self._done(proc, label, code))
|
||||
self._proc = proc
|
||||
proc.start(argv[0], argv[1:])
|
||||
|
||||
def _done(self, proc, label, code):
|
||||
out = bytes(proc.readAllStandardOutput()).decode(errors="replace").strip()
|
||||
self._proc = None
|
||||
self._busy = ""
|
||||
self.busyChanged.emit()
|
||||
self.changed.emit()
|
||||
self._check()
|
||||
last = out.splitlines()[-1] if out else ""
|
||||
if code == 0:
|
||||
self.message.emit(f"{label}: done" + (f" ({last})" if last and not last.startswith("ok") else ""), False)
|
||||
else:
|
||||
self.message.emit(f"{label} failed: {last or 'exit code ' + str(code)}", True)
|
||||
self._next()
|
||||
|
||||
|
||||
def main():
|
||||
app = QGuiApplication(sys.argv)
|
||||
app.setApplicationName("ft-remote-displays")
|
||||
app.setApplicationDisplayName("Frametop Remote Displays")
|
||||
app.setDesktopFileName("ft-remote-displays")
|
||||
if not QIcon.themeName():
|
||||
QIcon.setThemeName("breeze")
|
||||
QQuickStyle.setStyle("org.kde.desktop")
|
||||
engine = QQmlApplicationEngine()
|
||||
backend = Backend()
|
||||
engine.rootContext().setContextProperty("backend", backend)
|
||||
engine.load(QUrl.fromLocalFile(os.path.join(HERE, "main.qml")))
|
||||
if not engine.rootObjects():
|
||||
sys.exit(1)
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install (or remove) Frametop Remote Displays' menu entry on the Frame.
|
||||
# Usage: remote-displays/install.sh [install|uninstall]
|
||||
set -euo pipefail
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
. "$root/scripts/_env.sh"
|
||||
"$root/scripts/sync.sh" >/dev/null
|
||||
apps=.local/share/applications
|
||||
case ${1:-install} in
|
||||
install)
|
||||
fill_template "$root/remote-displays/ft-remote-displays.desktop" | on_frame "mkdir -p ~/$apps && cat > ~/$apps/ft-remote-displays.desktop"
|
||||
on_frame "chmod +x remote-displays/ft-remote-displays"
|
||||
echo "installed: Frametop Remote Displays" ;;
|
||||
uninstall)
|
||||
on_frame "rm -f ~/$apps/ft-remote-displays.desktop; echo removed" ;;
|
||||
*) echo "usage: $0 [install|uninstall]" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -0,0 +1,575 @@
|
||||
// Frametop Remote Displays (Kirigami). Backend: ft_remote_displays.py ("backend").
|
||||
import QtQuick
|
||||
import QtQuick.Controls as Controls
|
||||
import QtQuick.Layouts
|
||||
import org.kde.kirigami as Kirigami
|
||||
|
||||
Kirigami.ApplicationWindow {
|
||||
id: root
|
||||
title: "Frametop Remote Displays"
|
||||
width: Kirigami.Units.gridUnit * 42
|
||||
height: Kirigami.Units.gridUnit * 34
|
||||
|
||||
pageStack.initialPage: hostsPage
|
||||
|
||||
Connections {
|
||||
target: backend
|
||||
function onMessage(text, isError) {
|
||||
root.showPassiveNotification(text, isError ? "long" : "short")
|
||||
}
|
||||
}
|
||||
|
||||
// What a stream is doing, in words (ft-screens' state, or ours for a disconnected one).
|
||||
function stateText(state) {
|
||||
const words = { live: "streaming", connecting: "connecting", starting: "starting", queued: "waiting its turn",
|
||||
lost: "lost, trying again", disconnected: "disconnected",
|
||||
"desktop off": "connects when the Frametop desktop starts", "not running": "not running" }
|
||||
if (state in words) return words[state]
|
||||
if (state.startsWith("live via ")) return "streaming over the " + state.slice(9)
|
||||
if (state.startsWith("lost ")) return "lost (" + state.slice(5) + "), trying again"
|
||||
return state
|
||||
}
|
||||
function stateColor(state) {
|
||||
if (state.startsWith("live")) return Kirigami.Theme.positiveTextColor
|
||||
if (state.startsWith("lost")) return Kirigami.Theme.negativeTextColor
|
||||
if (state === "connecting" || state === "starting" || state === "queued") return Kirigami.Theme.neutralTextColor
|
||||
return Kirigami.Theme.disabledTextColor
|
||||
}
|
||||
|
||||
footer: Controls.ToolBar {
|
||||
visible: backend.busy !== ""
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
Controls.BusyIndicator { running: backend.busy !== ""; Layout.preferredHeight: Kirigami.Units.iconSizes.medium }
|
||||
Controls.Label { text: backend.busy + "…"; Layout.fillWidth: true }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- dialogs
|
||||
// A host: picked from the computers found on the network (or typed in), then a sign-in
|
||||
// with its Vibepollo Web UI login, for Frametop's own API token. Also signs in again to
|
||||
// one already added (openFor).
|
||||
Kirigami.PromptDialog {
|
||||
id: hostDialog
|
||||
objectName: "hostDialog"
|
||||
title: existing ? "Sign in to " + fixedName : "Add a computer"
|
||||
standardButtons: Kirigami.Dialog.NoButton
|
||||
property bool existing: false
|
||||
property string fixedName: ""
|
||||
property string fixedAddress: ""
|
||||
property string fixedDongle: ""
|
||||
property var found: []
|
||||
property bool searching: false
|
||||
property bool working: false
|
||||
property string error: ""
|
||||
property int picked: -1 // index in found; found.length: typed in
|
||||
readonly property bool typed: picked === found.length
|
||||
readonly property string pickedName: existing ? fixedName
|
||||
: typed ? (otherName.text.trim() || otherAddress.text.trim()) : picked >= 0 ? found[picked].name : ""
|
||||
readonly property string pickedAddress: existing ? fixedAddress
|
||||
: typed ? otherAddress.text.trim() : picked >= 0 ? found[picked].address : ""
|
||||
readonly property string pickedDongle: existing ? fixedDongle : !typed && picked >= 0 ? found[picked].dongle : ""
|
||||
readonly property bool ok: pickedName !== "" && pickedAddress !== "" && user.text !== "" && password.text !== "" && !working
|
||||
function look() {
|
||||
searching = true
|
||||
backend.discoverHosts()
|
||||
}
|
||||
function openNew() {
|
||||
existing = false; found = []; picked = -1; error = ""; working = false
|
||||
otherName.text = ""; otherAddress.text = ""; password.text = ""
|
||||
open()
|
||||
look()
|
||||
}
|
||||
function openFor(h) {
|
||||
existing = true; fixedName = h.name; fixedAddress = h.address; fixedDongle = h.direct.split(",")[0].trim()
|
||||
error = ""; working = false; password.text = ""
|
||||
open()
|
||||
user.forceActiveFocus()
|
||||
}
|
||||
function accept() {
|
||||
if (!ok) return
|
||||
working = true; error = ""
|
||||
backend.signIn(pickedName, pickedAddress, pickedDongle, user.text, password.text)
|
||||
}
|
||||
Connections {
|
||||
target: backend
|
||||
function onHostsFound(list) {
|
||||
hostDialog.searching = false
|
||||
hostDialog.found = list
|
||||
if (hostDialog.picked < 0 || hostDialog.picked > list.length) {
|
||||
const free = list.findIndex(h => !h.added)
|
||||
hostDialog.picked = free >= 0 ? free : list.length
|
||||
}
|
||||
}
|
||||
function onSignedIn(name, ok, why) {
|
||||
if (!hostDialog.working) return
|
||||
hostDialog.working = false
|
||||
if (!ok) {
|
||||
hostDialog.error = why
|
||||
return
|
||||
}
|
||||
password.text = ""
|
||||
hostDialog.close()
|
||||
root.showPassiveNotification("Signed in to " + name)
|
||||
const h = backend.hosts.find(x => x.name === name)
|
||||
if (h && !hostDialog.existing) displayDialog.openFor(h)
|
||||
}
|
||||
}
|
||||
ColumnLayout {
|
||||
RowLayout {
|
||||
visible: !hostDialog.existing
|
||||
Controls.Label { text: "Computers running Vibepollo on your network:"; Layout.fillWidth: true }
|
||||
Controls.ToolButton {
|
||||
icon.name: "view-refresh"
|
||||
text: "Look again"
|
||||
display: Controls.AbstractButton.IconOnly
|
||||
enabled: !hostDialog.searching
|
||||
onClicked: hostDialog.look()
|
||||
Controls.ToolTip.text: text
|
||||
Controls.ToolTip.visible: hovered
|
||||
}
|
||||
}
|
||||
Controls.BusyIndicator { visible: hostDialog.searching; running: visible; Layout.alignment: Qt.AlignHCenter }
|
||||
Repeater {
|
||||
model: hostDialog.existing ? [] : hostDialog.found
|
||||
Controls.RadioButton {
|
||||
required property var modelData
|
||||
required property int index
|
||||
enabled: !modelData.added
|
||||
text: modelData.name + " (" + modelData.address + (modelData.dongle ? ", and on the dongle" : "") + ")"
|
||||
+ (modelData.added ? ": added" : "")
|
||||
checked: hostDialog.picked === index
|
||||
onToggled: if (checked) hostDialog.picked = index
|
||||
}
|
||||
}
|
||||
Controls.RadioButton {
|
||||
visible: !hostDialog.existing && !hostDialog.searching
|
||||
text: hostDialog.found.length ? "Another one, by its address" : "None found: type its address"
|
||||
checked: hostDialog.typed
|
||||
onToggled: if (checked) hostDialog.picked = hostDialog.found.length
|
||||
}
|
||||
Kirigami.FormLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: hostDialog.typed && !hostDialog.existing
|
||||
Controls.TextField { id: otherAddress; Kirigami.FormData.label: "Address:"; placeholderText: "192.168.1.20" }
|
||||
Controls.TextField { id: otherName; Kirigami.FormData.label: "Name:"; placeholderText: "My PC"; maximumLength: 40 }
|
||||
}
|
||||
Controls.Label {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: Kirigami.Units.largeSpacing
|
||||
wrapMode: Text.Wrap
|
||||
text: "Sign in with its Vibepollo Web UI user name and password. Frametop keeps a token that can only pair its displays, set their permissions and list the monitors; the password isn't kept."
|
||||
}
|
||||
Kirigami.FormLayout {
|
||||
Layout.fillWidth: true
|
||||
Controls.TextField { id: user; Kirigami.FormData.label: "User name:" }
|
||||
Controls.TextField {
|
||||
id: password
|
||||
Kirigami.FormData.label: "Password:"
|
||||
echoMode: TextInput.Password
|
||||
onAccepted: hostDialog.accept()
|
||||
}
|
||||
}
|
||||
Controls.BusyIndicator { visible: hostDialog.working; running: visible; Layout.alignment: Qt.AlignHCenter }
|
||||
Controls.Label {
|
||||
visible: hostDialog.error !== ""
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.Wrap
|
||||
color: Kirigami.Theme.negativeTextColor
|
||||
text: hostDialog.error
|
||||
}
|
||||
}
|
||||
customFooterActions: [
|
||||
Kirigami.Action { text: "Sign in"; icon.name: "go-next"; enabled: hostDialog.ok; onTriggered: hostDialog.accept() },
|
||||
Kirigami.Action { text: "Cancel"; icon.name: "dialog-cancel"; onTriggered: { password.text = ""; hostDialog.close() } }
|
||||
]
|
||||
}
|
||||
|
||||
// A host's displays to add: its monitors as they are (all of them ticked, but the ones
|
||||
// already added), and a virtual one at any size.
|
||||
Kirigami.PromptDialog {
|
||||
id: displayDialog
|
||||
objectName: "displayDialog"
|
||||
title: "Add displays from " + host
|
||||
standardButtons: Kirigami.Dialog.NoButton
|
||||
property string host: ""
|
||||
property string address: ""
|
||||
property var added: [] // what its displays stream (app), so a monitor isn't added twice
|
||||
property var monitors: []
|
||||
property var chosen: ({}) // app -> ticked
|
||||
property bool virtualChosen: false
|
||||
property string error: ""
|
||||
property bool loading: false
|
||||
readonly property int count: monitors.filter(m => chosen[m.app]).length + (virtualChosen ? 1 : 0)
|
||||
function openFor(h) {
|
||||
host = h.name; address = h.address; added = h.displays.map(d => d.app)
|
||||
monitors = []; chosen = ({}); virtualChosen = false; error = ""; loading = true
|
||||
open()
|
||||
backend.listMonitors(address)
|
||||
}
|
||||
function tick(app, on) {
|
||||
const c = Object.assign({}, chosen)
|
||||
c[app] = on
|
||||
chosen = c
|
||||
}
|
||||
function accept() {
|
||||
if (!count) return
|
||||
close()
|
||||
const fps = displayRate.currentValue, kbps = Math.round(displayBitrate.value * 1000)
|
||||
for (const m of monitors)
|
||||
if (chosen[m.app]) backend.addDisplay(host, m.app, m.name, m.width, m.height, fps, kbps)
|
||||
if (virtualChosen) {
|
||||
const r = backend.resolutions[displayRes.currentIndex]
|
||||
backend.addDisplay(host, "monitor", "Virtual", r.width, r.height, fps, kbps)
|
||||
}
|
||||
}
|
||||
Connections {
|
||||
target: backend
|
||||
function onMonitorsReady(address, monitors, error) {
|
||||
if (address !== displayDialog.address) return
|
||||
displayDialog.loading = false
|
||||
displayDialog.monitors = monitors.filter(m => !m.virtual && m.active)
|
||||
const c = {}
|
||||
for (const m of displayDialog.monitors) c[m.app] = !displayDialog.added.includes(m.app)
|
||||
displayDialog.chosen = c
|
||||
displayDialog.error = error
|
||||
}
|
||||
}
|
||||
ColumnLayout {
|
||||
Controls.BusyIndicator { visible: displayDialog.loading; running: visible; Layout.alignment: Qt.AlignHCenter }
|
||||
Controls.Label {
|
||||
visible: displayDialog.error !== ""
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.Wrap
|
||||
text: "Couldn't list its monitors: " + displayDialog.error
|
||||
}
|
||||
Repeater {
|
||||
model: displayDialog.monitors
|
||||
Controls.CheckBox {
|
||||
required property var modelData
|
||||
readonly property bool already: displayDialog.added.includes(modelData.app)
|
||||
enabled: !already
|
||||
text: modelData.name + " (" + modelData.width + " × " + modelData.height + (modelData.primary ? ", main" : "") + ")"
|
||||
+ (already ? ": added" : "")
|
||||
checked: displayDialog.chosen[modelData.app] === true
|
||||
onToggled: displayDialog.tick(modelData.app, checked)
|
||||
}
|
||||
}
|
||||
Controls.CheckBox {
|
||||
visible: !displayDialog.loading
|
||||
text: "A virtual display (the host makes a new monitor at the size you pick)"
|
||||
checked: displayDialog.virtualChosen
|
||||
onToggled: displayDialog.virtualChosen = checked
|
||||
}
|
||||
Kirigami.FormLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: displayDialog.count > 0
|
||||
Controls.ComboBox {
|
||||
id: displayRes
|
||||
Kirigami.FormData.label: "Virtual display:"
|
||||
visible: displayDialog.virtualChosen
|
||||
model: backend.resolutions
|
||||
textRole: "text"
|
||||
Component.onCompleted: currentIndex = Math.max(0, backend.resolutions.findIndex(r => r.width === 2560 && r.height === 1440))
|
||||
}
|
||||
Controls.ComboBox {
|
||||
id: displayRate
|
||||
Kirigami.FormData.label: "Frame rate:"
|
||||
model: backend.streamRates.map(r => ({ text: r + " fps", value: r }))
|
||||
textRole: "text"
|
||||
valueRole: "value"
|
||||
Component.onCompleted: currentIndex = Math.max(0, indexOfValue(60))
|
||||
}
|
||||
RowLayout {
|
||||
Kirigami.FormData.label: "Bitrate:"
|
||||
Controls.SpinBox { id: displayBitrate; from: 0; to: 150; stepSize: 5; value: 0; editable: true }
|
||||
Controls.Label { text: displayBitrate.value === 0 ? "Mbit/s (0: by size and rate)" : "Mbit/s" }
|
||||
}
|
||||
}
|
||||
}
|
||||
customFooterActions: [
|
||||
Kirigami.Action {
|
||||
text: displayDialog.count > 1 ? "Add " + displayDialog.count : "Add"
|
||||
icon.name: "list-add"
|
||||
enabled: displayDialog.count > 0
|
||||
onTriggered: displayDialog.accept()
|
||||
},
|
||||
Kirigami.Action { text: "Later"; icon.name: "dialog-cancel"; onTriggered: displayDialog.close() }
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- hosts and their displays
|
||||
Component {
|
||||
id: hostsPage
|
||||
Kirigami.ScrollablePage {
|
||||
title: "Remote displays"
|
||||
actions: [
|
||||
Kirigami.Action {
|
||||
text: "Add computer"
|
||||
icon.name: "list-add"
|
||||
onTriggered: hostDialog.openNew()
|
||||
}
|
||||
]
|
||||
header: Kirigami.InlineMessage {
|
||||
position: Kirigami.InlineMessage.Position.Header
|
||||
visible: true
|
||||
type: backend.desktopRunning ? Kirigami.MessageType.Information : Kirigami.MessageType.Warning
|
||||
text: !backend.desktopRunning
|
||||
? "The Frametop desktop isn't running. The connected displays start with it."
|
||||
: backend.hosts.length === 0
|
||||
? "Another computer's monitors as Frametop screens, with the screens' controls, in your layouts and profiles. The computer runs Vibepollo: add it, sign in with its Web UI login, then pick its displays."
|
||||
: "Each display is a panel like a screen: move, size, curve and pin it in VR, and Display Settings' Save as profile keeps where it is. A disconnected display keeps its place and settings."
|
||||
}
|
||||
ColumnLayout {
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
Repeater {
|
||||
model: backend.hosts
|
||||
delegate: Kirigami.AbstractCard {
|
||||
id: hostCard
|
||||
required property var modelData
|
||||
readonly property bool anyConnected: modelData.displays.some(d => d.connected)
|
||||
Layout.fillWidth: true
|
||||
contentItem: ColumnLayout {
|
||||
RowLayout {
|
||||
Kirigami.Icon {
|
||||
source: "computer"
|
||||
Layout.preferredWidth: Kirigami.Units.iconSizes.medium
|
||||
Layout.preferredHeight: Kirigami.Units.iconSizes.medium
|
||||
}
|
||||
ColumnLayout {
|
||||
spacing: 0
|
||||
Kirigami.Heading { level: 3; text: hostCard.modelData.name }
|
||||
Controls.Label {
|
||||
text: hostCard.modelData.address + " · "
|
||||
+ (hostCard.modelData.online === true ? "online"
|
||||
: hostCard.modelData.online === false ? "not answering" : "checking…")
|
||||
+ (hostCard.modelData.hasToken ? "" : " · not signed in")
|
||||
color: hostCard.modelData.online === false ? Kirigami.Theme.negativeTextColor : Kirigami.Theme.textColor
|
||||
opacity: hostCard.modelData.online === false ? 1 : 0.7
|
||||
}
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Controls.Switch {
|
||||
text: "Connected"
|
||||
visible: hostCard.modelData.displays.length > 0
|
||||
checked: hostCard.anyConnected
|
||||
onToggled: backend.setHostConnected(hostCard.modelData.name, checked)
|
||||
Controls.ToolTip.text: "Connect or disconnect all of its displays"
|
||||
Controls.ToolTip.visible: hovered
|
||||
}
|
||||
Controls.Button {
|
||||
visible: hostCard.modelData.hasToken
|
||||
text: "Add displays"
|
||||
icon.name: "list-add"
|
||||
onClicked: displayDialog.openFor(hostCard.modelData)
|
||||
}
|
||||
Controls.Button {
|
||||
visible: !hostCard.modelData.hasToken
|
||||
text: "Sign in"
|
||||
icon.name: "unlock"
|
||||
onClicked: hostDialog.openFor(hostCard.modelData)
|
||||
}
|
||||
Controls.ToolButton {
|
||||
visible: hostCard.modelData.hasToken
|
||||
icon.name: "unlock"
|
||||
display: Controls.AbstractButton.IconOnly
|
||||
text: "Sign in again"
|
||||
Controls.ToolTip.text: "Sign in again: a new token replaces the one kept here (revoke the old one in its Web UI, API Tokens)"
|
||||
Controls.ToolTip.visible: hovered
|
||||
onClicked: hostDialog.openFor(hostCard.modelData)
|
||||
}
|
||||
Controls.ToolButton {
|
||||
icon.name: "edit-delete-remove"
|
||||
display: Controls.AbstractButton.IconOnly
|
||||
enabled: hostCard.modelData.displays.length === 0
|
||||
text: "Remove this host"
|
||||
Controls.ToolTip.text: enabled ? text : "Remove its displays first"
|
||||
Controls.ToolTip.visible: hovered
|
||||
onClicked: backend.removeHost(hostCard.modelData.name)
|
||||
}
|
||||
}
|
||||
// How its streams reach it: its Steam Link dongle on the Frame's hotspot
|
||||
// (no router in the way) or the home network. Without a dongle, the
|
||||
// network, and Find to look for one.
|
||||
RowLayout {
|
||||
id: route
|
||||
property string finding: ""
|
||||
readonly property bool hasDongle: hostCard.modelData.direct !== ""
|
||||
Controls.Label { text: route.hasDongle ? "Connection:" : "Connection: the network" }
|
||||
Controls.ComboBox {
|
||||
visible: route.hasDongle
|
||||
model: [{ text: "Auto (dongle when it's up)", value: "auto" },
|
||||
{ text: "Network only", value: "network" },
|
||||
{ text: "Dongle only", value: "dongle" }]
|
||||
textRole: "text"
|
||||
valueRole: "value"
|
||||
currentIndex: Math.max(0, indexOfValue(hostCard.modelData.route))
|
||||
onActivated: if (currentValue !== hostCard.modelData.route) backend.setRoute(hostCard.modelData.name, currentValue)
|
||||
Controls.ToolTip.text: "Changing it starts the host's streams over (a virtual display is made again, so its windows move)"
|
||||
Controls.ToolTip.visible: hovered
|
||||
}
|
||||
Controls.Label { visible: route.hasDongle; text: "Dongle:" }
|
||||
Controls.TextField {
|
||||
id: dongle
|
||||
visible: route.hasDongle
|
||||
text: hostCard.modelData.direct
|
||||
placeholderText: "not found yet"
|
||||
Layout.preferredWidth: Kirigami.Units.gridUnit * 7
|
||||
onEditingFinished: if (text !== hostCard.modelData.direct) backend.setDirect(hostCard.modelData.name, text)
|
||||
}
|
||||
Controls.Button {
|
||||
text: route.finding === "…" ? "Looking…" : route.hasDongle ? "Find" : "Find a dongle"
|
||||
icon.name: "edit-find"
|
||||
flat: !route.hasDongle
|
||||
enabled: route.finding !== "…"
|
||||
onClicked: { route.finding = "…"; backend.findDongle(hostCard.modelData.name) }
|
||||
Controls.ToolTip.text: "Look for this computer on the Frame's hotspot (Steam Link's dongle)"
|
||||
Controls.ToolTip.visible: hovered
|
||||
}
|
||||
Controls.Label {
|
||||
visible: route.finding !== "" && route.finding !== "…"
|
||||
text: route.finding
|
||||
opacity: 0.7
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Connections {
|
||||
target: backend
|
||||
function onDongleFound(name, address, why) {
|
||||
if (name !== hostCard.modelData.name) return
|
||||
route.finding = address ? "found " + address : "not found: " + why
|
||||
if (address && address !== hostCard.modelData.direct) backend.setDirect(name, address)
|
||||
}
|
||||
}
|
||||
}
|
||||
Controls.Label {
|
||||
visible: hostCard.modelData.displays.length === 0
|
||||
text: "No displays yet."
|
||||
opacity: 0.7
|
||||
}
|
||||
Repeater {
|
||||
model: hostCard.modelData.displays
|
||||
delegate: ColumnLayout {
|
||||
id: disp
|
||||
required property var modelData
|
||||
property bool expanded: false
|
||||
Layout.fillWidth: true
|
||||
Kirigami.Separator { Layout.fillWidth: true }
|
||||
RowLayout {
|
||||
Kirigami.Icon {
|
||||
source: disp.modelData.virtual ? "video-display-symbolic" : "video-display"
|
||||
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium
|
||||
Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium
|
||||
}
|
||||
ColumnLayout {
|
||||
spacing: 0
|
||||
Kirigami.Heading { level: 4; text: disp.modelData.label }
|
||||
Controls.Label {
|
||||
text: (disp.modelData.virtual ? "virtual display" : "monitor") + ", "
|
||||
+ disp.modelData.width + " × " + disp.modelData.height + " at " + disp.modelData.fps
|
||||
+ " fps, screen " + disp.modelData.number
|
||||
opacity: 0.7
|
||||
}
|
||||
Controls.Label {
|
||||
text: root.stateText(disp.modelData.state)
|
||||
color: root.stateColor(disp.modelData.state)
|
||||
}
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Controls.Switch {
|
||||
text: "Connected"
|
||||
checked: disp.modelData.connected
|
||||
onToggled: backend.setConnected(disp.modelData.id, checked)
|
||||
}
|
||||
Controls.Switch {
|
||||
text: "Shown"
|
||||
enabled: disp.modelData.connected
|
||||
checked: disp.modelData.shown
|
||||
onToggled: backend.setShown(disp.modelData.id, checked)
|
||||
Controls.ToolTip.text: "Hide its panel in VR; the stream keeps going, slowly"
|
||||
Controls.ToolTip.visible: hovered
|
||||
}
|
||||
Controls.ToolButton {
|
||||
icon.name: disp.expanded ? "go-up" : "configure"
|
||||
display: Controls.AbstractButton.IconOnly
|
||||
text: disp.expanded ? "Hide its settings" : "Its stream and size"
|
||||
Controls.ToolTip.text: text
|
||||
Controls.ToolTip.visible: hovered
|
||||
onClicked: disp.expanded = !disp.expanded
|
||||
}
|
||||
Controls.ToolButton {
|
||||
icon.name: "edit-delete-remove"
|
||||
display: Controls.AbstractButton.IconOnly
|
||||
text: "Remove this display"
|
||||
Controls.ToolTip.text: text
|
||||
Controls.ToolTip.visible: hovered
|
||||
onClicked: backend.removeDisplay(disp.modelData.id)
|
||||
}
|
||||
}
|
||||
Kirigami.FormLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: disp.expanded
|
||||
RowLayout {
|
||||
Kirigami.FormData.label: "Stream:"
|
||||
Controls.SpinBox {
|
||||
id: sw
|
||||
from: 640; to: 7680; stepSize: 8; editable: true
|
||||
value: disp.modelData.width
|
||||
}
|
||||
Controls.Label { text: "×" }
|
||||
Controls.SpinBox {
|
||||
id: sh
|
||||
from: 360; to: 4320; stepSize: 8; editable: true
|
||||
value: disp.modelData.height
|
||||
}
|
||||
Controls.ComboBox {
|
||||
id: sr
|
||||
model: backend.streamRates.map(r => ({ text: r + " fps", value: r }))
|
||||
textRole: "text"
|
||||
valueRole: "value"
|
||||
Component.onCompleted: currentIndex = Math.max(0, indexOfValue(disp.modelData.fps))
|
||||
}
|
||||
Controls.SpinBox {
|
||||
id: sb
|
||||
from: 0; to: 150; stepSize: 5; editable: true
|
||||
value: Math.round(disp.modelData.bitrate / 1000)
|
||||
}
|
||||
Controls.Label { text: sb.value === 0 ? "Mbit/s (auto)" : "Mbit/s" }
|
||||
Controls.Button {
|
||||
text: "Apply"
|
||||
enabled: sw.value !== disp.modelData.width || sh.value !== disp.modelData.height
|
||||
|| sr.currentValue !== disp.modelData.fps || sb.value * 1000 !== disp.modelData.bitrate
|
||||
onClicked: backend.setStream(disp.modelData.id, sw.value, sh.value, sr.currentValue, sb.value * 1000)
|
||||
}
|
||||
}
|
||||
Controls.Label {
|
||||
visible: !disp.modelData.virtual
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.Wrap
|
||||
opacity: 0.7
|
||||
text: "A monitor is scaled to the stream's size; its own resolution stays as it is."
|
||||
}
|
||||
RowLayout {
|
||||
Kirigami.FormData.label: "Width in VR:"
|
||||
Controls.Slider {
|
||||
id: rm
|
||||
from: 0.3; to: 6.0; stepSize: 0.05
|
||||
value: disp.modelData.metres
|
||||
Layout.preferredWidth: Kirigami.Units.gridUnit * 12
|
||||
onMoved: backend.setMetres(disp.modelData.id, value)
|
||||
}
|
||||
Controls.Label {
|
||||
text: rm.value.toFixed(2) + " m wide, "
|
||||
+ (rm.value * disp.modelData.height / disp.modelData.width).toFixed(2) + " m tall"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-8
@@ -1,28 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build ft-screens in the dev container on the Frame (screens/build/ft-screens), and
|
||||
# ft-handtest, which tries the hand cutouts (handcut.cpp) on a test panel of its own.
|
||||
# compositor.c is the wlroots side (C; wlroots headers aren't C++), vr.cpp the OpenVR side.
|
||||
# compositor.c is the wlroots side (C; wlroots headers aren't C++), vr.cpp the OpenVR side,
|
||||
# remote.c the remote screens (their streams are ft-stream's, stream/build.sh).
|
||||
# vr.cpp needs OpenVR's IVRIPCResourceManagerClient (ImportDmabuf), which the header
|
||||
# shipped with SteamVR on the Frame predates, so the build uses the public header from
|
||||
# Valve's openvr repo (pinned; the Frame's runtime supports its interface versions).
|
||||
# Valve's openvr repo (pinned in scripts/openvr.sh).
|
||||
# keyboard.cpp draws its key labels with stb_truetype (public domain, one header, pinned).
|
||||
set -euo pipefail
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
"$root/scripts/sync.sh" >/dev/null
|
||||
exec "$root/scripts/frame.sh" -C screens 'set -e; mkdir -p build/include
|
||||
openvr=v2.15.6
|
||||
[ -f build/include/openvr-$openvr ] || { curl -fsSL "https://raw.githubusercontent.com/ValveSoftware/openvr/$openvr/headers/openvr.h" -o build/include/openvr.h && touch build/include/openvr-$openvr; }
|
||||
. ../scripts/openvr.sh
|
||||
stb=2c980bb59875b0d32144a71867fbdebb2f77cd20
|
||||
[ -f build/include/stb-$stb ] || { curl -fsSL "https://raw.githubusercontent.com/nothings/stb/$stb/stb_truetype.h" -o build/include/stb_truetype.h && touch build/include/stb-$stb; }
|
||||
gcc -std=c11 -O2 -Wall -Wno-unused-parameter -c -o build/compositor.o compositor.c \
|
||||
$(pkg-config --cflags wlroots-0.20 wayland-server xkbcommon libdrm pixman-1)
|
||||
cc="gcc -std=c11 -O2 -Wall -Wno-unused-parameter $(pkg-config --cflags wlroots-0.20 wayland-server xkbcommon libdrm pixman-1)"
|
||||
$cc -c -o build/compositor.o compositor.c
|
||||
$cc -c -o build/remote.o remote.c
|
||||
cxx="g++ -std=c++17 -O2 -Wall -Wno-missing-field-initializers -Ibuild/include $(pkg-config --cflags egl glesv2 gbm libdrm)"
|
||||
$cxx -c -o build/vr.o vr.cpp
|
||||
$cxx -c -o build/keyboard.o keyboard.cpp
|
||||
$cxx -c -o build/handcut.o handcut.cpp
|
||||
$cxx -c -o build/handtest.o handtest.cpp
|
||||
vrlibs="$(pkg-config --libs egl glesv2 gbm) -L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64"
|
||||
g++ -o build/ft-screens build/compositor.o build/vr.o build/keyboard.o build/handcut.o \
|
||||
vrlibs="$(pkg-config --libs egl glesv2 gbm) $OPENVR_LIBS"
|
||||
g++ -o build/ft-screens build/compositor.o build/remote.o build/vr.o build/keyboard.o build/handcut.o \
|
||||
$(pkg-config --libs wlroots-0.20 wayland-server xkbcommon pixman-1) $vrlibs
|
||||
g++ -o build/ft-handtest build/handtest.o build/handcut.o $vrlibs
|
||||
echo "built build/ft-screens build/ft-handtest"'
|
||||
+103
-9
@@ -12,13 +12,16 @@
|
||||
// buffers are in pixels, so a panel position in pixels is divided by the screen's KWin
|
||||
// scale first ("scale <screen> <s>", from ft-layout).
|
||||
//
|
||||
// Usage: ft-screens [--socket NAME] [--control NAME] [--no-vr] [--screen WxH@METRES]...
|
||||
// Usage: ft-screens [--socket NAME] [--control NAME] [--no-vr] [--beside] [--screen WxH@METRES]...
|
||||
// [--spares N] [--rates F,V,H] [-- COMMAND ARGS...]
|
||||
// --socket Wayland socket name in $XDG_RUNTIME_DIR (default ft-screens-0)
|
||||
// --control the control socket's abstract name (default ft_screens)
|
||||
// --no-vr run without SteamVR, for tests next to the running desktop: no panels, no
|
||||
// input, and nothing sent to the input relay. Commands still work, and
|
||||
// "toplevels" shows what KWin opened.
|
||||
// --beside with SteamVR, next to the running desktop, for remote screens (remote.c):
|
||||
// nothing goes to the input relay, ft-floatd or ft-layout. Use other --socket
|
||||
// and --control names, and no COMMAND.
|
||||
// --screen one per screen, in KWin's order (default: 3440x1440@2.4)
|
||||
// --spares KWin's outputs after the screens: spares for floating windows (ft-floatd
|
||||
// turns them on and sizes them; see docs/floating-windows.md)
|
||||
@@ -63,6 +66,8 @@
|
||||
|
||||
#include "vr.h"
|
||||
#include "controller-click.h"
|
||||
#include "remote.h"
|
||||
#include "relay-buttons.h"
|
||||
|
||||
#define MAX_SCREENS 24 // screens and spare outputs
|
||||
// A screen counts as playing a video while its last VIDEO_COMMITS commits each redrew at
|
||||
@@ -130,12 +135,14 @@ struct server {
|
||||
uint32_t watch_until;
|
||||
uint32_t typed_ms; // the last key sent to the desktop: its screen counts as focused
|
||||
struct screen *pointer_focus;
|
||||
struct ft_relay_buttons relay_buttons; // the input relay's mouse buttons held on the seat
|
||||
struct ft_controller_click controller_click;
|
||||
pid_t child;
|
||||
// Where typing goes: the screens after a click on one, Steam after a click on another
|
||||
// panel. The input relay grabs the keyboards while it's the screens (see keys_update).
|
||||
bool keys_clicked; // the last click was on a screen
|
||||
bool keys_desktop; // ...and the screens are showing: typing goes to the desktop
|
||||
int key_remote; // the last click was on this remote screen (index): typing goes to it; -1 not
|
||||
int relay_fd; // unbound, so the relay can't reply into our control socket
|
||||
uint32_t relay_sent; // when the relay last heard from us (ms)
|
||||
unsigned ticks;
|
||||
@@ -144,6 +151,7 @@ struct server {
|
||||
bool kb_auto; // opened for a focused text field (not by a button)
|
||||
unsigned kb_close_at; // ticks: close it then (a text field lost focus), 0 not
|
||||
bool vr; // connected to SteamVR (not --no-vr)
|
||||
bool beside; // a second instance next to the desktop (--beside): remote screens only
|
||||
};
|
||||
|
||||
static uint32_t now_ms(void) {
|
||||
@@ -174,6 +182,9 @@ static void track_buffer(struct server *s, struct wlr_buffer *buffer) {
|
||||
|
||||
// ---------------------------------------------------------------- screens
|
||||
|
||||
static bool relay_can_press(struct server *s);
|
||||
static void release_relay_buttons(struct server *s, const char *why);
|
||||
|
||||
// A video (or anything moving over a large area) on a screen you don't look at keeps the
|
||||
// full frame rate (see frame_interval): its commits keep redrawing much of it, and keep
|
||||
// coming as fast as its rate lets them. A cursor blinking or a spinner turning redraws a
|
||||
@@ -247,7 +258,10 @@ static void screen_destroy(struct wl_listener *l, void *data) {
|
||||
wlr_log(WLR_INFO, "screen %d closed", sc->index + 1);
|
||||
if (sc->held) wlr_buffer_unlock(sc->held);
|
||||
ft_vr_screen_destroy(sc->index);
|
||||
if (sc->server->pointer_focus == sc) sc->server->pointer_focus = NULL;
|
||||
if (sc->server->pointer_focus == sc) {
|
||||
release_relay_buttons(sc->server, "its screen closed");
|
||||
sc->server->pointer_focus = NULL;
|
||||
}
|
||||
if (sc->decoration) wl_list_remove(&sc->decoration_destroy.link);
|
||||
sc->server->screens[sc->index] = NULL;
|
||||
wl_list_remove(&sc->commit.link);
|
||||
@@ -322,6 +336,13 @@ static void new_decoration(struct wl_listener *l, void *data) {
|
||||
|
||||
static void panel_key(struct server *s, uint32_t code, bool pressed);
|
||||
|
||||
// Typing goes to remote screen `index` (or to the desktop: -1). The one it leaves lets go of
|
||||
// the keys it held.
|
||||
static void set_key_remote(struct server *s, int index) {
|
||||
if (s->key_remote >= 0 && s->key_remote != index) ft_remote_blur(s->key_remote);
|
||||
s->key_remote = index;
|
||||
}
|
||||
|
||||
static void handle_vr_event(const struct ft_event *e, void *data) {
|
||||
struct server *s = data;
|
||||
if (e->type == FT_QUIT) {
|
||||
@@ -338,6 +359,16 @@ static void handle_vr_event(const struct ft_event *e, void *data) {
|
||||
s->kb_close_at = 0;
|
||||
return;
|
||||
}
|
||||
if (ft_remote_is(e->screen)) {
|
||||
// Another machine's display: its stream takes the input, and a click or a spin to it
|
||||
// takes the typing there too.
|
||||
if ((e->type == FT_BUTTON && e->pressed) || e->type == FT_FRONT) {
|
||||
set_key_remote(s, e->screen);
|
||||
s->keys_clicked = true;
|
||||
}
|
||||
ft_remote_event(e);
|
||||
return;
|
||||
}
|
||||
if (e->screen < 0 || e->screen >= MAX_SCREENS || !s->screens[e->screen]) return;
|
||||
struct ft_event filtered = *e;
|
||||
if (e->screen < s->n_config &&
|
||||
@@ -351,6 +382,8 @@ static void handle_vr_event(const struct ft_event *e, void *data) {
|
||||
// and ft-floatd makes its window (or the top one on a screen) KWin's active window.
|
||||
wlr_seat_keyboard_notify_enter(s->seat, surface, NULL, 0, NULL);
|
||||
s->keys_clicked = true;
|
||||
set_key_remote(s, -1);
|
||||
if (s->beside) return;
|
||||
char msg[32];
|
||||
snprintf(msg, sizeof msg, "front %d", e->screen + 1);
|
||||
struct sockaddr_un addr = {.sun_family = AF_UNIX};
|
||||
@@ -382,6 +415,7 @@ static void handle_vr_event(const struct ft_event *e, void *data) {
|
||||
if (e->pressed) {
|
||||
wlr_seat_keyboard_notify_enter(s->seat, surface, NULL, 0, NULL);
|
||||
s->keys_clicked = true;
|
||||
set_key_remote(s, -1);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -410,6 +444,7 @@ static void handle_vr_event(const struct ft_event *e, void *data) {
|
||||
break;
|
||||
case FT_LEAVE:
|
||||
if (s->pointer_focus == sc) {
|
||||
release_relay_buttons(s, "the pointer left the screens");
|
||||
wlr_seat_pointer_notify_clear_focus(s->seat);
|
||||
s->pointer_focus = NULL;
|
||||
}
|
||||
@@ -425,7 +460,7 @@ static void handle_vr_event(const struct ft_event *e, void *data) {
|
||||
// gamescope's focus) doesn't get the keys too. Without word from us for a few seconds,
|
||||
// the relay gives the keyboards back, so a closed desktop doesn't keep them.
|
||||
static void keys_update(struct server *s) {
|
||||
if (!s->vr) return; // a test instance leaves the running desktop's keyboards alone
|
||||
if (!s->vr || s->beside) return; // a test instance leaves the running desktop's keyboards alone
|
||||
const bool desktop = s->keys_clicked && ft_vr_screens_shown();
|
||||
const uint32_t t = now_ms();
|
||||
if (desktop == s->keys_desktop && t - s->relay_sent < 1000) return;
|
||||
@@ -499,6 +534,9 @@ static int tick(int fd, uint32_t mask, void *data) {
|
||||
const ssize_t got = read(fd, &expirations, sizeof expirations); // clears it; how many doesn't matter
|
||||
(void)got;
|
||||
ft_vr_poll(handle_vr_event, s);
|
||||
ft_remote_tick();
|
||||
if (s->relay_buttons.held && !relay_can_press(s))
|
||||
release_relay_buttons(s, ft_vr_paused() ? "paused" : "its screen hid");
|
||||
if (++s->ticks % 9 == 0) keys_update(s);
|
||||
if (s->kb_close_at && s->ticks >= s->kb_close_at) {
|
||||
s->kb_close_at = 0;
|
||||
@@ -528,7 +566,9 @@ static int child_exited(int sig, void *data) {
|
||||
int status;
|
||||
pid_t pid;
|
||||
while ((pid = waitpid(-1, &status, WNOHANG)) > 0)
|
||||
if (pid == s->child) {
|
||||
if (ft_remote_child(pid, status)) {
|
||||
continue;
|
||||
} else if (pid == s->child) {
|
||||
wlr_log(WLR_INFO, "session exited");
|
||||
wl_display_terminate(s->display);
|
||||
}
|
||||
@@ -553,13 +593,54 @@ static void send_key(struct server *s, uint32_t code, int pressed) {
|
||||
wlr_seat_keyboard_notify_key(s->seat, ev.time_msec, code, ev.state);
|
||||
}
|
||||
|
||||
// The input relay's mouse buttons (relay-buttons.h) can be pressed: the pointer is on a
|
||||
// screen that shows, and nothing's paused.
|
||||
static bool relay_can_press(struct server *s) {
|
||||
return s->pointer_focus && !ft_vr_paused() && ft_vr_screen_visible(s->pointer_focus->index);
|
||||
}
|
||||
|
||||
// A mouse button from the input relay (pointer mode passes a mouse's side button through as
|
||||
// a key, for Back): to the screen the pointer is on, like a laser's click, wherever typing goes.
|
||||
static void relay_button(struct server *s, uint32_t code, bool pressed, char *reply, int size) {
|
||||
if (!ft_relay_button(&s->relay_buttons, code, pressed, relay_can_press(s)))
|
||||
return (void)snprintf(reply, size, pressed ? "ok no pointer, or held" : "ok not held");
|
||||
wlr_seat_pointer_notify_button(s->seat, now_ms(), code,
|
||||
pressed ? WL_POINTER_BUTTON_STATE_PRESSED : WL_POINTER_BUTTON_STATE_RELEASED);
|
||||
wlr_seat_pointer_notify_frame(s->seat);
|
||||
snprintf(reply, size, "ok");
|
||||
}
|
||||
|
||||
// ...and released by us when the pointer leaves the screens, its screen hides or closes, or
|
||||
// everything pauses: before the pointer leaves that screen, so KWin gets the releases.
|
||||
static void release_relay_buttons(struct server *s, const char *why) {
|
||||
if (!s->relay_buttons.held) return;
|
||||
uint32_t code;
|
||||
while ((code = ft_relay_buttons_take(&s->relay_buttons))) {
|
||||
wlr_seat_pointer_notify_button(s->seat, now_ms(), code, WL_POINTER_BUTTON_STATE_RELEASED);
|
||||
wlr_log(WLR_INFO, "relay button %u released (%s)", code, why);
|
||||
}
|
||||
wlr_seat_pointer_notify_frame(s->seat);
|
||||
}
|
||||
|
||||
// Keys from the input relay (physical keyboards): "key <evdev code> <1 press|0 release>".
|
||||
// They go to the screen KWin has keyboard focus on (the last one clicked), while typing
|
||||
// goes to the desktop (keys_update). The release of a key the desktop got the press for
|
||||
// always goes through, or the key stays held there (a modifier held as typing moves to
|
||||
// Steam would otherwise modify every key typed after it).
|
||||
// Steam would otherwise modify every key typed after it). Mouse buttons go where the
|
||||
// pointer is instead (relay_button), and codes that are neither go nowhere.
|
||||
static void handle_key(struct server *s, uint32_t code, int value, char *reply, int size) {
|
||||
if (value == 2) return (void)snprintf(reply, size, "ok repeat ignored"); // KWin repeats itself
|
||||
const enum ft_relay_key kind = ft_relay_key_kind(code);
|
||||
if (kind == FT_RELAY_BUTTON) {
|
||||
relay_button(s, code, value != 0, reply, size);
|
||||
return;
|
||||
}
|
||||
if (kind == FT_RELAY_DROP) return (void)snprintf(reply, size, "ok not a key or mouse button");
|
||||
// A remote screen has the typing, except for the release of a key the desktop holds.
|
||||
if (s->key_remote >= 0 && ft_remote_is(s->key_remote) && (value || !key_held(&s->keyboard, code))) {
|
||||
ft_remote_key(s->key_remote, code, value != 0);
|
||||
return (void)snprintf(reply, size, "ok remote");
|
||||
}
|
||||
if (value || !key_held(&s->keyboard, code)) {
|
||||
if (!s->seat->keyboard_state.focused_surface) return (void)snprintf(reply, size, "ok no focus");
|
||||
if (!s->keys_desktop) return (void)snprintf(reply, size, "ok typing goes to Steam");
|
||||
@@ -617,6 +698,10 @@ static void keyboard_command(struct server *s, const char *what, char *reply, in
|
||||
// A key from our keyboard, for the focused screen. Its release always goes through, so
|
||||
// no key stays held.
|
||||
static void panel_key(struct server *s, uint32_t code, bool pressed) {
|
||||
if (s->key_remote >= 0 && ft_remote_is(s->key_remote) && (pressed || !key_held(&s->keyboard, code))) {
|
||||
ft_remote_key(s->key_remote, code, pressed);
|
||||
return;
|
||||
}
|
||||
if (pressed ? s->seat->keyboard_state.focused_surface != NULL : key_held(&s->keyboard, code))
|
||||
send_key(s, code, pressed);
|
||||
}
|
||||
@@ -692,7 +777,7 @@ static int control_readable(int fd, uint32_t mask, void *data) {
|
||||
else if (got >= 4 && (strcmp(what, "down") == 0 || strcmp(what, "up") == 0))
|
||||
e.type = FT_BUTTON, e.pressed = what[0] == 'd';
|
||||
else index = 0;
|
||||
if (index < 1 || index > MAX_SCREENS || !s->screens[index - 1]) {
|
||||
if ((index < 1 || index > MAX_SCREENS || !s->screens[index - 1]) && !ft_remote_is(index - 1)) {
|
||||
snprintf(reply, sizeof reply, "error input <screen> move|down|up|leave [x y [button]]");
|
||||
} else {
|
||||
handle_vr_event(&e, s);
|
||||
@@ -746,7 +831,7 @@ static int control_readable(int fd, uint32_t mask, void *data) {
|
||||
if (strcmp(buf + 6, "-") != 0 && strncmp(buf + 6, "frametop.", 9) != 0) s->keys_clicked = false;
|
||||
len = sizeof from;
|
||||
continue;
|
||||
} else {
|
||||
} else if (!ft_remote_command(buf, reply, sizeof reply)) {
|
||||
ft_vr_command(buf, reply, sizeof reply);
|
||||
}
|
||||
if (len > offsetof(struct sockaddr_un, sun_path))
|
||||
@@ -814,6 +899,7 @@ int main(int argc, char **argv) {
|
||||
s.controller_click.threshold = 32;
|
||||
for (int i = 0; i < MAX_SCREENS; ++i) s.scale[i] = 1;
|
||||
s.kb_screen = -1;
|
||||
s.key_remote = -1;
|
||||
s.rate[FT_FOCUSED] = 0, s.rate[FT_IN_VIEW] = 15, s.rate[FT_HIDDEN] = 1;
|
||||
s.period_ns = 1000000000LL / 90;
|
||||
s.phase_ms = 1;
|
||||
@@ -829,6 +915,8 @@ int main(int argc, char **argv) {
|
||||
s.spares = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--no-vr") == 0) {
|
||||
s.vr = false;
|
||||
} else if (strcmp(argv[i], "--beside") == 0) {
|
||||
s.beside = true;
|
||||
} else if (strcmp(argv[i], "--rates") == 0 && i + 1 < argc) {
|
||||
if (sscanf(argv[++i], "%d,%d,%d", &s.rate[FT_FOCUSED], &s.rate[FT_IN_VIEW], &s.rate[FT_HIDDEN]) != 3) {
|
||||
fprintf(stderr, "bad --rates %s (want FOCUSED,IN_VIEW,HIDDEN in Hz, 0 full)\n", argv[i]);
|
||||
@@ -848,7 +936,7 @@ int main(int argc, char **argv) {
|
||||
break;
|
||||
} else {
|
||||
fprintf(stderr,
|
||||
"usage: %s [--socket NAME] [--control NAME] [--no-vr] [--screen WxH@METRES]... "
|
||||
"usage: %s [--socket NAME] [--control NAME] [--no-vr] [--beside] [--screen WxH@METRES]... "
|
||||
"[--spares N] [--rates F,V,H] [-- COMMAND ARGS...]\n",
|
||||
argv[0]);
|
||||
return 2;
|
||||
@@ -859,10 +947,12 @@ int main(int argc, char **argv) {
|
||||
setvbuf(stdout, NULL, _IOLBF, 0); // vr.cpp prints to stdout; keep it in order with the log
|
||||
wlr_log_init(WLR_INFO, NULL);
|
||||
if (s.vr && !ft_vr_init()) return 1;
|
||||
if (s.beside) ft_vr_beside();
|
||||
if (!s.vr) wlr_log(WLR_INFO, "--no-vr: running without SteamVR");
|
||||
|
||||
s.display = wl_display_create();
|
||||
s.loop = wl_display_get_event_loop(s.display);
|
||||
ft_remote_init(s.loop, s.vr);
|
||||
wl_list_init(&s.buffers);
|
||||
wlr_compositor_create(s.display, 6, NULL);
|
||||
wlr_subcompositor_create(s.display);
|
||||
@@ -934,12 +1024,16 @@ int main(int argc, char **argv) {
|
||||
wl_display_run(s.display);
|
||||
|
||||
wlr_log(WLR_INFO, "stopping");
|
||||
// SteamVR first, while every buffer the panels show still exists (KWin's and the remote
|
||||
// streams'): vrcompositor leaves standby the moment we disconnect, and twice it drew a
|
||||
// remote panel's texture that was already gone (SIGBUS, 2026-10-07).
|
||||
ft_vr_shutdown();
|
||||
ft_remote_shutdown();
|
||||
if (s.child > 0) kill(s.child, SIGTERM);
|
||||
wl_display_destroy_clients(s.display);
|
||||
// wlroots asserts that nothing still listens to its globals when they go.
|
||||
wl_list_remove(&s.new_toplevel.link);
|
||||
wl_list_remove(&s.new_decoration.link);
|
||||
ft_vr_shutdown();
|
||||
wl_display_destroy(s.display);
|
||||
return 0;
|
||||
}
|
||||
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user