mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-10 08:00:19 +02:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7160180906 | ||
|
|
72d9b79d7b | ||
|
|
b5e410b0b9 | ||
|
|
d9f213b872 | ||
|
|
811e15ed9d | ||
|
|
7f55e9328a | ||
|
|
9def5f1762 | ||
|
|
b521564a68 | ||
|
|
1fdd6f0e78 | ||
|
|
6b7af52e1c | ||
|
|
a53ced4f88 | ||
|
|
28f91af0d2 | ||
|
|
e69fe11b2a | ||
|
|
65acbd9638 | ||
|
|
ef1c802e2d | ||
|
|
26de1430b0 | ||
|
|
e21a2beae0 | ||
|
|
8d6aa459fc | ||
|
|
b93c25eb7e | ||
|
|
b61aebcf16 | ||
|
|
dba0ef5707 | ||
|
|
70c3ad9b1c | ||
|
|
185cb766b1 | ||
|
|
305dd9bcef | ||
|
|
90698db6c7 | ||
|
|
48a2dcf759 | ||
|
|
06228725cc | ||
|
|
d82364ed00 | ||
|
|
eca95477cd | ||
|
|
52b10478ad | ||
|
|
275529da8f | ||
|
|
b727b8b8fe | ||
|
|
46bf562c5f | ||
|
|
0ffbf7a718 | ||
|
|
2e293beb3a |
No files matched your search
@@ -83,9 +83,17 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
pre=()
|
||||
[[ $VERSION == *-* ]] && pre=(--prerelease)
|
||||
pre=() notes=pack/release-notes.md
|
||||
if [[ $VERSION == *-* ]]; then
|
||||
pre=(--prerelease)
|
||||
# get.sh --release asks for a channel and offers stable first: an experimental
|
||||
# release's install line names its channel.
|
||||
notes=$RUNNER_TEMP/release-notes.md
|
||||
sed 's/bash -s -- --release`/bash -s -- --release --experimental`/' pack/release-notes.md >"$notes"
|
||||
grep -q -- '--release --experimental`' "$notes" ||
|
||||
echo "::warning::pack/release-notes.md has no get.sh --release line to mark experimental"
|
||||
fi
|
||||
gh release create "$GITHUB_REF_NAME" --draft --verify-tag "${pre[@]}" \
|
||||
--title "Frametop $VERSION" --notes-file pack/release-notes.md --generate-notes \
|
||||
--title "Frametop $VERSION" --notes-file "$notes" --generate-notes \
|
||||
framedrop/build/Frametop.zip framedrop/build/frametop.framedrop.json \
|
||||
framedrop/build/frametop-release.json framedrop/build/SHA256SUMS
|
||||
@@ -13,11 +13,13 @@ Two settings apps come with it: Frametop Display Settings for the screens, profi
|
||||
|
||||
Frametop is an independent project, not made by or affiliated with Valve.
|
||||
|
||||
Frametop lives at [Frametop/frametop](https://github.com/Frametop/frametop): code, releases, issues, and pull requests. It moved there from DeeJanuz/frametop on 2026-10-09; the old links and clones still work.
|
||||
|
||||
Join the [Frametop Discord](https://discord.gg/W3X9f7z3Bc) for questions, ideas, and help with your setup.
|
||||
|
||||
## Install on the headset
|
||||
|
||||
> **Frametop doesn't work on the SteamOS beta right now.** On the beta (SteamOS 0.4.3), gaze mode can't read the eye tracker, and the desktop has started without its taskbar ([#15](https://github.com/DeeJanuz/frametop/issues/15)). Use the stable SteamOS release until this note is gone.
|
||||
> **SteamOS 0.4:** SteamOS 0.4 moved the eye tracker's data that gaze mode reads. This version of Frametop reads both SteamOS 0.3's and 0.4's, and it's tested on 0.4.5. Run `scripts/doctor.sh` after the update: it says whether the eye tracker's layout is one Frametop knows. It also says whether the update deleted the Bluetooth fixes or our eye tracker's frame grabber, which happens when they were installed by Frametop 0.3.0-exp.3 or older. Reinstall what it names (`setup/bluetooth/install.sh install`, `gaze/tracker/install.sh`). From then on they're kept through updates.
|
||||
|
||||
You need a Steam Frame with an internet connection, a keyboard (Bluetooth, or the on-screen one), and about 3 GB of free space.
|
||||
|
||||
@@ -26,10 +28,12 @@ You need a Steam Frame with an internet connection, a keyboard (Bluetooth, or th
|
||||
3. Run:
|
||||
|
||||
```
|
||||
curl -fsSL https://deejanuz.github.io/frametop/get.sh | bash
|
||||
curl -fsSL https://frametop.github.io/frametop/get.sh | bash
|
||||
```
|
||||
|
||||
It asks which version you want: stable (the `main` branch, tested releases) or experimental (the `experimental` branch, the newest features, less tested). Then it clones the repo into `~/frametop` and runs `install.sh`. To choose without the question, add `-s -- --stable` or `-s -- --experimental` after `bash`. By hand, the same is `git clone https://github.com/DeeJanuz/frametop.git ~/frametop`, then `cd ~/frametop` and `./install.sh` (add `--branch experimental` to the clone for experimental).
|
||||
It asks which version you want: stable (the `main` branch, tested releases) or experimental (the `experimental` branch, the newest features, less tested). Then it clones the repo into `~/frametop` and runs `install.sh`. To choose without the question, add `-s -- --stable` or `-s -- --experimental` after `bash`. By hand, the same is `git clone https://github.com/Frametop/frametop.git ~/frametop`, then `cd ~/frametop` and `./install.sh` (add `--branch experimental` to the clone for experimental).
|
||||
|
||||
The third and fourth choices, stable release and experimental release, download Frametop already built (`Frametop.zip`, about 1.1 GB, from the [releases](https://github.com/Frametop/frametop/releases)) and install it without compiling anything. `-s -- --release` picks the stable release without the question, and `-s -- --release --experimental` the experimental one.
|
||||
|
||||
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.
|
||||
|
||||
@@ -133,7 +137,7 @@ With the displays off, the headset keeps tracking and rendering, so it uses abou
|
||||
|
||||
## Known limitations
|
||||
|
||||
This is an early release, tested on one Steam Frame (SteamOS 0.3.0 build 20260922, SteamVR 2.17.10).
|
||||
This is an early release, tested on one Steam Frame (SteamOS 0.4.5 build 20261007, SteamVR 2.18.2; before that SteamOS 0.3.0 build 20260922, SteamVR 2.17.10).
|
||||
|
||||
- A SteamOS or SteamVR update can break parts of it until Frametop catches up. After an update, run `cd ~/frametop && scripts/doctor.sh` in a terminal. It checks what Frametop needs from SteamOS, and says what changed since the versions you last marked as working and what to try. Once everything works, `scripts/doctor.sh --mark-good` records the versions. If something stops working, please report it.
|
||||
- The first install downloads 1–2 GB for the build container and compiles everything on the headset, which takes several minutes.
|
||||
@@ -157,14 +161,18 @@ 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. 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.
|
||||
From a release, start in `~/.local/share/frametop/releases/current` instead of `~/frametop`.
|
||||
|
||||
This writes `frametop-report-<date>.txt` with version numbers, service states, settings, Frametop's keyboard and the Steam menu, and recent logs. Bluetooth addresses and the headset's serial number are masked.
|
||||
|
||||
If the problem is something you can make happen, like a window that won't drag or a keyboard that doesn't open, run `scripts/report.sh --watch` instead. After the usual report it records for 60 seconds (`--watch 120` for longer) while you make it happen in the headset. It notes when the Steam menu opens and closes, which laser drags what, where typing goes, and when Frametop's keyboard opens or why it doesn't. 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/Frametop/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
|
||||
|
||||
Run the same command again. It updates `~/frametop` to the latest of the version you have (or switches, if you pick the other one) and installs it:
|
||||
|
||||
```
|
||||
curl -fsSL https://deejanuz.github.io/frametop/get.sh | bash
|
||||
curl -fsSL https://frametop.github.io/frametop/get.sh | bash
|
||||
```
|
||||
|
||||
Or by hand: `cd ~/frametop && git pull && ./install.sh`.
|
||||
@@ -174,7 +182,7 @@ Or by hand: `cd ~/frametop && git pull && ./install.sh`.
|
||||
In a terminal on the headset, run:
|
||||
|
||||
```
|
||||
curl -fsSL https://deejanuz.github.io/frametop/uninstall.sh | bash
|
||||
curl -fsSL https://frametop.github.io/frametop/uninstall.sh | bash
|
||||
```
|
||||
|
||||
It works in two steps, so it never takes away the keyboard, mouse, or desktop you're using while it runs:
|
||||
@@ -194,7 +202,7 @@ A Plasma session runs nested inside ft-screens (`screens/`), a small Wayland com
|
||||
|
||||
| Folder | What it is |
|
||||
| --- | --- |
|
||||
| `get.sh` | The one-line installer: picks stable or experimental, clones or updates the repo, and runs `install.sh`. |
|
||||
| `get.sh` | The one-line installer: picks stable or experimental, clones or updates the repo, and runs `install.sh`; or installs a built release (`--release`). |
|
||||
| `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. |
|
||||
|
||||
+2
-2
@@ -200,7 +200,7 @@ A podman container's monitor process (conmon) stays in the cgroup of whatever st
|
||||
|
||||
KWin renders with OpenGL through zink on Turnip, Vulkan on the same GPU vrcompositor needs to hit its frame time, and on the Frame that costs CPU too. The nested session started with KWin's defaults: blur and background contrast on (no `[Plugins]` group in its kwinrc) and animations at full length. Blur re-renders what's behind every translucent panel and menu each time it changes, and every animated frame is one more frame for KWin and ft-screens to draw and send. They're off by default in the Frametop desktop. The session script writes them before KWin starts, only where the desktop's own config has no value, once: System Settings deletes a setting put back to KDE's default rather than writing it, so without the marker in `frametoprc` a user who turned blur back on would lose it at the next start. The effect ids (`blur`, `contrast`) are the ones built into KWin 6.2.5 on SteamOS; KWin reads `<id>Enabled` from `[Plugins]`.
|
||||
|
||||
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.
|
||||
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`. Steam's entry (`steam -silent`, from steamdeck-kde-presets) reached the running Steam client as a command line it ran (`ExecCommandLine` in its console log), since the desktop starts from Steam; SteamOS 0.4 added `-vrdisable -deckard` to it, for Desktop Mode, where Plasma starts Steam itself. The session hides all three 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.
|
||||
|
||||
@@ -231,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 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.
|
||||
`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 (SteamOS 0.3's, or 0.4'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
|
||||
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ kwriteconfig6 --file ~/.config/frametop/kwinrc --group Plugins --key contrastEna
|
||||
kwriteconfig6 --file ~/.config/frametop/kdeglobals --group KDE --key AnimationDurationFactor 1
|
||||
```
|
||||
|
||||
Two of the system's autostart programs don't start in this desktop: Discover's update notifier (`org.kde.discover.notifier`), which starts Discover to check for updates, and IBus (`ibus`), which can't reach the desktop's apps because KWin's input method is `input/ft-textinput`. The session script puts copies with `Hidden=true` in `~/.config/frametop/autostart` once (marked in `frametoprc`), and skips a name you already have a file for. Delete a copy to start that program again.
|
||||
Three of the system's autostart programs don't start in this desktop: Discover's update notifier (`org.kde.discover.notifier`), which starts Discover to check for updates, IBus (`ibus`), which can't reach the desktop's apps because KWin's input method is `input/ft-textinput`, and Steam (`steam`), which is already running. The session script puts copies with `Hidden=true` in `~/.config/frametop/autostart` once (marked in `frametoprc`; Steam's was added later and is hidden once on desktops that already had the other two), and skips a name you already have a file for. Delete a copy to start that program again.
|
||||
|
||||
Settings are in two files, and Frametop Display Settings edits both. The screens (resolution, width in metres, scale, curve, which one has the taskbar) and their layout are in `~/.config/frametop-layout.json`. The backend, remote desktop, and pointer settings are in `~/.config/frametop.conf`; `session/frametop.conf.example` lists every key.
|
||||
|
||||
|
||||
@@ -439,17 +439,23 @@ The user decided remote displays are Frametop displays, with the same controls a
|
||||
**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.
|
||||
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` (the fork's release), 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).
|
||||
- Where other PCs get Frametop's build: the fork's GitHub releases, with the source as the release's tag (GPL-3.0). See below (2026-10-09).
|
||||
- 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.
|
||||
|
||||
**Frametop's build released (2026-10-09):**
|
||||
- Release [`frametop-2.0.0-1`](https://github.com/Frametop/frametop-vibepollo/releases/tag/frametop-2.0.0-1) of Frametop/frametop-vibepollo has `sunshine.exe` and the Arch package (`pacman -U`), each with its `.sha256`. The fork's CI (`frametop-build.yml`, Depot's runners) builds them from the tag and publishes them; a `frametop-*` tag is what makes a release. The tag is the source.
|
||||
- The host setup's `$BuildUrl` points at that `sunshine.exe`, so a PC needs only `host/windows`. It replaces the original, the hand-built `2f032252`, and the first CI build (`a84b6cfc`).
|
||||
- New in it: a Frametop display stream that asks for SDR turns the display's HDR off while it captures it, and back on when the stream ends (with `dd_hdr_option` automatic, Vibepollo's default). The test PC's HDR monitor looked washed out in SDR: Vibepollo's conversion clips at 80 nits while Windows draws SDR content at its SDR white level (240 nits there), and scaling for that left the colours heavily oversaturated. Displays it turned off are listed in `config\frametop_display_hdr.json` until they're back on, so the next start turns them back on after a crash or a forced stop. On the test PC's dev build: HDR off 0.4 s after the stream started, 8-bit capture, back on at the end, off again on a reconnect.
|
||||
- Tested on the test PC: with the first CI build installed and only `host/windows` in a folder, `-Check` downloaded the release and matched its SHA-256, and the run backed up, swapped and restarted Vibepollo; the Web UI answered.
|
||||
|
||||
**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.
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ framedrop/build.sh --image REF --version V [--commit SHA] [--channel C] [ZIP_URL
|
||||
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).
|
||||
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/Frametop/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
|
||||
|
||||
|
||||
+2
-2
@@ -40,14 +40,14 @@ files=("$here/installer/frametop-install.sh" "$here/installer/progress.py" "$her
|
||||
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}
|
||||
url=${1:-https://github.com/Frametop/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}
|
||||
url=${1:-https://github.com/Frametop/frametop/releases/download/framedrop-installer/Frametop.zip}
|
||||
files+=("$repo/get.sh")
|
||||
fi
|
||||
|
||||
|
||||
@@ -61,8 +61,8 @@ 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 git && check "git ls-remote github" git ls-remote --heads https://github.com/Frametop/frametop.git experimental
|
||||
have curl && check "curl get.sh" sh -c 'curl -fsSL https://frametop.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
|
||||
|
||||
+3
-2
@@ -26,6 +26,7 @@ Gaze as an input method for the whole desktop, without replacing anything of Ste
|
||||
- `ft-gazed` (host Python, a user service: `gaze/run.sh install`) runs ft-gaze and corrects its gaze. Two settings on the Gaze page of Frametop Input Settings (`GAZE_TRACKER` and `GAZE_EYE` in `~/.config/frametop.conf`, read again when the file changes) pick whose eye tracking it uses and how it weights the eyes:
|
||||
- **Eye tracker:** our own (Own tracker: see "Our own eye tracker" below) or SteamVR's. The default, `GAZE_TRACKER=auto`, is ours when it's installed (its frame grabber, and ft-eyes' Python in the gaze service's checkout), else SteamVR's, and it switches when ours is installed or removed; picking one on the Gaze page sets it for good. The gaze service runs ours while it's the one in use. It keeps its own calibration: with Own tracker chosen, Calibrate on the Gaze page calibrates it. The gaze pointer's settings (hand back, nudges, hold to drag, the dot) are the pointer helper's, so they're the same with either.
|
||||
- **Eye bias:** Auto, Left, or Right. The gaze combines both eyes, each calibrated on its own, because their errors partly cancel: on 306 clicks with our tracker, the eyes' sideways errors were correlated -0.37, and both together were 0.65 degrees off (median) against 0.96 for the left eye alone and 1.11 for the right. So Left or Right leans instead of choosing: that eye counts twice as much as the other (0.03 degrees worse there toward the better eye, 0.13 toward the worse). Auto weights each eye by the inverse square of how far off it was at your last 20 nudges, once each eye has 5, and evenly before that. Each eye's miss is measured before that nudge teaches anything, so each is a fresh test. The calibration's own fit isn't used for this: on SteamVR's test of 2026-09-29, the calibration dots said the left eye was the better one, and new spots said the right. Either eye carries the gaze alone while the other is closed or lost.
|
||||
- **One eye:** SteamOS 0.4's Track Dominant Eye Only (SteamVR's settings, General, with Show advanced settings on; `steamvr.eyeTrackingDominantEyeOnly` with `steamvr.dominantEye`) makes SteamVR's tracker ignore the other eye. With SteamVR's tracker, Frametop then goes by that eye alone: the calibration and the checks wait only for it, the gaze is that eye's own reading once it's calibrated (SteamVR's combined gaze before, which follows that eye then), and the fit check shows the other eye as not tracked. The service reads the setting again when SteamVR's settings file changes. Not yet tried in the headset: what SteamVR's shared memory says about the ignored eye wasn't measured.
|
||||
|
||||
With SteamVR, each eye is its own reading (set 2), corrected by its calibration from the probe (the Left eye and Right eye sources) plus what the pointer has taught that eye since. On that test, the two eyes each calibrated and averaged were 1.70 degrees off (median; mean 1.62) against 1.72 (mean 1.84) for SteamVR's combined gaze with its calibration. A calibration from before the probe had the eyes as sources, or `--source`, uses the older path. That path runs on SteamVR's combined gaze (mmap set 1), corrected as a whole. When the tracker loses one eye (its variance for that eye jumps from about 0.001 to 0.02), the gaze comes from the other eye instead: that eye's own reading (set 2) plus what it usually reads against the combined gaze, learned while both eyes are seen, in 10 degree cells of where it looks. Set 1 keeps going on one eye too, but it holds the lost eye's yaw where it was, so the gaze moves half as far sideways as your eyes do. On a recording, one eye alone came out a median 0.8 degrees from both eyes' gaze over a steady look, a little more jittery.
|
||||
|
||||
@@ -34,7 +35,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 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`.
|
||||
- **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. A button mapped to gaze precision or gaze drag counts as the left click there (before 2026-10-09 the panel ignored it). 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). The dot's ring shows full while ft-eyes checks it; the service doesn't wait for that answer (before 2026-10-09 it did, up to 3 s a dot, so the pointer could come back over the panel, and a slow answer closed the calibration as if the headset came off). 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.
|
||||
|
||||
@@ -55,7 +56,7 @@ The tracker stops when the headset is off your head. SteamVR also calibrates gaz
|
||||
|
||||
`gaze/tracker/` is an eye tracker of our own, because SteamVR's is about 1.5 degrees off after the best correction the gaze service can learn, and what's left is mostly look-to-look noise that no correction on top of its output can remove. Ours processes the eye cameras itself: 0.59 degrees (median) in its best live session against 0.83 for SteamVR's with the probe's correction, and after the headset was taken off and put back without recalibrating, 0.58 once your first clicks had taught it where the headset sat (`tracker/findings.md` has the measurements).
|
||||
|
||||
- `ft-eyegrab` (C, root, the system service `frametop-eyegrab.service`) copies the eye-camera frames (512x400, 90 fps per eye) out of the DMA-BUFs SteamVR's `eyetracking` process holds into `/dev/shm/frametop-eyes-cams`, owned by you. It maps them read-only, and it only copies while someone touches `/dev/shm/frametop-eyes-want` (ft-eyes and the recorder do, every second). Otherwise it holds none of the tracker's buffers. Its unit keeps only the capabilities that needs (`CAP_SYS_PTRACE`, `CAP_DAC_READ_SEARCH`, `CAP_CHOWN`). `gaze/tracker/install.sh` builds it and installs it to `/etc/frametop` with sudo, which it asks for (`uninstall`, `status`, and `log` too).
|
||||
- `ft-eyegrab` (C, root, the system service `frametop-eyegrab.service`) copies the eye-camera frames (512x400, 90 fps per eye) out of the DMA-BUFs SteamVR's `eyetracking` process holds into `/dev/shm/frametop-eyes-cams`, owned by you. It maps them read-only, and it only copies while someone touches `/dev/shm/frametop-eyes-want` (ft-eyes and the recorder do, every second). Otherwise it holds none of the tracker's buffers. Its unit keeps only the capabilities that needs (`CAP_SYS_PTRACE`, `CAP_DAC_READ_SEARCH`, `CAP_CHOWN`). `gaze/tracker/install.sh` builds it and installs it to `/etc/frametop` with sudo, which it asks for (`uninstall`, `status`, and `log` too). It also adds it to SteamOS's update keep list (`/etc/atomic-update.conf.d/frametop-eyegrab.conf`): an update deletes `/etc` files that list doesn't name, and without the grabber gaze mode falls back to SteamVR's tracker and its calibration. `scripts/doctor.sh` reports a grabber an update deleted.
|
||||
- `ft-eyes` (Python with numpy and OpenCV, in the dev container: `gaze/tracker/build.sh` puts the pinned `requirements.txt` in `gaze/tracker/build/venv`) finds each eye's pupil (dark threshold, closing, ellipse fit) and glint pair (`eyes_pupil.py`), and maps them to a gaze with a quadratic fit per eye (`eyes_model.py`). It follows the headset moving on your face with a per-eye shift, which your clicks teach, and uses the glints only to notice a sudden jump. It publishes the gaze in `/dev/shm/frametop-eyes-gaze` (ft-gaze's source `own`) and takes calibration dots and clicks on `@ft_eyes`. The gaze service runs it while Eye tracker is Own tracker, or while the probe uses it. State (the calibration, each eye's shift, the clicks) is in `~/.local/state/frametop/gaze/eyes/`.
|
||||
- `lab/` has the tools for improving it on recordings. `ft-eyes-record NAME` (or `ft-eyes-session`, with SteamVR's gaze alongside) records the cameras. `ft-eyes-score` fits and scores on recordings against the probe's practice clicks. `ft-eyes-e2e` runs the whole live path on two recordings (calibrate on one, click through the other). `ft-eyes-replay` plays a recording into a scratch share. Heavy ones are meant for a PC: if you have `frame-job` (a personal tool, not in this repo), `gaze/tracker/.frame-job` sends them there. `lab/py` runs them with that Python (in the dev container on the Frame; on a PC, the same venv from `requirements.txt`, which frame-job's setup makes).
|
||||
|
||||
|
||||
+17
-4
@@ -51,7 +51,10 @@ GUIDE = [
|
||||
|
||||
|
||||
class FitCheck:
|
||||
def __init__(self):
|
||||
def __init__(self, ignore=None):
|
||||
# The eye SteamVR's tracker ignores (0 left, 1 right) with Track Dominant Eye Only on
|
||||
# (gazecal.tracked_eye), or None: its losses say nothing about the fit.
|
||||
self.ignore = ignore
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
@@ -99,7 +102,8 @@ class FitCheck:
|
||||
if q and (eye.get("new") or [1, 1])[k]:
|
||||
self.q[k].append(q[k])
|
||||
closed = [opens[k] < CLOSED for k in (0, 1)]
|
||||
if all(closed) or all(self.lost):
|
||||
judged = [k for k in (0, 1) if k != self.ignore]
|
||||
if all(closed[k] for k in judged) or all(self.lost[k] for k in judged):
|
||||
return # a blink: says nothing about the fit
|
||||
key = (math.floor(hy / CELL), math.floor(hp / CELL))
|
||||
for k in (0, 1):
|
||||
@@ -143,6 +147,8 @@ class FitCheck:
|
||||
# --- Summaries ---
|
||||
|
||||
def status(self, k):
|
||||
if k == self.ignore:
|
||||
return "not tracked", (0.6, 0.6, 0.6)
|
||||
if not self.samples:
|
||||
return "no data", (0.6, 0.6, 0.6)
|
||||
if self.lost[k]:
|
||||
@@ -174,8 +180,13 @@ class FitCheck:
|
||||
return ["Look around slowly (the screen's corners, then down at your keyboard, up, left and right) "
|
||||
"or press Enter for a guided check."]
|
||||
out = []
|
||||
if self.ignore is not None:
|
||||
out.append(f"SteamVR tracks only your {EYES[1 - self.ignore].lower()} (Track Dominant Eye Only in "
|
||||
f"SteamVR's settings), so your {EYES[self.ignore].lower()} doesn't count here.")
|
||||
bad = {}
|
||||
for k in (0, 1):
|
||||
if k == self.ignore:
|
||||
continue
|
||||
for key, words, _ in REGIONS:
|
||||
share = self.region_share(k, key)
|
||||
if share is not None and share >= 0.15:
|
||||
@@ -197,7 +208,7 @@ class FitCheck:
|
||||
"face, not one eye's fit.")
|
||||
continue
|
||||
k = next(iter(eyes))
|
||||
other = self.region_share(1 - k, key)
|
||||
other = self.region_share(1 - k, key) if 1 - k != self.ignore else None
|
||||
vs = f", the {EYES[1 - k].lower()} {other:.0%}" if other is not None else ""
|
||||
line = f"{EYES[k]}: lost {eyes[k]:.0%} of the time looking {words}{vs}."
|
||||
if key == "down":
|
||||
@@ -211,10 +222,12 @@ class FitCheck:
|
||||
"eyes.")
|
||||
out.append(line)
|
||||
s0, s1 = self.signal(0), self.signal(1)
|
||||
if s0 is not None and s1 is not None and abs(s0 - s1) > 0.25:
|
||||
if self.ignore is None and s0 is not None and s1 is not None and abs(s0 - s1) > 0.25:
|
||||
k = 0 if s0 < s1 else 1
|
||||
out.append(f"The tracker is less sure of your {EYES[k].lower()} even when it has it "
|
||||
f"(signal {min(s0, s1):.0%} against {max(s0, s1):.0%}).")
|
||||
if self.ignore is not None and len(out) == 1:
|
||||
out.append(f"Your {EYES[1 - self.ignore].lower()} is tracked everywhere you've looked so far.")
|
||||
if not out:
|
||||
out.append("Both eyes are tracked everywhere you've looked so far.")
|
||||
return out
|
||||
|
||||
+9
-8
@@ -98,10 +98,11 @@ 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.
|
||||
// SteamOS 0.4 (SteamVR 2.18.2; first on the 0.4.3 beta, the same on 0.4.5, the release)
|
||||
// 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
|
||||
@@ -115,11 +116,11 @@ constexpr size_t kVar1 = 0x177, kVar2 = 0x1b3;
|
||||
// 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 + 5; // enough for either layout
|
||||
constexpr size_t kShifts[] = {0, 5}; // the layouts EyeFile::Detect knows: stable, 0.4.x beta
|
||||
constexpr size_t kShifts[] = {0, 5}; // the layouts EyeFile::Detect knows: SteamOS 0.3, 0.4
|
||||
|
||||
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
|
||||
// Everything from the timestamp on is read at base + shift: 0 on SteamOS 0.3, 5 on 0.4
|
||||
// (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;
|
||||
@@ -673,7 +674,7 @@ int main(int argc, char **argv) {
|
||||
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");
|
||||
std::fprintf(stderr, "ft-gaze: eye-server.mmap layout: %s\n", eyes.shift ? "SteamOS 0.4 (+5)" : "SteamOS 0.3");
|
||||
} else if (d == EyeFile::kNone) {
|
||||
nextLayoutCheck = now + 1.0;
|
||||
if (!missSince) missSince = now;
|
||||
|
||||
+48
-8
@@ -106,7 +106,8 @@ Control socket: abstract unix datagram "@ft_gazed":
|
||||
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)
|
||||
now (a left click, Meta+J, or a gaze precision or gaze drag
|
||||
press) | close it (a right click, Meta+K)
|
||||
|
||||
Options: --source action|mmap1|mmap2 (the older one-source path with that source, whatever
|
||||
the settings say; set 2 was a little quieter in the probe, but loses the pointer whenever
|
||||
@@ -131,7 +132,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from gazecal import (DEFAULT_MODEL, EYE_FOUND, EYE_LOST, MODELS, STATE, Correction, EyeFallback, # noqa: E402
|
||||
EyeWeights, Fixation, LiveCorrection, SteamEyeLog)
|
||||
EyeWeights, Fixation, LiveCorrection, SteamEyeLog, TrackedEye)
|
||||
from gazecheck import Checks # noqa: E402
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
@@ -239,6 +240,9 @@ class Service:
|
||||
self.opens = (deque(maxlen=90), deque(maxlen=90)) # left, right
|
||||
self.vergence = deque(maxlen=90)
|
||||
self.fallback = EyeFallback()
|
||||
# SteamVR's tracker following one eye only (gazecal.tracked_eye): 0 left, 1 right, or None.
|
||||
self.tracked = TrackedEye()
|
||||
self.one_eye = self.tracked()
|
||||
self.lost = [False, False]
|
||||
self.bad_at = [0.0, 0.0] # sample time an eye was last lost or closed
|
||||
self.counts = {"samples": 0, "sent": 0, "blinks": 0, "one_eye": 0, "one_eye_used": 0, "lost_left": 0,
|
||||
@@ -283,7 +287,9 @@ class Service:
|
||||
return "source"
|
||||
if self.tracker == "own":
|
||||
return "own"
|
||||
return "eyes" if all(self.models[e].samples for e in SIDES) else "source"
|
||||
# With one eye tracked, that eye's own calibration is enough.
|
||||
sides = SIDES if self.one_eye is None else (SIDES[self.one_eye],)
|
||||
return "eyes" if all(self.models[e].samples for e in sides) else "source"
|
||||
|
||||
# --- Settings, calibration and lessons ---
|
||||
|
||||
@@ -294,6 +300,9 @@ class Service:
|
||||
auto = ""
|
||||
if self.tracker_setting == "auto":
|
||||
auto = " (auto: ours is installed)" if tracker == "own" else " (auto: ours isn't installed)"
|
||||
if tracker == "steam" and EYEGRAB[1].exists() and not EYEGRAB[0].exists():
|
||||
auto = (f" (auto: ours lost {EYEGRAB[0]}, which SteamOS updates delete unless it's kept;"
|
||||
" reinstall it with gaze/tracker/install.sh)")
|
||||
log(f"tracker {tracker}{auto}, eye bias {bias}" + (f" (--source {self.override} wins)" if self.override else ""))
|
||||
self.tracker, self.bias = tracker, bias
|
||||
for w in self.weights.values():
|
||||
@@ -640,11 +649,17 @@ class Service:
|
||||
for k in (0, 1):
|
||||
self.lost[k] = unc[k] > (EYE_FOUND if self.lost[k] else EYE_LOST)
|
||||
if not down:
|
||||
self.counts["lost_left"] += self.lost[0]
|
||||
self.counts["lost_right"] += self.lost[1]
|
||||
self.counts["lost_left"] += self.lost[0] and self.one_eye != 1
|
||||
self.counts["lost_right"] += self.lost[1] and self.one_eye != 0
|
||||
return low
|
||||
|
||||
def on_sample(self, s):
|
||||
one = self.tracked()
|
||||
if one != self.one_eye:
|
||||
log("SteamVR now tracks both eyes" if one is None else
|
||||
f"SteamVR tracks the {SIDES[one]} eye only (Track Dominant Eye Only): going by that eye")
|
||||
self.one_eye = one
|
||||
self.fix.reset()
|
||||
self.checks.on_sample(s)
|
||||
kind = self.kind
|
||||
if kind != self.last_kind:
|
||||
@@ -671,7 +686,8 @@ class Service:
|
||||
eyes = [(p["hy"], p["hp"]) if "hy" in p else None for p in per]
|
||||
if not any(eyes):
|
||||
return
|
||||
hp, hit = next(e[1] for e in eyes if e), m1.get("hit")
|
||||
one = self.one_eye
|
||||
hp, hit = (eyes[one] if one is not None and eyes[one] else next(e for e in eyes if e))[1], m1.get("hit")
|
||||
self.counts["samples"] += 1
|
||||
self.last_sample = time.monotonic()
|
||||
down = hp < KEYBOARD_PITCH and not hit
|
||||
@@ -679,8 +695,14 @@ class Service:
|
||||
if down:
|
||||
self.counts["looking_down"] += 1
|
||||
return
|
||||
if own and self.one_eye is not None:
|
||||
# SteamVR judges only that eye's openness, and a blink closes both: ours still
|
||||
# sees the other, so it stays in.
|
||||
low[1 - self.one_eye] = low[self.one_eye]
|
||||
# Our tracker finds the pupils itself; SteamVR's openness still marks the blinks.
|
||||
bad = [eyes[k] is None or low[k] or (not own and self.lost[k]) for k in (0, 1)]
|
||||
if not own and self.one_eye is not None:
|
||||
bad[1 - self.one_eye] = True # SteamVR ignores that eye
|
||||
if all(bad):
|
||||
self.counts["blinks"] += 1
|
||||
return
|
||||
@@ -715,11 +737,27 @@ class Service:
|
||||
self.bad_at[k] = s["t"] # the fallback doesn't learn from these either
|
||||
return
|
||||
bad = [low[k] or self.lost[k] for k in (0, 1)]
|
||||
hy, hp = src["hy"], src["hp"]
|
||||
eyes = (s["src"].get("mmap2") or {}).get("eyes")
|
||||
one = self.one_eye
|
||||
if one is not None:
|
||||
# SteamVR's combined gaze already follows that eye alone; set 2's averages the
|
||||
# ignored one in, so mmap2 takes the eye's own reading. No fallback to learn.
|
||||
if bad[one]:
|
||||
self.counts["blinks"] += 1
|
||||
return
|
||||
if self.source == "mmap2":
|
||||
if not eyes:
|
||||
self.counts["dropped"] += 1
|
||||
return
|
||||
hy, hp = eyes[one]
|
||||
fy, fp = self.fix(hy, hp, s["t"], 1.0)
|
||||
cy, cp = self.correction(self.source, fy, fp)
|
||||
self.send(s["t"], fy + cy, fp + cp, fy, fp, None)
|
||||
return
|
||||
if all(bad):
|
||||
self.counts["blinks"] += 1
|
||||
return
|
||||
hy, hp = src["hy"], src["hp"]
|
||||
eyes = (s["src"].get("mmap2") or {}).get("eyes")
|
||||
for k in (0, 1):
|
||||
if bad[k]:
|
||||
self.bad_at[k] = s["t"]
|
||||
@@ -907,6 +945,8 @@ class Service:
|
||||
self.on_control()
|
||||
elif key.data == "checks":
|
||||
self.checks.on_readable()
|
||||
elif key.data == "eyes_reply":
|
||||
self.checks.on_eyes_reply(key.fileobj)
|
||||
elif key.data == "panel" and self.checks.panel_proc:
|
||||
self.checks.read_panel()
|
||||
elif key.data == "own":
|
||||
|
||||
+70
-3
@@ -7,6 +7,7 @@ the tracker has lost the other), EyeWeights (how much each eye counts), and Stea
|
||||
reports them.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import statistics
|
||||
@@ -397,6 +398,63 @@ class LiveCorrection:
|
||||
EYE_LOST = 0.004
|
||||
EYE_FOUND = 0.0025
|
||||
|
||||
# SteamOS 0.4's "Track Dominant Eye Only" (SteamVR's settings, General, advanced): SteamVR's
|
||||
# tracker ignores the other eye, for someone whose eyes don't look at the same spot. Its
|
||||
# settings: steamvr.eyeTrackingDominantEyeOnly, and steamvr.dominantEye (0 left, 1 right,
|
||||
# SteamVR's default). Frametop then goes by that eye alone: a calibration that waits for both
|
||||
# eyes would never take a dot, and the other eye's reading isn't where the person looks.
|
||||
STEAMVR_SETTINGS = (Path.home() / ".config" / "openvr" / "config" / "steamvr.vrsettings",
|
||||
Path.home() / ".steam" / "steam" / "config" / "steamvr.vrsettings")
|
||||
|
||||
|
||||
def tracked_eye(paths=STEAMVR_SETTINGS):
|
||||
"""The one eye SteamVR's tracker follows (0 left, 1 right), or None for both. The first
|
||||
of the settings files that exists counts (SteamVR keeps only settings changed from its
|
||||
defaults, so a missing key is the default)."""
|
||||
for path in paths:
|
||||
try:
|
||||
text = Path(path).read_text()
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
steamvr = json.loads(text).get("steamvr")
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
if not isinstance(steamvr, dict) or steamvr.get("eyeTrackingDominantEyeOnly") is not True:
|
||||
return None
|
||||
return 0 if steamvr.get("dominantEye", 1) == 0 else 1
|
||||
return None
|
||||
|
||||
|
||||
class TrackedEye:
|
||||
"""tracked_eye(), read again when SteamVR's settings file changes (looked at no more than
|
||||
every CHECK seconds), since the setting can change while a service runs."""
|
||||
|
||||
CHECK = 2.0
|
||||
|
||||
def __init__(self, paths=STEAMVR_SETTINGS):
|
||||
self.paths = paths
|
||||
self.eye = None
|
||||
self.stamp = None
|
||||
self.checked = None
|
||||
|
||||
def __call__(self, now=None):
|
||||
now = time.monotonic() if now is None else now
|
||||
if self.checked is not None and now - self.checked < self.CHECK:
|
||||
return self.eye
|
||||
self.checked = now
|
||||
stamp = []
|
||||
for path in self.paths:
|
||||
try:
|
||||
st = os.stat(path)
|
||||
stamp.append((st.st_mtime_ns, st.st_size))
|
||||
except OSError:
|
||||
stamp.append(None)
|
||||
if stamp != self.stamp:
|
||||
self.stamp = stamp
|
||||
self.eye = tracked_eye(self.paths)
|
||||
return self.eye
|
||||
|
||||
|
||||
class EyeFallback:
|
||||
"""The gaze from one eye, while the tracker has lost the other.
|
||||
@@ -640,7 +698,7 @@ def cross_validate(points, mode):
|
||||
return errs
|
||||
|
||||
|
||||
def steady_samples(samples, vergence_jump=1.5, why=None):
|
||||
def steady_samples(samples, vergence_jump=1.5, why=None, eye=None):
|
||||
"""The samples of one look at one spot where the tracker had both eyes: none in a blink
|
||||
(openness under half its median over the samples), none where it had lost an eye (its
|
||||
variance over EYE_LOST), and none where the angle between the eyes' directions (`lr`, the
|
||||
@@ -648,28 +706,37 @@ def steady_samples(samples, vergence_jump=1.5, why=None):
|
||||
vergence itself depends on distance (about 2.8 degrees for a screen 1.3 m away, a
|
||||
fraction of one far off), so only a jump away from what it was during this look means
|
||||
the tracker lost an eye. Without the mmap there's nothing to judge by: all are kept.
|
||||
`eye` (0 left, 1 right; see tracked_eye) judges that eye alone: the other one's loss,
|
||||
openness and the vergence don't count.
|
||||
`why`, a dict, gets how many were dropped for each reason: "lost_left", "lost_right",
|
||||
"lost_both", "blink" and "vergence" (each sample once, for the first that applies)."""
|
||||
if why is None:
|
||||
why = {}
|
||||
|
||||
def openness(o):
|
||||
return o[eye] if eye is not None else min(o)
|
||||
# Openness: a blink is a sharp drop from what it was during this look. Not a fixed
|
||||
# level: looking down, the upper lids come down with the eyes, and in bright light you
|
||||
# squint, so the reading can stay under 0.5 for the whole look while the tracker follows
|
||||
# the eyes fine (a calibration dot at the bottom of the bright round failed that way).
|
||||
opens = [min(o) for o in ((smp["src"].get("mmap1") or {}).get("open") for smp in samples) if o]
|
||||
opens = [openness(o) for o in ((smp["src"].get("mmap1") or {}).get("open") for smp in samples) if o]
|
||||
floor = max(0.12, 0.5 * statistics.median(opens)) if len(opens) >= 5 else 0.12
|
||||
seen = []
|
||||
for smp in samples:
|
||||
m1 = smp["src"].get("mmap1") or {}
|
||||
o = m1.get("open")
|
||||
lost = [u > EYE_LOST for u in m1.get("unc") or [0, 0]]
|
||||
if eye is not None:
|
||||
lost[1 - eye] = False
|
||||
# A lost eye's openness reads 0 too, so a lost eye is named before a blink.
|
||||
key = ("lost_both" if all(lost) else "lost_left" if lost[0] else "lost_right") if any(lost) else \
|
||||
"blink" if o and min(o) < floor else None
|
||||
"blink" if o and openness(o) < floor else None
|
||||
if key:
|
||||
why[key] = why.get(key, 0) + 1
|
||||
else:
|
||||
seen.append(smp)
|
||||
if eye is not None:
|
||||
return seen
|
||||
|
||||
def vergence(smp):
|
||||
return (smp["src"].get("mmap1") or {}).get("lr", (smp["src"].get("mmap2") or {}).get("lr"))
|
||||
|
||||
+113
-18
@@ -46,8 +46,10 @@ the dot), and the gaze held still up to then is taken (ACCEPT_SPREAD). They wait
|
||||
it takes, up to CLICK_IDLE. A dot not taken says why in the panel's note line (reject_reason:
|
||||
gazecal.steady_samples' drop counts for SteamVR's tracker, ft-eyes' reply for ours), as does a
|
||||
click with nothing taken after ACCEPT_WAIT, and a failed calibration names its most common
|
||||
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).
|
||||
reason there and in the status. A right click or Meta+K ("calquit") closes the panel. A press
|
||||
mapped to gaze precision or gaze drag counts as the left click (pointer/helper/calpanel.h). 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
|
||||
@@ -56,6 +58,12 @@ are enough to start it, each dot stands in for the gaze, and a click takes the C
|
||||
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.
|
||||
|
||||
The service doesn't wait for ft-eyes' answers to calib-point and calib-fit (ask_eyes): the dot
|
||||
shows its ring full, and further clicks do nothing until the answer comes, or its deadline
|
||||
passes; while it fits, a right click doesn't close the panel either. Until 2026-10-09 it waited, up to 3 s a dot and 10 s for the fit, so the gaze stopped,
|
||||
the helper's panel lease ran out (the pointer came back, and a click went to the desktop
|
||||
behind the panel), and an answer over EYES_GONE closed the calibration as the headset coming off.
|
||||
|
||||
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)
|
||||
@@ -260,6 +268,7 @@ class Checks:
|
||||
self.panel_restart_at = 0.0
|
||||
self.screens_shown = None
|
||||
self.last_progress = 0.0
|
||||
self.asking = None # (socket, deadline, done): a command to ft-eyes waiting for its reply
|
||||
|
||||
@property
|
||||
def active(self):
|
||||
@@ -345,6 +354,48 @@ class Checks:
|
||||
if was != (on, headset):
|
||||
self.svc.update_awake()
|
||||
|
||||
def ask_eyes(self, command, timeout, done):
|
||||
"""A command to ft-eyes whose reply comes later: done(reply) runs from on_eyes_reply, or with
|
||||
"" after `timeout` (tick), as ask() gives without one. ask() held the whole service up to 3 s
|
||||
a dot (calib-point) and 10 s at the end (calib-fit): the gaze stopped, and the helper's 3 s
|
||||
calpanel lease ran out, so the pointer came back and a click went to the desktop behind the
|
||||
panel. Each command has its own socket, so a late reply can't be taken for the next one."""
|
||||
self.drop_ask()
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM | socket.SOCK_CLOEXEC | socket.SOCK_NONBLOCK)
|
||||
try:
|
||||
s.bind("")
|
||||
s.sendto(command.encode(), EYES)
|
||||
except OSError:
|
||||
s.close()
|
||||
done("")
|
||||
return
|
||||
self.sel.register(s, selectors.EVENT_READ, "eyes_reply")
|
||||
self.asking = (s, time.monotonic() + timeout, done)
|
||||
|
||||
def on_eyes_reply(self, sock):
|
||||
if not self.asking or self.asking[0] is not sock:
|
||||
return # dropped already (closed, or timed out in this loop)
|
||||
try:
|
||||
reply = sock.recv(4096).decode("utf-8", "replace")
|
||||
except BlockingIOError:
|
||||
return
|
||||
except OSError:
|
||||
reply = ""
|
||||
done = self.asking[2]
|
||||
self.drop_ask()
|
||||
done(reply)
|
||||
|
||||
def drop_ask(self):
|
||||
if not self.asking:
|
||||
return
|
||||
s = self.asking[0]
|
||||
self.asking = None
|
||||
try:
|
||||
self.sel.unregister(s)
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
s.close()
|
||||
|
||||
# --- State ---
|
||||
|
||||
def calibrated(self):
|
||||
@@ -487,9 +538,10 @@ class Checks:
|
||||
if time.monotonic() - self.sample_at > 2:
|
||||
return "error the headset is off or the eye tracker isn't sending"
|
||||
now = time.monotonic()
|
||||
ignore = None if self.svc.one_eye is None else 1 - self.svc.one_eye # the eye SteamVR ignores
|
||||
self.check = {"kind": "fit", "reason": reason, "own": False, "dots": [], "i": 0, "started": now, "shown": now,
|
||||
"run": [], "accept": False, "done_at": None, "tries": 0, "skipped": 0, "captured": 0,
|
||||
"points": {}, "fit": FitCheck(), "drawn": {}, "drawn_at": 0.0, "step": None}
|
||||
"points": {}, "fit": FitCheck(ignore=ignore), "drawn": {}, "drawn_at": 0.0, "step": None}
|
||||
log(f"fit check: {reason}")
|
||||
self.to_helper("calpanel 1")
|
||||
self.to_panel("show fit")
|
||||
@@ -559,7 +611,8 @@ class Checks:
|
||||
if self.pending:
|
||||
self.run_pending()
|
||||
unc = (s["src"].get("mmap1") or {}).get("unc")
|
||||
if unc and min(unc) <= EYE_LOST:
|
||||
one = self.svc.one_eye
|
||||
if unc and (min(unc) if one is None else unc[one]) <= EYE_LOST:
|
||||
self.seen_at = time.monotonic()
|
||||
if self.away and self.back_since is None:
|
||||
self.back_since = self.seen_at
|
||||
@@ -567,8 +620,8 @@ class Checks:
|
||||
if c and c["kind"] == "fit":
|
||||
c["fit"].feed(s, self.sample_at)
|
||||
return
|
||||
if not c or c["done_at"]:
|
||||
return
|
||||
if not c or c["done_at"] or self.asking:
|
||||
return # (asking: ft-eyes has this dot's look, or the fit)
|
||||
if c["own"]:
|
||||
src = s["src"].get("own") or {}
|
||||
if c["blind"]:
|
||||
@@ -636,9 +689,12 @@ class Checks:
|
||||
rec.update(eyes=eyes, miss=miss)
|
||||
t0, t1 = samples[0]["t"], samples[-1]["t"]
|
||||
if c["kind"] == "full":
|
||||
reply = ask(EYES, f"calib-point {t0:.6f} {t1:.6f} {yaw:.4f} {pitch:.4f}", 3.0)
|
||||
rec["reply"] = reply
|
||||
ok = reply.startswith("ok")
|
||||
# ft-eyes checks the pupils held still: its answer takes the dot or not (point_done).
|
||||
# The ring shows it full meanwhile, so the click is seen to have landed.
|
||||
self.to_panel(f"dot {yaw:.3f} {pitch:.3f} capture 1.00")
|
||||
self.ask_eyes(f"calib-point {t0:.6f} {t1:.6f} {yaw:.4f} {pitch:.4f}", 3.0,
|
||||
lambda reply: self.point_done(rec, miss, reply))
|
||||
return
|
||||
else:
|
||||
try:
|
||||
svc.eyes_sock.sendto(f"click {t1:.6f} {yaw:.4f} {pitch:.4f}".encode(), EYES)
|
||||
@@ -648,7 +704,8 @@ class Checks:
|
||||
svc.weights["own"].add(miss)
|
||||
else:
|
||||
why = {}
|
||||
steady = steady_samples(samples, why=why)
|
||||
one = svc.one_eye
|
||||
steady = steady_samples(samples, why=why, eye=one)
|
||||
rec["dropped"] = why
|
||||
reads = {}
|
||||
for name in ("action", "mmap1", "mmap2", "left", "right"):
|
||||
@@ -656,11 +713,20 @@ class Checks:
|
||||
if "hy" in (smp["src"].get(name) or {})]
|
||||
if len(pts) >= 15:
|
||||
reads[name] = (statistics.median(p[0] for p in pts), statistics.median(p[1] for p in pts))
|
||||
if one is not None:
|
||||
# SteamVR tracks one eye (gazecal.tracked_eye): the other's reading isn't where
|
||||
# you look, and set 2's average has it in, so mmap2 is that eye's own (as ft-gazed
|
||||
# sends it then).
|
||||
reads.pop(("left", "right")[1 - one], None)
|
||||
reads.pop("mmap2", None)
|
||||
if ("left", "right")[one] in reads:
|
||||
reads["mmap2"] = reads[("left", "right")[one]]
|
||||
rec["reads"] = reads
|
||||
main = ("left", "right") if svc.kind == "eyes" else (svc.source,)
|
||||
main = (("left", "right") if one is None else (("left", "right")[one],)) if svc.kind == "eyes" else (svc.source,)
|
||||
if not all(n in reads for n in main):
|
||||
ok = False
|
||||
rec["reply"] = f"only {len(steady)} of {len(samples)} samples had both eyes"
|
||||
rec["reply"] = f"only {len(steady)} of {len(samples)} samples had " + (
|
||||
"both eyes" if one is None else f"your {('left', 'right')[one]} eye")
|
||||
elif c["kind"] == "full":
|
||||
for name, (hy, hp) in reads.items():
|
||||
c["points"].setdefault(name, []).append((hy, hp, yaw - hy, pitch - hp))
|
||||
@@ -680,9 +746,23 @@ class Checks:
|
||||
svc.lives[name].add({"time": time.time(), "hy": hy, "hp": hp, "dy": yaw - hy, "dp": pitch - hp,
|
||||
"wy": 1.0, "wp": 1.0, "how": "check"}, svc.models[name], svc.mode)
|
||||
rec["miss"] = miss
|
||||
if svc.kind == "eyes":
|
||||
if svc.kind == "eyes" and len(miss) == 2: # (one eye tracked: nothing to weigh)
|
||||
svc.weights["steam"].add(miss)
|
||||
svc.dirty = True
|
||||
self.captured(rec, ok)
|
||||
|
||||
def point_done(self, rec, miss, reply):
|
||||
"""ft-eyes' answer to a full calibration dot's calib-point ("" without one)."""
|
||||
rec["reply"] = reply
|
||||
ok = reply.startswith("ok")
|
||||
if ok:
|
||||
self.svc.weights["own"].add(miss)
|
||||
self.captured(rec, ok)
|
||||
|
||||
def captured(self, rec, ok):
|
||||
"""A capture is over: the dot is taken, tried again, or skipped."""
|
||||
c = self.check
|
||||
yaw, pitch, _ = c["dots"][c["i"]]
|
||||
if not ok:
|
||||
short, long, fit = reject_reason(rec.get("reply", "") if c["own"] else None, rec.get("dropped"))
|
||||
rec["reason"] = long
|
||||
@@ -761,8 +841,15 @@ class Checks:
|
||||
return
|
||||
self.full_failed = None
|
||||
if c["own"]:
|
||||
reply = ask(EYES, "calib-fit", 10.0)
|
||||
log(f"calibration ({c['captured']} of {n} dots): our tracker says {reply or 'nothing'}")
|
||||
def fitted(reply):
|
||||
log(f"calibration ({c['captured']} of {n} dots): our tracker says {reply or 'nothing'}")
|
||||
self.close()
|
||||
|
||||
self.to_panel("text Saving the calibration")
|
||||
c["done_at"] = None # (tick would advance past the last dot again)
|
||||
c["fitting"] = True
|
||||
self.ask_eyes("calib-fit", 10.0, fitted)
|
||||
return
|
||||
else:
|
||||
mode = svc.mode if svc.mode != "none" else DEFAULT_MODEL
|
||||
for name, pts in c["points"].items():
|
||||
@@ -785,6 +872,7 @@ class Checks:
|
||||
return
|
||||
if why:
|
||||
log(f"{self.check['kind']} check closed: {why}")
|
||||
self.drop_ask()
|
||||
self.to_panel("hide")
|
||||
self.to_helper("calpanel 0")
|
||||
if self.check["kind"] == "full" and self.screens_shown:
|
||||
@@ -794,8 +882,8 @@ class Checks:
|
||||
|
||||
def quit(self):
|
||||
c = self.check
|
||||
if not c:
|
||||
return
|
||||
if not c or c.get("fitting"):
|
||||
return # (fitting: ft-eyes has every dot; the panel closes once it answers)
|
||||
self.close("quit")
|
||||
if c["kind"] == "full" and self.calibrated() is False:
|
||||
# No calibration still: gaze mode can't work, so it goes off until it's turned on again.
|
||||
@@ -849,7 +937,7 @@ class Checks:
|
||||
if cmd == "calaccept":
|
||||
if self.check and self.check["kind"] == "fit":
|
||||
self.check["fit"].toggle_guide(time.monotonic())
|
||||
elif self.check:
|
||||
elif self.check and not self.asking: # (asking: this dot's click landed already)
|
||||
if not self.check["accept"]:
|
||||
self.check["accept_at"] = time.monotonic()
|
||||
self.check["accept"] = True
|
||||
@@ -887,6 +975,13 @@ class Checks:
|
||||
else:
|
||||
self.fit_tick(now)
|
||||
return
|
||||
if self.asking:
|
||||
# ft-eyes has a dot's look or the fit: wait for its answer, at most to the deadline.
|
||||
if now >= self.asking[1]:
|
||||
done = self.asking[2]
|
||||
self.drop_ask()
|
||||
done("")
|
||||
return
|
||||
if c["done_at"] and now >= c["done_at"]:
|
||||
if c.get("closing"):
|
||||
self.close()
|
||||
|
||||
@@ -161,11 +161,10 @@ class GazeReader:
|
||||
env = dict(os.environ)
|
||||
# The Frametop desktop has its own runtime dir; podman needs the real one.
|
||||
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"
|
||||
# ft-gaze quits when its stdin closes, which is the one thing distrobox passes on
|
||||
# when we go away (even if we're killed).
|
||||
self.proc = subprocess.Popen([str(distrobox), "enter", "dev", "--", str(HELPER), "--watch-stdin"], env=env,
|
||||
# when we go away (even if we're killed). in-box starts the container first, and picks
|
||||
# the release's own on a release install.
|
||||
self.proc = subprocess.Popen([str(REPO / "scripts" / "in-box"), str(HELPER), "--watch-stdin"], env=env,
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
start_new_session=True)
|
||||
out = Gio.UnixInputStream.new(self.proc.stdout.fileno(), False)
|
||||
|
||||
@@ -6,7 +6,9 @@ its dots. Until 2026-10-05 it couldn't: a fresh install that chose our tracker n
|
||||
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,
|
||||
panel (a stand-in process). The fake ft-eyes also answers late or not at all, as a slow one
|
||||
does: the service must keep running meanwhile (until 2026-10-09 it waited, up to 3 s a dot and
|
||||
10 s for the fit, and the helper's 3 s panel lease ran out). 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
|
||||
@@ -43,7 +45,8 @@ 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
|
||||
gazed.TrackedEye = lambda: lambda now=None: None # both eyes, whatever SteamVR's settings say
|
||||
DOTS = 4
|
||||
real_dots = gazecheck.check_dots
|
||||
gazecheck.check_dots = lambda kind, own: real_dots(kind, own)[:DOTS] # a short calibration
|
||||
logs = []
|
||||
@@ -92,8 +95,10 @@ 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}
|
||||
# The fake ft-eyes: uncalibrated until calib-fit. "fail" answers the next calib-point with that;
|
||||
# "delay" holds calib-point's and calib-fit's answers that many seconds; "drop" leaves the next
|
||||
# calib-point unanswered.
|
||||
eyes_state = {"cal": None, "points": [], "fail": None, "delay": 0.0, "drop": False}
|
||||
eyes = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
eyes.bind(gazed.EYES_SOCKET)
|
||||
eyes.settimeout(0.2)
|
||||
@@ -116,19 +121,31 @@ def eyes_answer():
|
||||
reply = "ok"
|
||||
elif w[0] == "calib-point":
|
||||
eyes_state["points"].append(tuple(map(float, w[1:5])))
|
||||
if eyes_state["drop"]:
|
||||
eyes_state["drop"] = False
|
||||
continue
|
||||
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:
|
||||
if addr and w[0] in ("calib-point", "calib-fit") and eyes_state["delay"]:
|
||||
threading.Timer(eyes_state["delay"], send_late, (reply, addr)).start()
|
||||
elif addr:
|
||||
eyes.sendto(reply.encode(), addr)
|
||||
|
||||
|
||||
def send_late(reply, addr):
|
||||
try:
|
||||
eyes.sendto(reply.encode(), addr)
|
||||
except OSError:
|
||||
pass # the service gave up on it
|
||||
|
||||
|
||||
threading.Thread(target=eyes_answer, daemon=True).start()
|
||||
|
||||
helper_state = {"reply": "ok off worn", "heard": []}
|
||||
helper_state = {"reply": "ok off worn", "heard": [], "calpanel": []} # calpanel: when "calpanel 1" came
|
||||
helper = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
helper.bind(gazed.POINTER)
|
||||
helper.settimeout(0.2)
|
||||
@@ -146,6 +163,8 @@ def helper_answer():
|
||||
helper.sendto(helper_state["reply"].encode(), addr)
|
||||
else:
|
||||
helper_state["heard"].append(data.decode())
|
||||
if data == b"calpanel 1":
|
||||
helper_state["calpanel"].append(time.monotonic())
|
||||
|
||||
|
||||
threading.Thread(target=helper_answer, daemon=True).start()
|
||||
@@ -225,10 +244,43 @@ 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)
|
||||
|
||||
# A slow ft-eyes (2.5 s): the service goes on meanwhile, and a second click is ignored.
|
||||
eyes_state["delay"] = 2.5
|
||||
wait(lambda: check_state().get("i") == 2 and not check_state().get("done_at"), 3)
|
||||
time.sleep(gazecheck.CHECK_SETTLE + gazecheck.CHECK_WINDOW + 0.1)
|
||||
before = len(eyes_state["points"])
|
||||
ask("calaccept")
|
||||
check("dot 3, ft-eyes slow: the service waits for it", wait(lambda: svc.checks.asking is not None, 2), True)
|
||||
t = time.monotonic()
|
||||
ask("status")
|
||||
check("the service still answers meanwhile", time.monotonic() - t < 0.5, True)
|
||||
ask("calaccept")
|
||||
check("dot 3: taken once ft-eyes answers", wait(lambda: check_state().get("captured") == 3, 4), True)
|
||||
check("the helper's panel lease was renewed while ft-eyes took its time",
|
||||
sum(t < at < t + eyes_state["delay"] for at in helper_state["calpanel"]) >= 2, True)
|
||||
check("and the calibration is still open (a blocked service took that as the headset off)",
|
||||
check_state().get("kind"), "full")
|
||||
check("the second click asked ft-eyes nothing", len(eyes_state["points"]), before + 1)
|
||||
eyes_state["delay"] = 0.0
|
||||
|
||||
# No answer: the dot isn't taken, after calib-point's 3 s.
|
||||
eyes_state["drop"] = True
|
||||
wait(lambda: check_state().get("i") == 3 and not check_state().get("done_at"), 3)
|
||||
time.sleep(gazecheck.CHECK_SETTLE + gazecheck.CHECK_WINDOW + 0.1)
|
||||
ask("calaccept")
|
||||
check("dot 4, no answer from ft-eyes: not taken, and the panel says so",
|
||||
wait(lambda: "didn't answer" in check_state().get("note", ""), 5), True)
|
||||
eyes_state["delay"] = 2.5 # the fit too
|
||||
check("dot 4, second try: taken", take_dot(3) or wait(lambda: check_state().get("captured") == 4, 4), True)
|
||||
|
||||
check("while ours fits, the panel stays", wait(lambda: check_state().get("fitting") is True, 3), True)
|
||||
ask("calquit")
|
||||
check("and a right click doesn't close it", check_state().get("kind"), "full")
|
||||
check("all dots: ours fits its calibration (calib-fit)",
|
||||
wait(lambda: eyes_state["cal"] is not None and not svc.checks.check, 4), True)
|
||||
wait(lambda: eyes_state["cal"] is not None and not svc.checks.check, 5), True)
|
||||
gaps = [b - a for a, b in zip(helper_state["calpanel"], helper_state["calpanel"][1:])]
|
||||
check("the helper's panel lease (3 s) never ran out", bool(gaps) and max(gaps) < 3.0, 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)
|
||||
|
||||
@@ -34,6 +34,7 @@ gazecheck.SCREENS = f"\0{tag}_screens"
|
||||
gazecheck.PANEL_PROG = gazed.REPO / "nonexistent-panel" # "isn't built": no panel
|
||||
gazed.IDLE_AFTER, gazed.WAKE_SETTLE = 1.0, 3.0
|
||||
gazed.read_settings = lambda: ("steam", "steam", "auto", 55.0)
|
||||
gazed.TrackedEye = lambda: lambda now=None: None # both eyes, whatever SteamVR's settings say
|
||||
logs = []
|
||||
gazed.log = gazecheck.log = lambda msg: logs.append(msg)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ int failures = 0;
|
||||
} 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).
|
||||
// timestamp on moved by `shift` (0 on SteamOS 0.3, 5 on 0.4).
|
||||
struct File {
|
||||
std::vector<uint8_t> bytes = std::vector<uint8_t>(324122); // eye-server.mmap's size
|
||||
EyeFile eyes;
|
||||
|
||||
Executable
+175
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline test of Track Dominant Eye Only (SteamOS 0.4): SteamVR's tracker ignores one eye,
|
||||
and the gaze code then goes by the other (gazecal.tracked_eye, steady_samples' `eye`,
|
||||
fitcheck's `ignore`, ft-gazed's live gaze). Reads only the temporary settings files it writes;
|
||||
ft-gazed's Service is built without its sockets, so nothing reaches the live gaze service.
|
||||
|
||||
gaze/test/one-eye-test.py
|
||||
"""
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from collections import deque
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, ".."))
|
||||
from fitcheck import FitCheck # noqa: E402
|
||||
from gazecal import ( # noqa: E402
|
||||
Correction,
|
||||
EyeFallback,
|
||||
EyeWeights,
|
||||
Fixation,
|
||||
TrackedEye,
|
||||
steady_samples,
|
||||
tracked_eye,
|
||||
)
|
||||
|
||||
loader = importlib.machinery.SourceFileLoader("ftgazed", os.path.join(HERE, "..", "ft-gazed"))
|
||||
gazed = importlib.util.module_from_spec(importlib.util.spec_from_loader("ftgazed", loader))
|
||||
loader.exec_module(gazed)
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def check(what, got, want):
|
||||
if got != want:
|
||||
failures.append(what)
|
||||
print(f"FAIL {what}: got {got!r}, want {want!r}", flush=True)
|
||||
|
||||
|
||||
tmp = tempfile.mkdtemp(prefix="ft-one-eye-test-")
|
||||
first, second = os.path.join(tmp, "a.vrsettings"), os.path.join(tmp, "b.vrsettings")
|
||||
paths = (first, second)
|
||||
|
||||
|
||||
def write(path, steamvr):
|
||||
with open(path, "w") as f:
|
||||
f.write(steamvr if isinstance(steamvr, str) else json.dumps({"steamvr": steamvr}, indent=3))
|
||||
|
||||
|
||||
# --- Reading SteamVR's settings ---
|
||||
check("no settings file: both eyes", tracked_eye(paths), None)
|
||||
write(second, {"eyeTrackingDominantEyeOnly": True})
|
||||
check("only the second file: it counts (right, SteamVR's default eye)", tracked_eye(paths), 1)
|
||||
write(first, {"supersampleScale": 1.0})
|
||||
check("the first file counts, without the setting: both eyes", tracked_eye(paths), None)
|
||||
write(first, {"eyeTrackingDominantEyeOnly": True, "dominantEye": 0})
|
||||
check("dominant eye left", tracked_eye(paths), 0)
|
||||
write(first, {"eyeTrackingDominantEyeOnly": False, "dominantEye": 0})
|
||||
check("setting off", tracked_eye(paths), None)
|
||||
write(first, "{ not json")
|
||||
check("a broken file: both eyes", tracked_eye(paths), None)
|
||||
|
||||
eye = TrackedEye(paths)
|
||||
write(first, {"eyeTrackingDominantEyeOnly": True, "dominantEye": 1})
|
||||
check("TrackedEye reads it", eye(now=100.0), 1)
|
||||
write(first, {"eyeTrackingDominantEyeOnly": True, "dominantEye": 0, "pad": "x" * 10})
|
||||
check("TrackedEye waits CHECK seconds", eye(now=101.0), 1)
|
||||
check("then sees the change", eye(now=102.5), 0)
|
||||
|
||||
# --- One look at a dot: the left eye lost all along (what SteamVR's tracker may report for
|
||||
# the eye it ignores), the right seen, and the vergence jumping with the lost eye ---
|
||||
look = [{"t": i / 90, "src": {"mmap1": {"hy": 1.0, "hp": 2.0, "unc": [0.02, 0.001], "open": [0.0, 0.8],
|
||||
"lr": 2.8 if i % 2 else 9.0}}} for i in range(40)]
|
||||
why = {}
|
||||
check("both eyes judged: nothing kept", len(steady_samples(look, why=why)), 0)
|
||||
check("both eyes judged: why", why, {"lost_left": 40})
|
||||
why = {}
|
||||
check("right eye only: all kept", len(steady_samples(look, why=why, eye=1)), 40)
|
||||
check("right eye only: nothing dropped", why, {})
|
||||
why = {}
|
||||
check("left eye only: nothing kept", len(steady_samples(look, why=why, eye=0)), 0)
|
||||
blink = [dict(s, src={"mmap1": dict(s["src"]["mmap1"], open=[0.0, 0.05])}) if 10 <= i < 15 else s
|
||||
for i, s in enumerate(look)]
|
||||
why = {}
|
||||
check("right eye only: its blinks still drop", len(steady_samples(blink, why=why, eye=1)), 35)
|
||||
check("right eye only: as blinks", why, {"blink": 5})
|
||||
|
||||
# --- The headset fit check ---
|
||||
fit = FitCheck(ignore=0)
|
||||
for i in range(400):
|
||||
fit.feed({"src": {"mmap1": {"hy": 0.0, "hp": 0.0, "unc": [0.02, 0.001], "open": [0.0, 0.8]}}}, i / 90)
|
||||
check("fit: the ignored eye's card", fit.status(0)[0], "not tracked")
|
||||
check("fit: the tracked eye's card", fit.status(1)[0], "tracking")
|
||||
hints = fit.hints()
|
||||
check("fit: says which eye counts", hints[0].startswith("SteamVR tracks only your right eye"), True)
|
||||
check("fit: no losses blamed on the ignored eye", any("Left eye: lost" in h for h in hints), False)
|
||||
check("fit: the tracked eye is fine", hints[-1], "Your right eye is tracked everywhere you've looked so far.")
|
||||
both = FitCheck()
|
||||
for i in range(400):
|
||||
both.feed({"src": {"mmap1": {"hy": 0.0, "hp": 0.0, "unc": [0.02, 0.001], "open": [0.0, 0.8]}}}, i / 90)
|
||||
check("fit, both eyes judged: the left eye is lost", both.status(0)[0], "LOST")
|
||||
|
||||
# --- ft-gazed's live gaze: the parts of Service the samples go through, no sockets ---
|
||||
def service(one, source="mmap1"):
|
||||
svc = gazed.Service.__new__(gazed.Service)
|
||||
svc.override, svc.tracker, svc.source, svc.one_eye = None, "steam", source, one
|
||||
svc.models = {name: Correction() for name in gazed.SOURCES}
|
||||
svc.counts = dict.fromkeys(("samples", "sent", "blinks", "one_eye", "one_eye_used", "lost_left", "lost_right",
|
||||
"looking_down", "dropped"), 0)
|
||||
svc.opens, svc.vergence = (deque(maxlen=90), deque(maxlen=90)), deque(maxlen=90)
|
||||
svc.lost, svc.bad_at, svc.fallback = [False, False], [0.0, 0.0], EyeFallback()
|
||||
svc.fix, svc.last_sample = Fixation(radius=1.0), 0.0
|
||||
svc.correction = lambda name, hy, hp: (0.0, 0.0)
|
||||
svc.sent = []
|
||||
svc.send = lambda t, hy, hp, rhy, rhp, eyes: svc.sent.append((hy, hp))
|
||||
return svc
|
||||
|
||||
|
||||
def feed(svc, n=90):
|
||||
for i in range(n):
|
||||
svc.on_source_sample({"t": i / 90, "src": {
|
||||
"mmap1": {"hy": 1.0, "hp": 2.0, "unc": [0.02, 0.001], "open": [0.0, 0.8]},
|
||||
"mmap2": {"hy": 3.0, "hp": 2.0, "eyes": [[9.0, 9.0], [5.0, 2.0]]}}})
|
||||
|
||||
|
||||
svc = service(None, "mmap2")
|
||||
feed(svc)
|
||||
check("mmap2, both eyes judged: a lost eye and no fallback yet drop the gaze", svc.sent, [])
|
||||
svc = service(1, "mmap2")
|
||||
feed(svc)
|
||||
check("mmap2, right eye only: its own reading goes out", (len(svc.sent), svc.sent[-1] if svc.sent else None), (90, (5.0, 2.0)))
|
||||
check("mmap2, right eye only: no blinks counted", svc.counts["blinks"], 0)
|
||||
svc = service(0, "mmap1")
|
||||
feed(svc)
|
||||
check("left eye only, and it's lost: nothing goes out", (svc.sent, svc.counts["blinks"]), ([], 90))
|
||||
svc = service(1)
|
||||
check("no calibration: the source, as a whole", svc.kind, "source")
|
||||
svc.models["right"].samples = 9
|
||||
check("right eye only and calibrated: each eye's own", svc.kind, "eyes")
|
||||
svc.one_eye = None
|
||||
check("both eyes judged: the left needs a calibration too", svc.kind, "source")
|
||||
|
||||
|
||||
# Our own tracker sees both eyes whatever SteamVR tracks; only the blinks come from SteamVR.
|
||||
def feed_own(svc, n=90, right_open=0.8):
|
||||
for i in range(n):
|
||||
svc.on_eyes_sample({"t": i / 90, "src": {
|
||||
"mmap1": {"unc": [0.02, 0.001], "open": [0.0, right_open]},
|
||||
"own": {"hy": 5.0, "hp": 2.0, "eyes": [[4.0, 2.0], [6.0, 2.0]]}}}, True)
|
||||
|
||||
|
||||
svc = service(1)
|
||||
svc.tracker, svc.weights = "own", {"own": EyeWeights()}
|
||||
check("own tracker, right eye only: our tracker", svc.kind, "own")
|
||||
feed_own(svc)
|
||||
check("own tracker, right eye only: SteamVR's closed left doesn't drop our left",
|
||||
(len(svc.sent), svc.counts["one_eye"], svc.counts["blinks"]), (90, 0, 0))
|
||||
svc = service(1)
|
||||
svc.tracker, svc.weights = "own", {"own": EyeWeights()}
|
||||
feed_own(svc, right_open=0.0)
|
||||
check("own tracker, right eye only: its blink drops both", (svc.sent, svc.counts["blinks"]), ([], 90))
|
||||
svc = service(None)
|
||||
svc.tracker, svc.weights = "own", {"own": EyeWeights()}
|
||||
feed_own(svc)
|
||||
check("own tracker, both eyes judged: SteamVR's closed left still drops it", svc.counts["one_eye"], 90)
|
||||
|
||||
for p in paths:
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
os.rmdir(tmp)
|
||||
print("FAILED: " + ", ".join(failures) if failures else "all passed")
|
||||
sys.exit(1 if failures else 0)
|
||||
@@ -0,0 +1,4 @@
|
||||
# Installed to /etc/atomic-update.conf.d/frametop-eyegrab.conf by gaze/tracker/install.sh.
|
||||
# A SteamOS update deletes every /etc file its keep list (/usr/lib/rauc/atomic-update-keep.conf)
|
||||
# doesn't name. That list keeps the unit, but not the program it runs.
|
||||
/etc/frametop/ft-eyegrab
|
||||
@@ -111,9 +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.
|
||||
On SteamOS 0.4 (SteamVR 2.18.2: the 0.4.3 beta, and 0.4.5, the release) 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 SteamOS 0.3's; ft-gaze detects which layout is live.
|
||||
|
||||
| Offset | What |
|
||||
| --- | --- |
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
# ft-eyes wants them. The gaze service (gaze/ft-gazed) runs ft-eyes itself, when ours is the
|
||||
# tracker in use (GAZE_TRACKER=auto, the default, picks it once this is installed) or the gaze
|
||||
# probe uses it. install.sh offers this after gaze mode.
|
||||
# Needs host sudo, for the binary (/etc/frametop/ft-eyegrab, root's) and the unit: it asks for
|
||||
# Needs host sudo, for the binary (/etc/frametop/ft-eyegrab, root's), the unit, and the entry that
|
||||
# keeps the binary through SteamOS updates (/etc/atomic-update.conf.d): it asks for
|
||||
# the password in the terminal, on the Frame or from a PC, or runs SUDO_ASKPASS when that's set
|
||||
# (frame_sudo in scripts/_env.sh, which also takes it from the repo's .env).
|
||||
# Usage: gaze/tracker/install.sh [install|uninstall|status|log [lines]]
|
||||
@@ -27,6 +28,7 @@ case ${1:-install} in
|
||||
sudo_run "set -e
|
||||
install -D -m 0755 -o root -g root $src/build/ft-eyegrab /etc/frametop/ft-eyegrab
|
||||
install -D -m 0644 -o root -g root /tmp/$unit /etc/systemd/system/$unit
|
||||
install -D -m 0644 -o root -g root $src/atomic-update.conf /etc/atomic-update.conf.d/frametop-eyegrab.conf
|
||||
rm -f /tmp/$unit
|
||||
systemctl daemon-reload
|
||||
systemctl enable $unit
|
||||
@@ -36,7 +38,7 @@ echo \"$unit: \$(systemctl is-active $unit)\""
|
||||
;;
|
||||
uninstall)
|
||||
sudo_run "systemctl disable --now $unit 2>/dev/null
|
||||
rm -f /etc/systemd/system/$unit /etc/frametop/ft-eyegrab
|
||||
rm -f /etc/systemd/system/$unit /etc/frametop/ft-eyegrab /etc/atomic-update.conf.d/frametop-eyegrab.conf
|
||||
rmdir /etc/frametop 2>/dev/null; systemctl daemon-reload; echo removed" ;;
|
||||
status) on_frame "systemctl is-active $unit; ls -l /dev/shm/frametop-eyes-cams 2>/dev/null" || true ;;
|
||||
log) on_frame "journalctl -u $unit --no-pager -o cat -n ${2:-20}" ;;
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
# Frametop's one-line installer. In a terminal on the Steam Frame (Konsole in the desktop, or
|
||||
# over SSH):
|
||||
#
|
||||
# curl -fsSL https://deejanuz.github.io/frametop/get.sh | bash
|
||||
# curl -fsSL https://frametop.github.io/frametop/get.sh | bash
|
||||
#
|
||||
# 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
|
||||
# Its third and fourth choices, or --release, install 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
|
||||
@@ -34,11 +34,14 @@ usage: get.sh [--stable | --experimental | --branch NAME] [--dir DIR] [--clone-o
|
||||
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]
|
||||
piped: curl -fsSL https://frametop.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
|
||||
# Frametop lives in the Frametop organization's repo: the branches clone from it, and its CI
|
||||
# runners (Depot, which need an organization) build the releases. It moved there from
|
||||
# DeeJanuz/frametop on 2026-10-09; GitHub redirects clones made from the old name.
|
||||
SLUG=${FRAMETOP_REPO:-Frametop/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() {
|
||||
@@ -111,7 +114,7 @@ install_release() {
|
||||
|
||||
# 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= branch= clone_only=0
|
||||
local repo=https://github.com/Frametop/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
|
||||
@@ -140,6 +143,7 @@ main() {
|
||||
echo "releases come from the stable or experimental list, not a branch" >&2
|
||||
return 2
|
||||
fi
|
||||
local dir_arg=$dir # the menu's release choice puts releases in their own default place
|
||||
if [ -z "$dir" ] && [ "$release" = 0 ]; then
|
||||
dir=$HOME/frametop
|
||||
fi
|
||||
@@ -176,12 +180,20 @@ main() {
|
||||
echo "Which version of Frametop?"
|
||||
echo " 1) stable: the main branch, tested releases"
|
||||
echo " 2) experimental: the newest features, less tested"
|
||||
if [ "$release" = 0 ]; then
|
||||
echo " 3) stable release: built, nothing to compile (a 1.1 GB download)"
|
||||
echo " 4) experimental release: built, nothing to compile (a 1.1 GB download)"
|
||||
fi
|
||||
[ -n "$current" ] && echo "(installed now: $current)"
|
||||
read -r -p "Choose 1 or 2 [$([ "$def" = main ] && echo 1 || echo 2)]: " answer </dev/tty || answer=
|
||||
read -r -p "Choose 1$([ "$release" = 0 ] && echo ", 2, 3, or 4" || echo " or 2") [$([ "$def" = main ] && echo 1 || echo 2)]: " \
|
||||
answer </dev/tty || answer=
|
||||
case ${answer:-$def} in
|
||||
1|main|s*) branch=main ;;
|
||||
2|experimental|e*) branch=experimental ;;
|
||||
*) echo "not 1 or 2: $answer" >&2; return 2 ;;
|
||||
3|4) [ "$release" = 0 ] || { echo "not 1 or 2: $answer" >&2; return 2; }
|
||||
release=1 dir=$dir_arg
|
||||
branch=$([ "$answer" = 3 ] && echo main || echo experimental) ;;
|
||||
*) echo "not one of the choices: $answer" >&2; return 2 ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
+5
-5
@@ -14,7 +14,7 @@ For now the recorder runs inside Frametop's desktop, so these steps install Fram
|
||||
|
||||
## 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 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/Frametop/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.
|
||||
@@ -27,7 +27,7 @@ For now the recorder runs inside Frametop's desktop, so these steps install Fram
|
||||
## 1. Install Frametop
|
||||
|
||||
```
|
||||
curl -fsSL https://deejanuz.github.io/frametop/get.sh | bash -s -- --stable
|
||||
curl -fsSL https://frametop.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.
|
||||
@@ -49,7 +49,7 @@ Open Frametop Hand Recorder from the desktop's application menu. It walks you th
|
||||
## Update
|
||||
|
||||
```
|
||||
curl -fsSL https://deejanuz.github.io/frametop/get.sh | bash -s -- --stable
|
||||
curl -fsSL https://frametop.github.io/frametop/get.sh | bash -s -- --stable
|
||||
~/frametop/hands/rec/install.sh
|
||||
```
|
||||
|
||||
@@ -61,8 +61,8 @@ Run the second command after SteamVR has restarted, as in the install.
|
||||
~/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.
|
||||
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/Frametop/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.
|
||||
Ask in the [Frametop Discord](https://discord.gg/W3X9f7z3Bc), the [Frametop issues](https://github.com/Frametop/frametop/issues), or the dataset's [discussion page](https://huggingface.co/datasets/DeeJanuz/frametop-hands/discussions). All three are public.
|
||||
@@ -40,6 +40,7 @@ Settings in `~/.config/frametop.conf` (`FT_<name>` in the environment overrides
|
||||
- `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_MODELS` (unset: `hands/models/ncnn`, the stock MediaPipe models): a folder holding `palm.ncnn.*` and `hand.ncnn.*`, as `--models`. Use it to run fine-tuned models, such as the ones trained on the hand dataset, without passing options to every launcher. Those folders usually lack the `*-int8` files, so `--int8` won't load them.
|
||||
- `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`.
|
||||
|
||||
The pointer helper's `POINTER_HANDS` and `POINTER_PINCH_*`/`POINTER_GRIP_*` settings are in "Pinches and grips in the pointer" below.
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
// 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), HANDS_MISREAD_GUARD (0 or 1: the
|
||||
// tracker's guards for fine-tuned landmark models, Tracker::set_misread_guard).
|
||||
// tracker's guards for fine-tuned landmark models, Tracker::set_misread_guard), HANDS_MODELS (as
|
||||
// --models: a folder with palm.ncnn.* and hand.ncnn.*, e.g. fine-tuned ones).
|
||||
#include "io.h"
|
||||
#include "pinch.h"
|
||||
#include "record.h"
|
||||
@@ -240,6 +241,7 @@ int main(int argc, char **argv) {
|
||||
// latency 9.6 against 14.1 ms, and the compositor's late frames and CPU/GPU time didn't change.
|
||||
std::vector<int> cpus = {5, 6, 7};
|
||||
if (const auto c = parse_cpus(setting("HANDS_CPUS").c_str()); !c.empty()) cpus = c;
|
||||
if (const std::string m = setting("HANDS_MODELS"); !m.empty()) models = m;
|
||||
// Which side camera is which (see the top): auto, 0 or 1, and where that came from
|
||||
std::string sides_mode = setting("HANDS_SWAP_SIDES"), sides_from = "config";
|
||||
if (sides_mode.empty()) sides_mode = "auto", sides_from = "default";
|
||||
@@ -322,7 +324,7 @@ 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, HANDS_MISREAD_GUARD=0|1\n"
|
||||
"HANDS_BRIGHT_ON, HANDS_BRIGHT_OFF, HANDS_COLOR_LEFT, HANDS_COLOR_CROP, HANDS_MISREAD_GUARD=0|1, HANDS_MODELS=DIR\n"
|
||||
"(FT_<name> overrides).\n",
|
||||
argv[0]);
|
||||
return a == "--help" ? 0 : 1;
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
# 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.
|
||||
# when a virtual display goes away, turns an HDR monitor's HDR off while Frametop streams
|
||||
# it (back on after), and doesn't stall the Web UI. Downloaded from its release and
|
||||
# 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.
|
||||
@@ -33,12 +34,13 @@ $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
|
||||
# Frametop's build: Frametop/frametop-vibepollo release frametop-2.0.0-1 (its CI, from the tag).
|
||||
$BuildSha = "57EECCF9CA6ECEC4F0C5AEBBB27AAFE711A700C9E0986601344A52682856E94E"
|
||||
$BuildUrl = "https://github.com/Frametop/frametop-vibepollo/releases/download/frametop-2.0.0-1/sunshine.exe"
|
||||
# Earlier Frametop builds, replaced by this one like the original is.
|
||||
$OlderBuildShas = @(
|
||||
"B5B7D2E7353454AEA6D895D0B68DE235E4CC581684C9DD44F8EFAF2E872F517F" # 2f032252, built by hand without WebRTC
|
||||
"6AF4F34503C7F6F84D1F6967D9FCEFAF2BD8145B34044C992B5E26A614E37A91" # a84b6cfc, CI, before the HDR-off guard
|
||||
)
|
||||
$Settings = [ordered]@{
|
||||
"remote_monitor_mute_audio" = "disabled"
|
||||
|
||||
@@ -1235,7 +1235,12 @@ class Backend(QObject):
|
||||
@Slot()
|
||||
def applyBluetoothFixes(self):
|
||||
if not os.path.exists("/run/host/etc/steamframe/bt-fixups.sh") and not os.path.exists("/etc/steamframe/bt-fixups.sh"):
|
||||
self.message.emit("Bluetooth fixes aren't installed (setup/bluetooth/install.sh)", True)
|
||||
# The unit outlives the script: SteamOS updates keep /etc's units, not what they run.
|
||||
if any(os.path.exists(r + "/etc/systemd/system/steamframe-bt-fixups.service") for r in ("/run/host", "")):
|
||||
self.message.emit("A SteamOS update deleted the Bluetooth fixes: reinstall them with "
|
||||
"setup/bluetooth/install.sh install", True)
|
||||
else:
|
||||
self.message.emit("Bluetooth fixes aren't installed (setup/bluetooth/install.sh)", True)
|
||||
return
|
||||
result = host("pkexec", "/etc/steamframe/bt-fixups.sh")
|
||||
if result.returncode == 0:
|
||||
|
||||
+15
-2
@@ -932,9 +932,22 @@ def main():
|
||||
if not focused:
|
||||
if not state["rules"].get("vr_keyboard_persist", True):
|
||||
vr_keyboard("hide") # ft-screens closes it only if it opened it for a text field
|
||||
elif mode == "always" or (mode == "no_keyboard" and not any(
|
||||
n.candidate and n.is_keyboard and n.role == "passthrough" and not n.uinput for n in nodes.values())):
|
||||
return
|
||||
keyboards = sorted({n.name for n in nodes.values()
|
||||
if n.candidate and n.is_keyboard and n.role == "passthrough" and not n.uinput})
|
||||
if mode == "always" or (mode == "no_keyboard" and not keyboards):
|
||||
vr_keyboard("show")
|
||||
why = "asking ft-screens to open Frametop's keyboard"
|
||||
elif mode == "no_keyboard":
|
||||
why = (f"not opening Frametop's keyboard: a keyboard is connected ({', '.join(keyboards)}), "
|
||||
"and the Keyboard setting opens it only without one")
|
||||
else:
|
||||
why = f"not opening Frametop's keyboard (Keyboard setting: {mode})"
|
||||
# For bug reports (scripts/report.sh): once per decision, again after 30 s.
|
||||
now = time.monotonic()
|
||||
if why != state.get("text_field_said") or now - state.get("text_field_said_at", 0.0) > 30:
|
||||
log(f"text field focused: {why}")
|
||||
state["text_field_said"], state["text_field_said_at"] = why, now
|
||||
|
||||
def do_action(action, value, now, source="mouse"):
|
||||
"""A mapped mouse or controller button, or key combination (pointer mode only, but
|
||||
|
||||
+35
-1
@@ -93,9 +93,11 @@ class NoDevice:
|
||||
|
||||
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
|
||||
LOG = [] # the relay's own log
|
||||
relay.log = lambda *args: LOG.append(" ".join(map(str, args)))
|
||||
bindings = {"now": None} # the rules' key_bindings; None: the relay's defaults
|
||||
devices = {"roles": {}, "buttons": {}} # the rules' "devices" roles and per-device "buttons"
|
||||
vr_keyboard = {"mode": None} # the rules' vr_keyboard (Frametop's keyboard); None: the default
|
||||
MOUSE_ID = "usb:0003:0004:test mouse" # the fake mouse's id (Node.id)
|
||||
|
||||
|
||||
@@ -103,6 +105,8 @@ def read_rules(path=None):
|
||||
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"])
|
||||
if vr_keyboard["mode"]:
|
||||
rules["vr_keyboard"] = vr_keyboard["mode"]
|
||||
return rules
|
||||
|
||||
|
||||
@@ -413,6 +417,36 @@ def tests():
|
||||
check("steam_menu, pause_toggle and commands work without pointer mode",
|
||||
(relay.needs_pointer("steam_menu"), relay.needs_pointer("pause_toggle"), relay.needs_pointer("command:ls")),
|
||||
(False, False, False))
|
||||
|
||||
# A text field on the desktop got focus (ft-textinput): Frametop's keyboard by the Keyboard
|
||||
# setting, and a log line saying why, once per decision (scripts/report.sh reads them).
|
||||
def text_field():
|
||||
c = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
c.sendto(b"textfield 1", f"\0{tag}_relay")
|
||||
time.sleep(0.1)
|
||||
return [m for m in typed() if m.startswith("vrkeyboard")]
|
||||
|
||||
def said():
|
||||
got = [m for m in LOG if m.startswith("text field focused")]
|
||||
LOG.clear()
|
||||
return got
|
||||
|
||||
typed(), said()
|
||||
check("text field, a keyboard connected (default setting): no keyboard", text_field(), [])
|
||||
check("...and the log says why", said(), [("text field focused: not opening Frametop's keyboard: a keyboard is "
|
||||
"connected (test keyboard), and the Keyboard setting opens it only "
|
||||
"without one")])
|
||||
check("the same again: not logged twice", (text_field(), said()), ([], []))
|
||||
vr_keyboard["mode"] = "always"
|
||||
use(None)
|
||||
check("text field, setting always: it opens", text_field(), ["vrkeyboard show"])
|
||||
check("...logged", said(), ["text field focused: asking ft-screens to open Frametop's keyboard"])
|
||||
vr_keyboard["mode"] = "never"
|
||||
use(None)
|
||||
check("text field, setting never: no keyboard, logged",
|
||||
(text_field(), said()), ([], ["text field focused: not opening Frametop's keyboard (Keyboard setting: never)"]))
|
||||
vr_keyboard["mode"] = None
|
||||
use(None)
|
||||
print("FAILED: " + ", ".join(failures) if failures else "all passed", flush=True)
|
||||
shutil.rmtree(OUT, ignore_errors=True)
|
||||
os._exit(1 if failures else 0)
|
||||
|
||||
+4
-2
@@ -899,10 +899,12 @@ def in_container():
|
||||
|
||||
|
||||
def run_stream(*args, timeout=60):
|
||||
"""ft-stream (it runs in the dev container, as ft-screens does)."""
|
||||
"""ft-stream, in the container ft-screens runs in: "dev" for a clone, the release's own for a
|
||||
release (scripts/in-box). A missing "dev" made distrobox ask whether to create it, and the
|
||||
question hung until the timeout."""
|
||||
cmd = [STREAM, *args]
|
||||
if not in_container():
|
||||
cmd = [os.path.expanduser("~/.local/bin/distrobox"), "enter", "dev", "--", *cmd]
|
||||
cmd = [os.path.join(REPO, "scripts", "in-box"), *cmd]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
return r.returncode, r.stdout, r.stderr
|
||||
|
||||
|
||||
@@ -194,6 +194,14 @@ 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 repo moved from DeeJanuz/frametop to the Frametop organization on
|
||||
2026-10-09, because Depot's runners need an organization. GitHub redirects the
|
||||
old repo URLs; the old Pages one-liner (`deejanuz.github.io/frametop/get.sh`)
|
||||
is kept by DeeJanuz/deejanuz.github.io, whose `frametop/get.sh` and
|
||||
`frametop/uninstall.sh` hand over to `frametop.github.io/frametop`. A release:
|
||||
merge into experimental (and main for a stable one, by merge commit), push,
|
||||
tag, and publish the draft.
|
||||
|
||||
## The longer arc
|
||||
|
||||
The image only pays off when it makes installing Frametop easier, so none of
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
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}
|
||||
TABLE_URL=${FRAMETOP_STEAMOS_TABLE:-https://raw.githubusercontent.com/Frametop/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,
|
||||
|
||||
@@ -4,7 +4,7 @@ On a Steam Frame (SteamOS, VR variant). Download **Frametop.zip** below (about 1
|
||||
|
||||
- **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`
|
||||
- **In a terminal on the headset:** `curl -fsSL https://frametop.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.
|
||||
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
"steamvr": "2.17.10",
|
||||
"frametop": "0.2.1",
|
||||
"date": "2026-10-05"
|
||||
},
|
||||
{
|
||||
"build": "20261007.6125817",
|
||||
"version": "0.4.5",
|
||||
"branch": "stable",
|
||||
"steamvr": "2.18.2",
|
||||
"frametop": "0.2.2",
|
||||
"date": "2026-10-09"
|
||||
}
|
||||
],
|
||||
"broken": []
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// The gaze calibration panel's answers (see ft-pointer.cpp's top): while ft-gazed says the panel
|
||||
// is up, a press from the relay answers it instead of clicking. Here so it can be tested without
|
||||
// SteamVR (pointer/test/calpanel-test.sh).
|
||||
//
|
||||
// Accept (take this dot): the left button's press ("btn trigger 1", whatever mouse button,
|
||||
// controller button or key combination is mapped to the left action), Meta+J ("gazekey left 1"),
|
||||
// and a gaze precision or gaze drag press ("precision|gazedrag <source> 1"): those are the left
|
||||
// button for someone who mapped it to one, and were dropped until 2026-10-09, so the panel
|
||||
// never took a dot from them. Quit: the right button ("btn b 1") and Meta+K ("gazekey right 1").
|
||||
// Their releases, and the other buttons, do nothing while the panel is up.
|
||||
#pragma once
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
enum class CalPanelAnswer { None, Accept, Quit, Ignore };
|
||||
|
||||
inline CalPanelAnswer calPanelAnswer(const char *msg) {
|
||||
char source[16];
|
||||
int value;
|
||||
if (!std::strncmp(msg, "btn trigger 1", 13) || !std::strncmp(msg, "gazekey left 1", 14))
|
||||
return CalPanelAnswer::Accept;
|
||||
if ((std::sscanf(msg, "precision %15s %d", source, &value) == 2 ||
|
||||
std::sscanf(msg, "gazedrag %15s %d", source, &value) == 2) && value == 1)
|
||||
return CalPanelAnswer::Accept;
|
||||
if (!std::strncmp(msg, "btn b 1", 7) || !std::strncmp(msg, "gazekey right 1", 15))
|
||||
return CalPanelAnswer::Quit;
|
||||
if (!std::strncmp(msg, "btn ", 4) || !std::strncmp(msg, "gazekey ", 8) ||
|
||||
!std::strncmp(msg, "precision ", 10) || !std::strncmp(msg, "gazedrag ", 9))
|
||||
return CalPanelAnswer::Ignore;
|
||||
return CalPanelAnswer::None;
|
||||
}
|
||||
@@ -227,8 +227,9 @@
|
||||
// drags again from there. Without gaze mode they work from wherever the pointer is.
|
||||
// The gaze calibration panel (gaze/panel/ft-gazepanel, run by the gaze service): while
|
||||
// ft-gazed says it's up ("calpanel 1", renewed every second; it lapses 3 s after the last),
|
||||
// the dot hides and a press answers the panel instead of clicking: a left click or gaze_left
|
||||
// sends "calaccept" to @ft_gazed (take this dot now), a right click or gaze_right "calquit".
|
||||
// the dot hides and a press answers the panel instead of clicking: a left click, gaze_left, or
|
||||
// a gaze_precision or gaze_drag press sends "calaccept" to @ft_gazed (take this dot now), a right
|
||||
// click or gaze_right "calquit" (calpanel.h).
|
||||
// POINTER_ROLE (right, left, or stylus): the hand role our device takes while connected. A
|
||||
// Frame controller in your hand counts as used through its touch sensors and takes its hand's
|
||||
// role back, and then no click lands (see "no hand role" in the main loop): with a controller
|
||||
@@ -316,6 +317,7 @@
|
||||
// POINTER_ROLE (right): gaze precision and keyboard clicks, above.
|
||||
#include <openvr.h>
|
||||
|
||||
#include "calpanel.h"
|
||||
#include "vrbuttons.h"
|
||||
#include "vrmath.h"
|
||||
|
||||
@@ -1485,14 +1487,12 @@ int main() {
|
||||
}
|
||||
}
|
||||
if (Clock::now() < calPanelUntil) {
|
||||
const bool accept = !std::strncmp(buf, "btn trigger 1", 13) || !std::strncmp(buf, "gazekey left 1", 14);
|
||||
const bool quit = !std::strncmp(buf, "btn b 1", 7) || !std::strncmp(buf, "gazekey right 1", 15);
|
||||
if (accept || quit) {
|
||||
SendTo(out, "ft_gazed", accept ? "calaccept" : "calquit");
|
||||
const CalPanelAnswer answer = calPanelAnswer(buf);
|
||||
if (answer == CalPanelAnswer::Accept || answer == CalPanelAnswer::Quit) {
|
||||
SendTo(out, "ft_gazed", answer == CalPanelAnswer::Accept ? "calaccept" : "calquit");
|
||||
continue;
|
||||
}
|
||||
if (!std::strncmp(buf, "btn ", 4) || !std::strncmp(buf, "gazekey ", 8) ||
|
||||
!std::strncmp(buf, "precision ", 10) || !std::strncmp(buf, "gazedrag ", 9))
|
||||
if (answer == CalPanelAnswer::Ignore)
|
||||
continue; // their releases, and the other buttons: nothing to click now
|
||||
}
|
||||
{
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Offline test of the gaze calibration panel's answers (pointer/helper/calpanel.h): which relay
|
||||
// messages take the dot, close the panel, or do nothing while it's up. Needs no SteamVR.
|
||||
//
|
||||
// pointer/test/calpanel-test.sh
|
||||
#include "../helper/calpanel.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
int main() {
|
||||
struct Case {
|
||||
const char *msg;
|
||||
CalPanelAnswer want;
|
||||
} cases[] = {
|
||||
{"btn trigger 1", CalPanelAnswer::Accept},
|
||||
{"gazekey left 1", CalPanelAnswer::Accept},
|
||||
{"precision mouse 1", CalPanelAnswer::Accept},
|
||||
{"precision keyboard 1", CalPanelAnswer::Accept},
|
||||
{"gazedrag mouse 1", CalPanelAnswer::Accept},
|
||||
{"gazedrag keyboard 1", CalPanelAnswer::Accept},
|
||||
{"btn b 1", CalPanelAnswer::Quit},
|
||||
{"gazekey right 1", CalPanelAnswer::Quit},
|
||||
{"btn trigger 0", CalPanelAnswer::Ignore},
|
||||
{"btn b 0", CalPanelAnswer::Ignore},
|
||||
{"btn a 1", CalPanelAnswer::Ignore}, // the relay's laser claim
|
||||
{"btn x 1", CalPanelAnswer::Ignore},
|
||||
{"btn system 1", CalPanelAnswer::Ignore},
|
||||
{"gazekey left 0", CalPanelAnswer::Ignore},
|
||||
{"gazekey right 0", CalPanelAnswer::Ignore},
|
||||
{"precision mouse 0", CalPanelAnswer::Ignore},
|
||||
{"gazedrag mouse 0", CalPanelAnswer::Ignore},
|
||||
{"move 0.1000 -0.2000", CalPanelAnswer::None},
|
||||
{"show", CalPanelAnswer::None},
|
||||
{"recenter", CalPanelAnswer::None},
|
||||
{"scroll 0 1", CalPanelAnswer::None},
|
||||
{"typing", CalPanelAnswer::None},
|
||||
};
|
||||
const char *names[] = {"none", "accept", "quit", "ignore"};
|
||||
int failed = 0;
|
||||
for (const Case &c : cases) {
|
||||
const CalPanelAnswer got = calPanelAnswer(c.msg);
|
||||
if (got != c.want) {
|
||||
std::printf("FAIL \"%s\": %s, want %s\n", c.msg, names[int(got)], names[int(c.want)]);
|
||||
++failed;
|
||||
}
|
||||
}
|
||||
std::printf("%s: %d of %zu cases\n", failed ? "FAILED" : "ok", int(sizeof cases / sizeof cases[0]) - failed,
|
||||
sizeof cases / sizeof cases[0]);
|
||||
return failed ? 1 : 0;
|
||||
}
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Offline test of the gaze calibration panel's answers (pointer/test/calpanel-test.cpp): builds
|
||||
# it in the dev container and runs it there. Nothing reaches SteamVR, so it's safe next to it.
|
||||
#
|
||||
# pointer/test/calpanel-test.sh
|
||||
set -euo pipefail
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
"$root/scripts/sync.sh" >/dev/null
|
||||
exec "$root/scripts/frame.sh" -C pointer 'set -e; mkdir -p test/build
|
||||
g++ -std=c++17 -O2 -Wall -o test/build/calpanel-test test/calpanel-test.cpp
|
||||
test/build/calpanel-test'
|
||||
+16
-2
@@ -686,9 +686,15 @@ static void keyboard_command(struct server *s, const char *what, char *reply, in
|
||||
return (void)snprintf(reply, size, "ok closed");
|
||||
}
|
||||
if (open) return (void)snprintf(reply, size, "ok open");
|
||||
if (!ft_vr_screens_shown()) return (void)snprintf(reply, size, "ok screens hidden");
|
||||
if (!ft_vr_screens_shown()) {
|
||||
wlr_log(WLR_INFO, "keyboard not opened (%s): the screens are hidden", toggle ? "button" : "text field");
|
||||
return (void)snprintf(reply, size, "ok screens hidden");
|
||||
}
|
||||
const int screen = focused_screen(s);
|
||||
if (screen < 0 || !ft_vr_keyboard_show(screen)) return (void)snprintf(reply, size, "error not shown");
|
||||
if (screen < 0 || !ft_vr_keyboard_show(screen)) {
|
||||
wlr_log(WLR_INFO, "keyboard not opened (%s) for screen %d", toggle ? "button" : "text field", screen + 1);
|
||||
return (void)snprintf(reply, size, "error not shown");
|
||||
}
|
||||
wlr_log(WLR_INFO, "keyboard open for screen %d (%s)", screen + 1, toggle ? "button" : "text field");
|
||||
s->kb_screen = screen;
|
||||
s->kb_auto = !toggle;
|
||||
@@ -831,6 +837,14 @@ 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 if (strcmp(buf, "debug") == 0) {
|
||||
// ft-screens' side of scripts/report.sh's debug line; vr.cpp adds SteamVR's.
|
||||
char vr[1024] = "ok vr=off";
|
||||
if (s->vr) ft_vr_command("debug", vr, sizeof vr);
|
||||
snprintf(reply, sizeof reply, "%s kb_screen=%d kb_auto=%d typing=%s focused_screen=%d pointer_screen=%d",
|
||||
vr, s->kb_screen + 1, s->kb_auto ? 1 : 0,
|
||||
s->key_remote >= 0 ? "remote" : s->keys_desktop ? "desktop" : s->keys_clicked ? "desktop (hidden)" : "steam",
|
||||
focused_screen(s) + 1, s->pointer_focus ? s->pointer_focus->index + 1 : 0);
|
||||
} else if (!ft_remote_command(buf, reply, sizeof reply)) {
|
||||
ft_vr_command(buf, reply, sizeof reply);
|
||||
}
|
||||
|
||||
+48
-1
@@ -329,6 +329,28 @@ struct Screen {
|
||||
}
|
||||
};
|
||||
std::map<int, Screen> g_screens;
|
||||
|
||||
// For the log and the debug line (scripts/report.sh): which screen, which drag, which laser.
|
||||
int ScreenIndexOf(const Screen &s) {
|
||||
for (const auto &[index, t] : g_screens)
|
||||
if (&t == &s) return index;
|
||||
return -1;
|
||||
}
|
||||
const char *DragName(Drag d) {
|
||||
switch (d) {
|
||||
case Drag::Move: return "move";
|
||||
case Drag::Resize: return "resize";
|
||||
case Drag::Roll: return "roll";
|
||||
default: return "none";
|
||||
}
|
||||
}
|
||||
std::string DeviceLabel(vr::TrackedDeviceIndex_t i) {
|
||||
if (i == kNone) return "none";
|
||||
if (vr::VRSystem()->GetTrackedDeviceClass(i) == vr::TrackedDeviceClass_Controller && !IsHandController(i))
|
||||
return "3D mouse";
|
||||
const std::string hand = HandName(i);
|
||||
return hand == "none" ? "device " + std::to_string(i) : hand + " controller";
|
||||
}
|
||||
std::map<const void *, vr::SharedTextureHandle_t> g_imports;
|
||||
|
||||
// Hand cutouts (see the top and handcut.h).
|
||||
@@ -1126,7 +1148,11 @@ vr::TrackedDeviceIndex_t WristOnLaser(const Screen &s, const Mat &d, const Mat &
|
||||
|
||||
void StartDrag(Screen &s, Drag mode, vr::TrackedDeviceIndex_t dev) {
|
||||
Mat d, p;
|
||||
if (dev == kNone || !DevicePose(dev, &d) || !ScreenPose(s, &p)) return;
|
||||
if (dev == kNone || !DevicePose(dev, &d) || !ScreenPose(s, &p)) {
|
||||
std::printf("screen %d: %s not started: no pose for the %s or the screen\n", ScreenIndexOf(s) + 1,
|
||||
DragName(mode), DeviceLabel(dev).c_str());
|
||||
return;
|
||||
}
|
||||
s.pinTarget = kNone;
|
||||
if (s.pinned != kNone && mode == Drag::Move) {
|
||||
// Carried freely; let go, it goes back on the same wrist (unless disarmed).
|
||||
@@ -1150,11 +1176,15 @@ void StartDrag(Screen &s, Drag mode, vr::TrackedDeviceIndex_t dev) {
|
||||
Mat l;
|
||||
if (!LaserPose(dev, &l) || !RollLaserAngle(s, l, &s.rollAngle)) s.drag = Drag::None, s.dragDevice = kNone;
|
||||
}
|
||||
if (s.drag != Drag::None)
|
||||
std::printf("screen %d: %s by the %s%s\n", ScreenIndexOf(s) + 1, DragName(mode), DeviceLabel(dev).c_str(),
|
||||
vr::VROverlay()->IsDashboardVisible() ? " (Steam menu open)" : "");
|
||||
ApplyAlpha(s);
|
||||
}
|
||||
|
||||
// Stop moving where it is (a command took over).
|
||||
void EndDrag(Screen &s) {
|
||||
if (s.drag != Drag::None) std::printf("screen %d: %s ended\n", ScreenIndexOf(s) + 1, DragName(s.drag));
|
||||
s.drag = Drag::None;
|
||||
s.dragDevice = kNone;
|
||||
s.pinTarget = s.onWrist = kNone;
|
||||
@@ -1984,6 +2014,8 @@ void UpdateSteamInFront() {
|
||||
const bool front = SteamInFront();
|
||||
if (front == g_steamInFront) return;
|
||||
g_steamInFront = front;
|
||||
std::printf("Steam %s%s\n", front ? "in front (the Steam menu or Steam's keyboard)" : "out of the way",
|
||||
front && keyboard::Shown() ? ": our keyboard steps aside" : !front && g_keyboardAside ? ": our keyboard comes back" : "");
|
||||
if (front && keyboard::Shown()) {
|
||||
g_asidePose = keyboard::Pose();
|
||||
keyboard::Hide();
|
||||
@@ -2838,6 +2870,21 @@ void ft_vr_command(const char *cmd, char *reply, int size) {
|
||||
std::snprintf(reply, size, "ok");
|
||||
} else if (std::sscanf(cmd, "spin %15s", word) == 1) {
|
||||
SpinCommand(word, reply, size);
|
||||
} else if (std::strcmp(cmd, "debug") == 0) {
|
||||
// One line for scripts/report.sh: what decides whether a laser drags and the keyboard shows.
|
||||
std::string drags;
|
||||
for (const auto &[index, s] : g_screens)
|
||||
if (s.drag != Drag::None)
|
||||
drags += (drags.empty() ? "" : ",") + std::to_string(index + 1) + ":" + DragName(s.drag) + ":" +
|
||||
DeviceLabel(s.dragDevice);
|
||||
std::snprintf(reply, size,
|
||||
"ok dashboard=%d steam_front=%d keyboard=%s mode=%s manual=%d lasers=%s game=%d paused=%d "
|
||||
"press=%#x by=%s on=%d catcher=%d drags=%s",
|
||||
vr::VROverlay()->IsDashboardVisible() ? 1 : 0, SteamInFront() ? 1 : 0,
|
||||
keyboard::Shown() ? "shown" : g_keyboardAside ? "aside" : "hidden", ModeName(), g_manual ? 1 : 0,
|
||||
LasersName(), g_gameRunning ? 1 : 0, g_paused ? 1 : 0, g_press.buttons,
|
||||
DeviceLabel(g_press.device).c_str(), g_press.screen + 1, g_catcherShown ? 1 : 0,
|
||||
drags.empty() ? "-" : drags.c_str());
|
||||
} else if (std::strncmp(cmd, "state", 5) == 0) {
|
||||
std::snprintf(reply, size, "ok %s %d %.0f %s %.0f %s %d %s", ModeName(), g_manual ? 1 : 0, g_wristAngle,
|
||||
g_gestureHand.c_str(), g_gestureAngle, LasersName(), g_gameRunning ? 1 : 0,
|
||||
|
||||
+5
-1
@@ -44,7 +44,11 @@ else
|
||||
fi
|
||||
check "SteamOS" on_home '. /etc/os-release; echo "$PRETTY_NAME $VERSION_ID build $BUILD_ID, $(uname -m)"'
|
||||
check "distrobox" on_home 'test -x ~/.local/bin/distrobox && ~/.local/bin/distrobox version | head -1'
|
||||
check "container $FRAME_BOX" on_home "podman ps -a --filter name=^$FRAME_BOX\$ --format '{{.Image}} {{.Status}}' | grep ."
|
||||
# A release runs in its own container, which its .frametop-release names (as scripts/in-box
|
||||
# reads it); a checkout builds and runs in FRAME_BOX.
|
||||
box=$FRAME_BOX
|
||||
[ "$FRAME_RELEASE" = 1 ] && box=${FRAMETOP_BOX:-$(sed -n 's/^BOX=//p' "$REPO_ROOT/.frametop-release" | tail -1)}
|
||||
check "container ${box:-(none named)}" on_home "podman ps -a --filter name=^$box\$ --format '{{.Image}} {{.Status}}' | grep ."
|
||||
check "free space in ~" on_home "df -h ~ | awk 'NR==2{print \$4\" free\"}'"
|
||||
if check "repo on the Frame" on_frame 'pwd'; then
|
||||
# A taskbar saved on a screen the desktop doesn't have is hidden; the desktop's next start
|
||||
|
||||
@@ -204,7 +204,7 @@ def main():
|
||||
with open(path, "w") as f:
|
||||
f.write(text)
|
||||
print(f"Wrote {path}")
|
||||
print("Attach it to https://github.com/DeeJanuz/frametop/issues/2 with what you saw at each step.")
|
||||
print("Attach it to https://github.com/Frametop/frametop/issues/2 with what you saw at each step.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+124
-8
@@ -1,11 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Collect what a Frametop bug report needs into one text file: versions, service states,
|
||||
# settings, recent logs, and the gaze report (scripts/gaze-report.py). Bluetooth addresses and
|
||||
# the headset's serial number are masked.
|
||||
# Usage: scripts/report.sh (in a terminal on the Frame, or from a PC over SSH)
|
||||
# settings, Frametop's keyboard and the Steam menu, recent logs, and the gaze report
|
||||
# (scripts/gaze-report.py). Bluetooth addresses and the headset's serial number are masked.
|
||||
# Usage: scripts/report.sh [--watch [SECONDS]] (in a terminal on the Frame, or from a PC over SSH)
|
||||
# --watch then record SECONDS (default 60) while you make the problem happen: the
|
||||
# desktop's state (the Steam menu, our keyboard, which laser drags what, where
|
||||
# typing goes) whenever it changes, and the logs from that time
|
||||
set -euo pipefail
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
. "$root/scripts/_env.sh"
|
||||
watch=0
|
||||
case ${1:-} in
|
||||
"") ;;
|
||||
--watch) watch=${2:-60}
|
||||
[[ $watch =~ ^[0-9]+$ ]] && [ "$watch" -ge 5 ] && [ "$watch" -le 600 ] ||
|
||||
{ echo "--watch takes 5 to 600 seconds" >&2; exit 2; } ;;
|
||||
-h|--help) sed -n '2,8p' "$0"; exit 0 ;;
|
||||
*) echo "usage: $0 [--watch [SECONDS]]" >&2; exit 2 ;;
|
||||
esac
|
||||
out=$root/frametop-report-$(date +%Y%m%d-%H%M%S).txt
|
||||
echo "Collecting (up to half a minute: the gaze service is woken to see whether the eye tracker sends)..."
|
||||
|
||||
@@ -14,18 +26,45 @@ repo=$1
|
||||
export XDG_RUNTIME_DIR=/run/user/$(id -u) DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus
|
||||
logs=~/.local/share/Steam/logs
|
||||
section() { printf '\n===== %s\n' "$*"; }
|
||||
# ask SOCKET COMMAND: a request to one of Frametop's abstract datagram sockets; prints the reply.
|
||||
ask() {
|
||||
python3 -I - "$1" "$2" <<'PY' 2>&1
|
||||
import socket, sys
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
s.bind("")
|
||||
s.settimeout(2)
|
||||
try:
|
||||
s.sendto(sys.argv[2].encode(), "\0" + sys.argv[1])
|
||||
print(s.recv(65536).decode())
|
||||
except OSError as e:
|
||||
print(f"no answer from @{sys.argv[1]} ({e})")
|
||||
PY
|
||||
}
|
||||
|
||||
section Versions
|
||||
grep -E '^(PRETTY_NAME|VERSION_ID|BUILD_ID|VARIANT_ID)=' /etc/os-release
|
||||
grep -m1 -o 'vrcompositor [0-9.]* startup' $logs/vrcompositor.txt 2>/dev/null || echo "SteamVR: not found"
|
||||
git -C "$repo" log -1 --format='Frametop %h (%cd)' --date=short 2>/dev/null || echo "Frametop: not a git checkout"
|
||||
# The log keeps earlier runs: the last start is this SteamVR.
|
||||
grep -a -o 'vrcompositor [0-9.]* startup' $logs/vrcompositor.txt 2>/dev/null | tail -n 1 | grep . || echo "SteamVR: not found"
|
||||
if [ -f "$repo/.frametop-release" ]; then
|
||||
rel() { sed -n "s/^$1=//p" "$repo/.frametop-release" | tail -1; }
|
||||
echo "Frametop release $(rel VERSION) ($(rel COMMIT | cut -c1-7), $(rel CHANNEL)) in $repo"
|
||||
box=$(rel BOX)
|
||||
else
|
||||
git -C "$repo" log -1 --format='Frametop %h (%cd)' --date=short 2>/dev/null || echo "Frametop: not a git checkout"
|
||||
git -C "$repo" branch --show-current 2>/dev/null | sed 's/^/branch: /'
|
||||
box=${FRAME_BOX:-dev}
|
||||
fi
|
||||
~/.local/bin/distrobox version 2>/dev/null || echo "distrobox: not installed"
|
||||
podman container inspect -f 'dev container: running={{.State.Running}} image={{.ImageName}}' dev 2>/dev/null || echo "dev container: missing"
|
||||
podman container inspect -f "container $box: running={{.State.Running}} image={{.ImageName}}" "$box" 2>/dev/null ||
|
||||
echo "container $box: missing"
|
||||
|
||||
section Services
|
||||
for u in frametop-input-relay frametop-pointer frametop-power; do
|
||||
for u in frametop-input-relay frametop-pointer frametop-power frametop-gaze frametop-desktop; do
|
||||
echo "$u: $(systemctl --user is-enabled $u 2>/dev/null) / $(systemctl --user is-active $u 2>/dev/null)"
|
||||
done
|
||||
for u in frametop-eyegrab steamframe-bt-fixups; do
|
||||
echo "$u (system): $(systemctl is-enabled $u 2>/dev/null) / $(systemctl is-active $u 2>/dev/null)"
|
||||
done
|
||||
echo "desktop: $(pgrep -x ft-screens >/dev/null && echo running || echo 'not running'), plasmashell: $(pgrep -c plasmashell || true)"
|
||||
echo "paused for VR games: $(cat /run/user/$(id -u)/frametop-pause.json 2>/dev/null || echo 'no (never paused since boot)')"
|
||||
# SteamVR's config folder: a terminal in the desktop has the session's own (docs/design.md).
|
||||
@@ -64,6 +103,46 @@ r = subprocess.run(["qdbus6", "org.kde.plasmashell", "/PlasmaShell", "org.kde.Pl
|
||||
print("live panels:", (r.stdout or r.stderr).strip())
|
||||
PY
|
||||
|
||||
section "Frametop's keyboard and the Steam menu"
|
||||
python3 - "$(ask frametop_relay devices)" <<'PY' 2>&1
|
||||
import json, os, sys
|
||||
try:
|
||||
rules = json.load(open(os.path.expanduser("~/.config/frametop-input.json")))
|
||||
except (OSError, ValueError):
|
||||
rules = {}
|
||||
print("Keyboard setting (Input Settings > Keyboard):", rules.get("vr_keyboard", "no_keyboard (the default)"),
|
||||
"| keep it open:", rules.get("vr_keyboard_persist", True))
|
||||
print("the relay's devices (with the default setting, a pass-through keyboard keeps ours closed):")
|
||||
try:
|
||||
d = json.loads(sys.argv[1])
|
||||
except ValueError:
|
||||
sys.exit(print(" " + sys.argv[1].strip()))
|
||||
print(f" pointer mode: {d.get('pointer_mode')}, paused: {d.get('paused')}")
|
||||
for n in d.get("nodes", []):
|
||||
print(" " + json.dumps(n))
|
||||
PY
|
||||
echo "ft-textinput (tells the relay about text fields): $(pgrep -f '[f]t-textinput' >/dev/null && echo running || echo 'not running')"
|
||||
echo "the desktop's KWin input method: $(pgrep -a kwin_wayland | grep -o -- '--inputmethod [^ ]*' | head -n 1 | grep . || echo 'none (the keyboard never opens for text fields)')"
|
||||
for p in $(pgrep -x plasmashell); do
|
||||
env=$(tr '\0' '\n' < /proc/$p/environ 2>/dev/null) || continue
|
||||
echo "$env" | grep -q '^XDG_CONFIG_HOME=.*/frametop$' || continue
|
||||
for v in QT_IM_MODULE GTK_IM_MODULE; do # xim here: Qt and GTK apps never ask for the keyboard
|
||||
printf "the desktop's %s: %s\n" $v "$(echo "$env" | sed -n "s/^$v=//p" | grep . || echo unset)"
|
||||
done
|
||||
done
|
||||
echo "ft-screens debug: $(ask ft_screens debug)"
|
||||
echo "ft-screens state (mode, manual, wrist, gesture hand and angle, lasers, game, in games): $(ask ft_screens state)"
|
||||
echo "ft-screens pause: $(ask ft_screens 'pause state'), concealed: $(ask ft_screens concealed)"
|
||||
echo "SteamVR overlays shown now:"
|
||||
XDG_CONFIG_HOME=$HOME/.config LD_LIBRARY_PATH=/opt/steamvr/bin/linuxarm64 timeout 10 \
|
||||
/opt/steamvr/bin/linuxarm64/vrcmd --overlays 2>/dev/null |
|
||||
awk '/^\x27/ { show = $0 !~ /not_visible/ } show' | sed 's/^/ /' | head -n 80
|
||||
echo "keyboard, drag and Steam menu lines from ft-screens (last 40):"
|
||||
grep -a -E 'keyboard|Steam (in front|out of the way)|: (move|resize|roll)( by| ended| not started)' \
|
||||
/tmp/frametop-screens.log 2>/dev/null | tail -n 40 | sed 's/^/ /'
|
||||
echo "text fields, from the relay (last 20):"
|
||||
journalctl --user -u frametop-input-relay --no-pager -o short 2>/dev/null | grep 'text field' | tail -n 20 | sed 's/^/ /'
|
||||
|
||||
section "Input relay (last 60 lines)"
|
||||
journalctl --user -u frametop-input-relay -n 60 --no-pager -o short 2>/dev/null
|
||||
section "Pointer helper (last 60 lines)"
|
||||
@@ -81,6 +160,43 @@ section "SteamVR server: Frametop and errors (last 60 lines)"
|
||||
grep -a -i -E 'ft_pointer|frametop|\[error\]' $logs/vrserver.txt 2>/dev/null | tail -n 60
|
||||
EOF
|
||||
|
||||
if [ "$watch" -gt 0 ]; then
|
||||
echo
|
||||
echo "Now make the problem happen within $watch seconds: for example, open the Steam menu, try to"
|
||||
echo "drag a window by its bar, and click into a text field. Recording..."
|
||||
on_frame_script "$watch" >> "$out" 2>&1 <<'EOF' || true
|
||||
seconds=$1
|
||||
printf '\n===== Watched for %s s from %s\n' "$seconds" "$(date '+%F %T')"
|
||||
since=$(date '+%F %T')
|
||||
start=$(wc -l < /tmp/frametop-screens.log 2>/dev/null || echo 0)
|
||||
python3 -I - "$seconds" <<'PY'
|
||||
import socket, sys, time
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
s.bind("")
|
||||
s.settimeout(1)
|
||||
end, last = time.monotonic() + float(sys.argv[1]), None
|
||||
print("ft-screens' debug line whenever it changed:")
|
||||
while time.monotonic() < end:
|
||||
try:
|
||||
s.sendto(b"debug", "\0ft_screens")
|
||||
line = s.recv(4096).decode()
|
||||
except OSError as e:
|
||||
line = f"no answer ({e})"
|
||||
if line != last:
|
||||
print(time.strftime("%H:%M:%S") + f"{time.time() % 1:.2f}"[1:], line, flush=True)
|
||||
last = line
|
||||
time.sleep(0.2)
|
||||
PY
|
||||
printf '\n===== ft-screens log while watching\n'
|
||||
tail -n +"$((start + 1))" /tmp/frametop-screens.log 2>/dev/null | tail -n 300
|
||||
for u in frametop-input-relay frametop-pointer; do
|
||||
printf '\n===== %s while watching\n' "$u"
|
||||
journalctl --user -u $u --since "$since" --no-pager -o short 2>/dev/null | tail -n 200
|
||||
done
|
||||
EOF
|
||||
echo "Recorded."
|
||||
fi
|
||||
|
||||
sed -i -E 's/([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}/xx:xx:xx:xx:xx:xx/g; s/cv\.[A-Z0-9]{8,}/cv.<serial>/g' "$out"
|
||||
echo "wrote $out"
|
||||
echo "Attach it to an issue at https://github.com/DeeJanuz/frametop/issues, with what you did, what you expected, and what happened."
|
||||
echo "Attach it to an issue at https://github.com/Frametop/frametop/issues, with what you did, what you expected, and what happened."
|
||||
+64
-3
@@ -41,6 +41,9 @@ VRPATHS = os.path.join(HOME, ".config/openvr/openvrpaths.vrpath")
|
||||
STRAY_VRPATHS = os.path.join(HOME, ".config/frametop/openvr/openvrpaths.vrpath")
|
||||
BACKLIGHT = "/sys/class/backlight/ae94000.dsi.0/brightness" # as in power/ft-powerd.cpp
|
||||
EYE_MMAP = "/dev/shm/eye-server.mmap"
|
||||
# SteamVR's settings, where SteamOS 0.4's Track Dominant Eye Only lives (as gaze/gazecal.py reads it).
|
||||
STEAMVR_SETTINGS = (os.path.join(HOME, ".config/openvr/config/steamvr.vrsettings"),
|
||||
os.path.join(HOME, ".steam/steam/config/steamvr.vrsettings"))
|
||||
VRSERVER = "http://127.0.0.1:27062" # its web socket: input/vrws.py
|
||||
PAUSE_STATE = f"/run/user/{os.getuid()}/frametop-pause.json" # input/game_pause.py
|
||||
HOST_GLIBC = (2, 39) # the newest the pointer driver may need (pointer/driver/build.sh)
|
||||
@@ -48,7 +51,8 @@ HOST_GLIBC = (2, 39) # the newest the pointer driver may need (pointer/driver/b
|
||||
# Packages in the OS image that Frametop depends on, and what to try by hand when one changes.
|
||||
PACKAGES = {
|
||||
"deckard-steamvr-rel": "the 3D mouse on the dashboard, on SteamVR Settings, and on a SteamVR "
|
||||
"window's grab bar; picking up a controller; mapped controller buttons; gaze",
|
||||
"window's grab bar; picking up a controller; mapped controller buttons; gaze, "
|
||||
"and a gaze calibration with Track Dominant Eye Only on",
|
||||
"kwin": "clicks near the far edge of a screen whose scale isn't 1; every screen comes back "
|
||||
"after a desktop restart; floating a window; no blur behind the taskbar's menus",
|
||||
"plasma-workspace": "the taskbar and panels after a desktop restart; no DiscoverNotifier or "
|
||||
@@ -57,7 +61,10 @@ PACKAGES = {
|
||||
"the registry stops and comes back after a desktop restart",
|
||||
"gamescope": "the headset's volume buttons with nothing focused; typing goes where you last clicked",
|
||||
"bluez": "a Bluetooth mouse reconnecting after it sleeps",
|
||||
"steamdeck-kde-presets": "Launch a program -> Native Desktop opens SteamOS's own desktop",
|
||||
"steamdeck-kde-presets": "Launch a program -> Native Desktop opens SteamOS's own desktop; starting "
|
||||
"the desktop sends Steam no command line (no ExecCommandLine in "
|
||||
"~/.local/share/Steam/logs/console_log.txt)",
|
||||
"holo-cursors": "the desktop keeps its Breeze cursor (over remote access, and with the gamescope backend)",
|
||||
}
|
||||
KERNEL_HINT = "display power (ft-powerd) and hand tracking"
|
||||
|
||||
@@ -75,7 +82,7 @@ UNITS = {
|
||||
# layouts its EyeFile::Detect knows: everything from the timestamp on moved by a shift.
|
||||
EYE_COUNTER, EYE_TIME, EYE_NEED = 0x38, 0x157, 0x1F3 + 5
|
||||
EYE_SET1 = (0x15F, 0x16B) # set 1 left, right
|
||||
EYE_LAYOUTS = {0: "stable", 5: "the 0.4.x beta's (+5)"}
|
||||
EYE_LAYOUTS = {0: "SteamOS 0.3's", 5: "SteamOS 0.4's (+5)"}
|
||||
|
||||
# Runs in a child process, so a SteamVR that hangs can't hang the check.
|
||||
OPENVR_PROBE = r"""
|
||||
@@ -185,6 +192,8 @@ def check_host():
|
||||
("/etc/profile.d/flatpak.sh", "warn", "Flatpak apps may open Discover instead of starting"),
|
||||
(STOCK_LAUNCHER, "warn", "SteamOS's Desktop launcher entry is gone or renamed, so Frametop's "
|
||||
"copy may not replace it, and there's no Native Desktop"),
|
||||
("/usr/share/steamos/steamos-cursor.png", "warn",
|
||||
"the gamescope backend has no cursor image (SteamOS 0.4's own session moved to /usr/share/holo)"),
|
||||
]
|
||||
missing = [n for n in needed if not os.path.exists(n[0])]
|
||||
for path, state, effect in missing:
|
||||
@@ -312,6 +321,36 @@ def installed_binaries():
|
||||
return found
|
||||
|
||||
|
||||
# Frametop's root files in /etc, by what installs them. A SteamOS update deletes every /etc file
|
||||
# its keep list (/usr/lib/rauc/atomic-update-keep.conf) doesn't name: it keeps the units, but not
|
||||
# what they run, unless a drop-in in /etc/atomic-update.conf.d names that too.
|
||||
ROOT_PARTS = [
|
||||
("Bluetooth fixes", "/etc/systemd/system/steamframe-bt-fixups.service", "/etc/steamframe/bt-fixups.sh",
|
||||
"/etc/atomic-update.conf.d/frametop-bluetooth.conf", "setup/bluetooth/install.sh install"),
|
||||
("eye tracker's frame grabber", "/etc/systemd/system/frametop-eyegrab.service", "/etc/frametop/ft-eyegrab",
|
||||
"/etc/atomic-update.conf.d/frametop-eyegrab.conf", "gaze/tracker/install.sh"),
|
||||
]
|
||||
|
||||
|
||||
def check_root_files():
|
||||
for label, unit, program, keep, install in ROOT_PARTS:
|
||||
if not os.path.exists(unit):
|
||||
continue
|
||||
try:
|
||||
with open(keep) as f:
|
||||
kept = program in (line.strip() for line in f)
|
||||
except OSError:
|
||||
kept = False
|
||||
if not os.path.exists(program):
|
||||
report("FAIL", label, f"{program} is gone (a SteamOS update deletes it unless it's kept); "
|
||||
f"reinstall with {install}")
|
||||
elif not kept:
|
||||
report("warn", label, f"the next SteamOS update will delete {program}; "
|
||||
f"reinstall with {install}, which keeps it")
|
||||
else:
|
||||
report("ok", label, "installed, and kept through SteamOS updates")
|
||||
|
||||
|
||||
def check_openvr():
|
||||
needs = {} # interface version: programs built against it
|
||||
for path in installed_binaries():
|
||||
@@ -471,8 +510,29 @@ def check_vr_socket():
|
||||
"(input/vrws.py: the message format changed?)")
|
||||
|
||||
|
||||
def tracked_eye():
|
||||
"""gaze/gazecal.py's tracked_eye: the one eye SteamVR's tracker follows with Track Dominant
|
||||
Eye Only on (0 left, 1 right), or None."""
|
||||
for path in STEAMVR_SETTINGS:
|
||||
try:
|
||||
with open(path) as f:
|
||||
steamvr = json.load(f).get("steamvr")
|
||||
except OSError:
|
||||
continue
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
if not isinstance(steamvr, dict) or steamvr.get("eyeTrackingDominantEyeOnly") is not True:
|
||||
return None
|
||||
return 0 if steamvr.get("dominantEye", 1) == 0 else 1
|
||||
return None
|
||||
|
||||
|
||||
def check_eye_tracker():
|
||||
gaze = systemctl("is-enabled", "frametop-gaze") == "enabled"
|
||||
one = tracked_eye()
|
||||
if one is not None:
|
||||
report("ok", "eye tracking", f"SteamVR tracks your {('left', 'right')[one]} eye only (Track Dominant "
|
||||
"Eye Only), and Frametop's gaze goes by that eye")
|
||||
try:
|
||||
with open(EYE_MMAP, "rb") as f:
|
||||
m = mmap.mmap(f.fileno(), 0, prot=mmap.PROT_READ)
|
||||
@@ -522,6 +582,7 @@ def main():
|
||||
versions = current_versions()
|
||||
check_versions(versions)
|
||||
check_host()
|
||||
check_root_files()
|
||||
steamvr_up = run("pgrep", "-x", "vrserver")[0] == 0
|
||||
if not steamvr_up:
|
||||
report("skip", "SteamVR checks", "SteamVR isn't running")
|
||||
|
||||
@@ -178,6 +178,16 @@ export XDG_CONFIG_HOME=$HOME/.config/frametop
|
||||
export XDG_STATE_HOME=$HOME/.local/state/frametop
|
||||
mkdir -p "$XDG_CONFIG_HOME" "$XDG_STATE_HOME"
|
||||
|
||||
# The cursor: the Steam client's XCURSOR_THEME=steam comes along in the environment, and
|
||||
# KWin and the apps take it over this desktop's own setting. SteamOS had no theme of that
|
||||
# name, so they fell back to Breeze; SteamOS 0.4 has one (holo-cursors: Steam's arrow, the
|
||||
# rest Breeze Light). The theme is this desktop's own (System Settings, Breeze unless
|
||||
# changed there); the size stays what it was, unless one is set there too.
|
||||
cursor_theme=$(kreadconfig6 --file kcminputrc --group Mouse --key cursorTheme)
|
||||
cursor_size=$(kreadconfig6 --file kcminputrc --group Mouse --key cursorSize)
|
||||
export XCURSOR_THEME=${cursor_theme:-breeze_cursors}
|
||||
[ -z "$cursor_size" ] || export XCURSOR_SIZE=$cursor_size
|
||||
|
||||
# Remote desktop over VNC: session/remote-desktop.sh captures the desktop with
|
||||
# krdp on 127.0.0.1, and session/vnc-bridge.sh re-serves its primary screen over VNC. krdpserver runs from the container, so KWin can't
|
||||
# match it to an installed app. KWin's permission check for screencast and fake
|
||||
@@ -273,15 +283,24 @@ fi
|
||||
# 600 MB and a share of a core, with Flatpak's helper and AppStream behind it); updates
|
||||
# come with SteamOS and from Discover in the stock desktop. IBus can't reach the
|
||||
# desktop's apps: KWin's input method is ft-textinput, and the session drops the
|
||||
# variables that point apps at IBus or XIM. Deleting the copy brings an entry back.
|
||||
if [ "$(kreadconfig6 --file "$frametoprc" --group Defaults --key autostart)" != 1 ]; then
|
||||
for entry in org.kde.discover.notifier ibus; do
|
||||
# variables that point apps at IBus or XIM. Steam is already running (the desktop starts
|
||||
# from it), so its entry's `steam -silent` only reaches that client as a command line it
|
||||
# runs; SteamOS 0.4 adds -vrdisable -deckard to it, meant for Desktop Mode's own Steam.
|
||||
# Deleting the copy brings an entry back. The marker is the last list done, so an entry
|
||||
# added later is hidden once on desktops that did the first list, and none comes back.
|
||||
case $(kreadconfig6 --file "$frametoprc" --group Defaults --key autostart) in
|
||||
2) hide= ;;
|
||||
1) hide=steam ;;
|
||||
*) hide="org.kde.discover.notifier ibus steam" ;;
|
||||
esac
|
||||
if [ -n "$hide" ]; then
|
||||
for entry in $hide; do
|
||||
src=/etc/xdg/autostart/$entry.desktop dst=$XDG_CONFIG_HOME/autostart/$entry.desktop
|
||||
[ -r "$src" ] && [ ! -e "$dst" ] || continue
|
||||
mkdir -p "$(dirname "$dst")"
|
||||
sed '/^\[Desktop Entry\]$/a Hidden=true' "$src" > "$dst"
|
||||
done
|
||||
kwriteconfig6 --file "$frametoprc" --group Defaults --key autostart 1
|
||||
kwriteconfig6 --file "$frametoprc" --group Defaults --key autostart 2
|
||||
fi
|
||||
|
||||
# Profiles reopen apps (docs/profiles.md), so Plasma's own session restore stays off here;
|
||||
|
||||
@@ -48,6 +48,7 @@ HANDS_BRIGHT_ON=40 # hand tracking, auto: the colour frames' mean bright
|
||||
HANDS_BRIGHT_OFF=25 # ... and back to the mono cameras under this
|
||||
HANDS_COLOR_LEFT=color_video0 # hand tracking: which colour camera is passthrough_left (hands/tools/check_color.py tells)
|
||||
HANDS_COLOR_CROP=subtract # hand tracking: how the colour calibration's crop applies, subtract | none (check_color.py tells)
|
||||
#HANDS_MODELS= # hand tracking: a folder of palm.ncnn.* and hand.ncnn.* to use instead of the stock models (hands/models/ncnn)
|
||||
POINTER_HANDS=0 # 1 = pinches click (on the release; hold and move to nudge the pointer first) and grips press and drag, with hand tracking
|
||||
POINTER_PINCH_GAIN=0.5 # hands: the pointer moves this times the pinching hand's angle (precision)
|
||||
POINTER_PINCH_DEADZONE=1.5 # hands: degrees the pinching hand moves before the pointer does (a tap's jitter)
|
||||
|
||||
+13
-1
@@ -32,6 +32,7 @@ Both settings reset whenever bluetoothd restarts or the Frame reboots, so they h
|
||||
| --- | --- |
|
||||
| `/etc/steamframe/bt-fixups.sh` | Turns controller privacy off, then sets the `ADDRESS_RESOLUTION` flags (`0x6`) on every bonded LE device that has an identity key |
|
||||
| `/etc/systemd/system/steamframe-bt-fixups.service` | Runs the script after every Bluetooth start |
|
||||
| `/etc/atomic-update.conf.d/frametop-bluetooth.conf` | Keeps the script through SteamOS updates |
|
||||
|
||||
The service runs after Bluetooth has started and never makes Bluetooth wait for it, because SteamOS's `set-bluetooth-mac-address.service`, which gives the Bluetooth chip its address, needs `bluetooth.service` to finish starting first. SteamOS's own files, including `main.conf`, are left untouched, so system updates won't conflict.
|
||||
|
||||
@@ -54,7 +55,7 @@ The install writes to `/etc`, so it needs `sudo` and the `steamos` user's passwo
|
||||
setup/bluetooth/install.sh install
|
||||
```
|
||||
|
||||
It copies the files above into place (`/etc` survives SteamOS updates) and enables the service. `./install.sh` offers this same step.
|
||||
It copies the files above into place and enables the service. A SteamOS update deletes every `/etc` file that SteamOS's keep list (`/usr/lib/rauc/atomic-update-keep.conf`) doesn't name. That list keeps the service but not the script, so the third file adds the script to it. `./install.sh` offers this same step.
|
||||
|
||||
### Step 3: pair your mouse or keyboard
|
||||
|
||||
@@ -88,6 +89,17 @@ The real test is to reboot the Frame, then move or click the device. It should r
|
||||
|
||||
Run `setup/bluetooth/install.sh run` again, then wake the device. Check that its address appears in the service log. The script only flags devices that have an identity key: look for `[IdentityResolvingKey]` in `/var/lib/bluetooth/<controller>/<device>/info` (readable as root).
|
||||
|
||||
#### Devices stopped reconnecting after a SteamOS update
|
||||
|
||||
Fixes installed before the keep entry existed lose their script in the update, and the service fails with `Unable to locate executable '/etc/steamframe/bt-fixups.sh'`. `scripts/doctor.sh` reports it too. Install again, which adds the keep entry, then start the service:
|
||||
|
||||
```
|
||||
setup/bluetooth/install.sh install
|
||||
sudo systemctl restart steamframe-bt-fixups.service
|
||||
```
|
||||
|
||||
The update keeps a copy of the deleted files in `/etc/previous`.
|
||||
|
||||
#### No Bluetooth at all after a boot
|
||||
|
||||
Check the controller:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Installed to /etc/atomic-update.conf.d/frametop-bluetooth.conf by setup/bluetooth/install.sh.
|
||||
# A SteamOS update deletes every /etc file its keep list (/usr/lib/rauc/atomic-update-keep.conf)
|
||||
# doesn't name. That list keeps the unit, but not the script it runs.
|
||||
/etc/steamframe/bt-fixups.sh
|
||||
@@ -18,6 +18,7 @@ case ${1:-install} in
|
||||
sudo_run "set -e
|
||||
install -D -m 0755 -o root -g root $src/bt-fixups.sh /etc/steamframe/bt-fixups.sh
|
||||
install -D -m 0644 -o root -g root $src/steamframe-bt-fixups.service /etc/systemd/system/steamframe-bt-fixups.service
|
||||
install -D -m 0644 -o root -g root $src/atomic-update.conf /etc/atomic-update.conf.d/frametop-bluetooth.conf
|
||||
rm -f /etc/systemd/system/bluetooth.service.d/steamframe.conf
|
||||
rmdir /etc/systemd/system/bluetooth.service.d 2>/dev/null || true
|
||||
systemctl daemon-reload
|
||||
@@ -25,7 +26,8 @@ systemctl enable steamframe-bt-fixups.service
|
||||
echo installed" ;;
|
||||
uninstall)
|
||||
sudo_run "systemctl disable steamframe-bt-fixups.service 2>/dev/null
|
||||
rm -f /etc/systemd/system/steamframe-bt-fixups.service /etc/systemd/system/bluetooth.service.d/steamframe.conf /etc/steamframe/bt-fixups.sh
|
||||
rm -f /etc/systemd/system/steamframe-bt-fixups.service /etc/systemd/system/bluetooth.service.d/steamframe.conf /etc/steamframe/bt-fixups.sh \
|
||||
/etc/atomic-update.conf.d/frametop-bluetooth.conf
|
||||
rmdir /etc/systemd/system/bluetooth.service.d /etc/steamframe 2>/dev/null; systemctl daemon-reload; echo removed" ;;
|
||||
run) sudo_run "/etc/steamframe/bt-fixups.sh" ;;
|
||||
*) echo "usage: $0 [install|uninstall|run]" >&2; exit 2 ;;
|
||||
|
||||
+7
-6
@@ -2,7 +2,7 @@
|
||||
# Uninstall Frametop from the Steam Frame. In a terminal on the headset (Konsole in the desktop,
|
||||
# or over SSH):
|
||||
#
|
||||
# curl -fsSL https://deejanuz.github.io/frametop/uninstall.sh | bash
|
||||
# curl -fsSL https://frametop.github.io/frametop/uninstall.sh | bash
|
||||
#
|
||||
# or ~/frametop/uninstall.sh. It doesn't use the rest of the repo, so it also works when
|
||||
# ~/frametop is gone or broken.
|
||||
@@ -28,7 +28,7 @@ shopt -s nullglob
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage: uninstall.sh [--dir DIR] [--dry-run]
|
||||
piped: curl -fsSL https://deejanuz.github.io/frametop/uninstall.sh | bash -s -- [options]
|
||||
piped: curl -fsSL https://frametop.github.io/frametop/uninstall.sh | bash -s -- [options]
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -41,9 +41,10 @@ driver=$HOME/.local/share/frametop/ft_pointer
|
||||
releases=$HOME/.local/share/frametop/releases # pack/install-release.sh
|
||||
vrpathreg=/opt/steamvr/bin/linuxarm64/vrpathreg
|
||||
handsctl=$HOME/.local/bin/ft-handsctl
|
||||
eyegrab_files=(/etc/systemd/system/frametop-eyegrab.service /etc/frametop/ft-eyegrab)
|
||||
eyegrab_files=(/etc/systemd/system/frametop-eyegrab.service /etc/frametop/ft-eyegrab
|
||||
/etc/atomic-update.conf.d/frametop-eyegrab.conf)
|
||||
bt_files=(/etc/systemd/system/steamframe-bt-fixups.service /etc/systemd/system/bluetooth.service.d/steamframe.conf
|
||||
/etc/steamframe/bt-fixups.sh)
|
||||
/etc/steamframe/bt-fixups.sh /etc/atomic-update.conf.d/frametop-bluetooth.conf)
|
||||
|
||||
step() { printf '\n\033[1m== %s\033[0m\n' "$*"; }
|
||||
run() { # run a command, or with --dry-run, show it
|
||||
@@ -184,9 +185,9 @@ main() {
|
||||
echo "($repo) and, if you want, your settings and the build container:"
|
||||
echo
|
||||
if [ "$repo" = "$HOME/frametop" ]; then
|
||||
echo " curl -fsSL https://deejanuz.github.io/frametop/uninstall.sh | bash"
|
||||
echo " curl -fsSL https://frametop.github.io/frametop/uninstall.sh | bash"
|
||||
else
|
||||
echo " curl -fsSL https://deejanuz.github.io/frametop/uninstall.sh | bash -s -- --dir $(printf %q "$repo")"
|
||||
echo " curl -fsSL https://frametop.github.io/frametop/uninstall.sh | bash -s -- --dir $(printf %q "$repo")"
|
||||
fi
|
||||
echo
|
||||
if ask "Restart the headset now? This closes everything open, in VR and on the desktop." n; then
|
||||
|
||||
Reference in new issue
Block a user