mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 01:00:06 +02:00
Turn the displays off while the headset isn't used, and keep it awake on a charger
A display mount that covers the proximity sensor makes the headset seem worn, so SteamVR never turned its displays off and they stayed on all night. The new power service, ft-powerd (frametop-power.service), goes by use instead: after DISPLAY_OFF_MIN minutes in which the headset and controllers didn't move and no input device was used, it turns the backlight off, and the next movement or input turns it back on. The new Power tab in Frametop Display Settings sets that time and has a Stay awake while plugged in switch. The switch sets Steam's own "When Plugged In and Idle -> Sleep after" to Never through Steam's UI, so the Frame stays reachable remotely while the power button still works. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f01c84f9a3
commit
b99fb97f32
13 files changed
+963
-24
No files matched your search
@@ -8,6 +8,8 @@ The mouse shows up as a small dot anchored in the room. It works on the SteamVR
|
|||||||
|
|
||||||
It comes with two settings apps, Frametop Display Settings for the screens and Frametop Input Settings for mice, keyboards, and button mappings, plus fixes that let Bluetooth LE mice and keyboards like the Swiftpoint Z3 reconnect after they sleep.
|
It comes with two settings apps, Frametop Display Settings for the screens and Frametop Input Settings for mice, keyboards, and button mappings, plus fixes that let Bluetooth LE mice and keyboards like the Swiftpoint Z3 reconnect after they sleep.
|
||||||
|
|
||||||
|
When you're not wearing the headset, Frametop can turn its displays off and keep it awake on the charger, so you can still reach it remotely. This works even on a stand or mount that makes the headset seem worn.
|
||||||
|
|
||||||
Frametop is an independent project, not made by or affiliated with Valve.
|
Frametop is an independent project, not made by or affiliated with Valve.
|
||||||
|
|
||||||
## Install on the headset
|
## Install on the headset
|
||||||
@@ -28,7 +30,7 @@ You need a Steam Frame with an internet connection, a keyboard (Bluetooth, or th
|
|||||||
|
|
||||||
After the restart, Launch a program → Desktop opens the multi-screen desktop, with its screens arranged around where you're facing. Frametop Display Settings and Frametop Input Settings are in the desktop's application menu, under Settings.
|
After the restart, Launch a program → Desktop opens the multi-screen desktop, with its screens arranged around where you're facing. Frametop Display Settings and Frametop Input Settings are in the desktop's application menu, under Settings.
|
||||||
|
|
||||||
If you work in the desktop for long stretches, stop Steam from putting the headset to sleep while it's plugged in: in Steam, open Settings → Power, and under When Plugged In and Idle set Sleep after to Never. By default Steam suspends the Frame after an hour without input, even while it charges. The displays still turn off a few seconds after you take the headset off.
|
If you work in the desktop for long stretches, or leave the headset on a stand, open Frametop Display Settings → Power. Turn on Stay awake while plugged in: by default Steam puts the Frame to sleep after an hour without input, even while it charges. And choose when the displays turn off while the headset isn't used. SteamVR turns them off a few seconds after you take the headset off, but a stand or mount that covers the proximity sensor inside it makes the headset seem worn, and its displays stay on all night.
|
||||||
|
|
||||||
### Add a Bluetooth mouse or keyboard
|
### Add a Bluetooth mouse or keyboard
|
||||||
|
|
||||||
@@ -51,12 +53,22 @@ If you work in the desktop for long stretches, stop Steam from putting the heads
|
|||||||
| While carrying a screen, sweep its laser across your other controller's ring, then let go | Pins it to that wrist, at its size and distance, as you hold it when you let go; it shows while you see its front. Grab its bar to adjust it (it stays pinned); sweep across the ring again to take it off |
|
| While carrying a screen, sweep its laser across your other controller's ring, then let go | Pins it to that wrist, at its size and distance, as you hold it when you let go; it shows while you see its front. Grab its bar to adjust it (it stays pinned); sweep across the ring again to take it off |
|
||||||
| Meta+Shift+R in the desktop | Puts the screens back in their layout (also in the menu as Reset Screen Layout, and mappable to a mouse button) |
|
| Meta+Shift+R in the desktop | Puts the screens back in their layout (also in the menu as Reset Screen Layout, and mappable to a mouse button) |
|
||||||
| Meta+Shift+H in the desktop | Hides or shows all screens (also in the menu as Hide/Show Screens, and mappable). The Visibility & wrist tab of Frametop Display Settings can instead show them only with the dashboard open, or while you look at your wrist |
|
| Meta+Shift+H in the desktop | Hides or shows all screens (also in the menu as Hide/Show Screens, and mappable). The Visibility & wrist tab of Frametop Display Settings can instead show them only with the dashboard open, or while you look at your wrist |
|
||||||
|
| Leave the headset on a stand | Its displays turn off once it has gone unused for the time set in Frametop Display Settings → Power, even if the stand covers its proximity sensor. Pick it up, or use any mouse, keyboard, or button, and they come back on |
|
||||||
| Play a VR game | The screens hide and your controllers stay in the game. Open the SteamVR dashboard, or press Meta+Shift+H, to see and use them. To keep them visible over games, change During VR games on the Visibility & wrist tab; the controllers still stay in the game, and you use the screens with the mouse or the dashboard |
|
| Play a VR game | The screens hide and your controllers stay in the game. Open the SteamVR dashboard, or press Meta+Shift+H, to see and use them. To keep them visible over games, change During VR games on the Visibility & wrist tab; the controllers still stay in the game, and you use the screens with the mouse or the dashboard |
|
||||||
|
|
||||||
You can map the mouse's extra buttons to actions such as Toggle SteamVR dashboard, Recenter pointer, or Head follow on/off on the Buttons page of Frametop Input Settings, and the Frame controllers' buttons on its Controllers page. Pointer speed, dot size, and the rest are on its Pointer page and take effect immediately. Head follow, which is experimental and off by default, makes the pointer come along when you turn your head: it stays put until your head turns past the leash angle, then glides back to its place in your view, and a leash of 0 keeps it fixed in your view. It's only lightly tested and not polished; tuning its settings, or improving how it feels, is open to anyone who wants to take it further.
|
You can map the mouse's extra buttons to actions such as Toggle SteamVR dashboard, Recenter pointer, or Head follow on/off on the Buttons page of Frametop Input Settings, and the Frame controllers' buttons on its Controllers page. Pointer speed, dot size, and the rest are on its Pointer page and take effect immediately. Head follow, which is experimental and off by default, makes the pointer come along when you turn your head: it stays put until your head turns past the leash angle, then glides back to its place in your view, and a leash of 0 keeps it fixed in your view. It's only lightly tested and not polished; tuning its settings, or improving how it feels, is open to anyone who wants to take it further.
|
||||||
|
|
||||||
Restarting the desktop (Restart desktop in Frametop Display Settings) closes its windows, but background work you started in it, such as servers, tmux sessions, or builds, keeps running.
|
Restarting the desktop (Restart desktop in Frametop Display Settings) closes its windows, but background work you started in it, such as servers, tmux sessions, or builds, keeps running.
|
||||||
|
|
||||||
|
### Leave the headset on a stand and reach it remotely
|
||||||
|
|
||||||
|
To keep the Frame on and connected while you're not wearing it, for SSH, remote desktop, or anything else running on it, open the Power tab in Frametop Display Settings:
|
||||||
|
|
||||||
|
- Turn off when unused for: how long the headset can go unused before its displays turn off (Never by default). Unused means the headset and controllers haven't moved and no mouse, keyboard, or button was used. SteamVR normally turns the displays off when its proximity sensor says the headset came off, but a stand or mount that covers the sensor makes the headset seem worn, so the displays stay on all night. This setting doesn't depend on the sensor. Pick the headset up or use any input, and the displays come back on.
|
||||||
|
- Stay awake while plugged in: stops Steam from putting the Frame to sleep while it charges. By default Steam puts it to sleep after an hour without input, even on the charger, which ends remote sessions. This is Steam's own Settings → Power → When Plugged In and Idle setting, so the power button still puts the Frame to sleep, and Steam's battery setting still applies.
|
||||||
|
|
||||||
|
With the displays off, the headset keeps tracking and rendering, so it uses about as much power as in use. Leave it on a charger that keeps up with that: a USB-C PD charger, not a 5 V one.
|
||||||
|
|
||||||
## Known limitations
|
## 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.3.0 build 20260922, SteamVR 2.17.10).
|
||||||
@@ -69,6 +81,7 @@ This is an early release, tested on one Steam Frame (SteamOS 0.3.0 build 2026092
|
|||||||
- The screens don't draw a mouse cursor of their own. The 3D mouse's dot or SteamVR's laser shows where you're pointing.
|
- The screens don't draw a mouse cursor of their own. The 3D mouse's dot or SteamVR's laser shows where you're pointing.
|
||||||
- On SteamVR's Settings page, the 3D mouse shows a laser beam and a larger hit dot, like a controller. SteamVR doesn't tell other programs where that page is (unlike Steam's pages, such as Library), so the mouse used to miss most of it: clicks went through to a desktop screen behind, and the dot disappeared. As a workaround, on that page only, the laser starts near your eye and SteamVR finds the page itself. See docs/design.md.
|
- On SteamVR's Settings page, the 3D mouse shows a laser beam and a larger hit dot, like a controller. SteamVR doesn't tell other programs where that page is (unlike Steam's pages, such as Library), so the mouse used to miss most of it: clicks went through to a desktop screen behind, and the dot disappeared. As a workaround, on that page only, the laser starts near your eye and SteamVR finds the page itself. See docs/design.md.
|
||||||
- Remote desktop over VNC (`./desktops.sh remote on`) needs Tailscale on the Frame.
|
- Remote desktop over VNC (`./desktops.sh remote on`) needs Tailscale on the Frame.
|
||||||
|
- Turning the displays off on a stand only turns their backlight off. SteamVR has no way for other programs to put the headset in standby, so tracking and rendering keep running, and the headset draws nearly its full power.
|
||||||
|
|
||||||
## Reporting problems
|
## Reporting problems
|
||||||
|
|
||||||
@@ -92,6 +105,7 @@ cd ~/frametop && git pull && ./install.sh
|
|||||||
./desktops.sh uninstall # the launcher's Desktop entry goes back to the stock desktop
|
./desktops.sh uninstall # the launcher's Desktop entry goes back to the stock desktop
|
||||||
./desktops.sh relay uninstall
|
./desktops.sh relay uninstall
|
||||||
pointer/helper/run.sh uninstall
|
pointer/helper/run.sh uninstall
|
||||||
|
power/run.sh uninstall
|
||||||
pointer/driver/install.sh uninstall # then restart SteamVR
|
pointer/driver/install.sh uninstall # then restart SteamVR
|
||||||
input-settings/install.sh uninstall
|
input-settings/install.sh uninstall
|
||||||
display-settings/install.sh uninstall
|
display-settings/install.sh uninstall
|
||||||
@@ -100,7 +114,7 @@ setup/bluetooth/install.sh uninstall # if you installed the Bluetooth fixes
|
|||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
A Plasma session runs nested inside ft-screens (`screens/`), a small Wayland compositor. KWin opens one window per screen, ft-screens sets each window's size, and each frame goes to SteamVR as an overlay without being copied. An input relay (`input/`) keeps Bluetooth mice working in SteamVR and feeds the mouse to the 3D pointer, which drives a virtual SteamVR controller (`pointer/`). [docs/reference.md](docs/reference.md) covers each piece, and [docs/design.md](docs/design.md) explains the design and what we learned about SteamVR on the Frame. [docs/hazards.md](docs/hazards.md) lists known ways the input handling can go wrong.
|
A Plasma session runs nested inside ft-screens (`screens/`), a small Wayland compositor. KWin opens one window per screen, ft-screens sets each window's size, and each frame goes to SteamVR as an overlay without being copied. An input relay (`input/`) keeps Bluetooth mice working in SteamVR and feeds the mouse to the 3D pointer, which drives a virtual SteamVR controller (`pointer/`). A power service (`power/`) turns the displays off while the headset isn't used. [docs/reference.md](docs/reference.md) covers each piece, and [docs/design.md](docs/design.md) explains the design and what we learned about SteamVR on the Frame. [docs/hazards.md](docs/hazards.md) lists known ways the input handling can go wrong.
|
||||||
|
|
||||||
| Folder | What it is |
|
| Folder | What it is |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -111,6 +125,7 @@ A Plasma session runs nested inside ft-screens (`screens/`), a small Wayland com
|
|||||||
| `layout/` | ft-layout: where the screens float, and their sizes. |
|
| `layout/` | ft-layout: where the screens float, and their sizes. |
|
||||||
| `input/` | The input relay (Bluetooth mice and keyboards, button maps). |
|
| `input/` | The input relay (Bluetooth mice and keyboards, button maps). |
|
||||||
| `pointer/` | The 3D mouse: SteamVR driver, helper service, and a probe tool. |
|
| `pointer/` | The 3D mouse: SteamVR driver, helper service, and a probe tool. |
|
||||||
|
| `power/` | ft-powerd: turns the displays off while the headset isn't used. |
|
||||||
| `display-settings/`, `input-settings/` | The two settings apps (Kirigami, Python). |
|
| `display-settings/`, `input-settings/` | The two settings apps (Kirigami, Python). |
|
||||||
| `setup/` | The build container and the Bluetooth fixes. See [setup/README.md](setup/README.md). |
|
| `setup/` | The build container and the Bluetooth fixes. See [setup/README.md](setup/README.md). |
|
||||||
| `scripts/` | Helpers the installers use. They run commands locally on the Frame, or over SSH from a PC. |
|
| `scripts/` | Helpers the installers use. They run commands locally on the Frame, or over SSH from a PC. |
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ the dev container:
|
|||||||
- Layout: a preset (curved or flat, rows, distance, gap, height) or the arrangement
|
- Layout: a preset (curved or flat, rows, distance, gap, height) or the arrangement
|
||||||
captured from where the screens are now, with a preview; arrange now; save the
|
captured from where the screens are now, with a preview; arrange now; save the
|
||||||
current arrangement; arrange automatically when the desktop starts.
|
current arrangement; arrange automatically when the desktop starts.
|
||||||
|
- Power: how long the headset can go unused before ft-powerd turns its displays off
|
||||||
|
(DISPLAY_OFF_MIN; the service's state comes from its control socket, @ft_powerd),
|
||||||
|
and whether the Frame stays awake while plugged in, which is Steam's own setting
|
||||||
|
(steam_settings.py; the value from before is kept as STEAM_SLEEP_AC_BEFORE).
|
||||||
Settings go to ~/.config/frametop.conf and ~/.config/frametop-layout.json. Anything
|
Settings go to ~/.config/frametop.conf and ~/.config/frametop-layout.json. Anything
|
||||||
that touches SteamVR runs layout/ft-layout on the host.
|
that touches SteamVR runs layout/ft-layout on the host.
|
||||||
Launch with display-settings/ft-display-settings (host wrapper).
|
Launch with display-settings/ft-display-settings (host wrapper).
|
||||||
@@ -22,8 +26,9 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
|
|
||||||
from PySide6.QtCore import Property, QObject, QProcess, QTimer, QUrl, Signal, Slot
|
from PySide6.QtCore import Property, QObject, QProcess, Qt, QTimer, QUrl, Signal, Slot
|
||||||
from PySide6.QtGui import QGuiApplication, QIcon
|
from PySide6.QtGui import QGuiApplication, QIcon
|
||||||
from PySide6.QtQml import QQmlApplicationEngine
|
from PySide6.QtQml import QQmlApplicationEngine
|
||||||
from PySide6.QtQuickControls2 import QQuickStyle
|
from PySide6.QtQuickControls2 import QQuickStyle
|
||||||
@@ -32,6 +37,7 @@ HERE = os.path.dirname(os.path.abspath(__file__))
|
|||||||
LAYOUT_DIR = os.path.join(HERE, "..", "layout")
|
LAYOUT_DIR = os.path.join(HERE, "..", "layout")
|
||||||
sys.path.insert(0, LAYOUT_DIR)
|
sys.path.insert(0, LAYOUT_DIR)
|
||||||
import ft_layout # noqa: E402 (pure Python: the same geometry ft-layout uses)
|
import ft_layout # noqa: E402 (pure Python: the same geometry ft-layout uses)
|
||||||
|
import steam_settings # noqa: E402
|
||||||
|
|
||||||
FT_LAYOUT = os.path.join(LAYOUT_DIR, "ft-layout")
|
FT_LAYOUT = os.path.join(LAYOUT_DIR, "ft-layout")
|
||||||
DESKTOPS = os.path.join(HERE, "..", "desktops.sh")
|
DESKTOPS = os.path.join(HERE, "..", "desktops.sh")
|
||||||
@@ -46,6 +52,10 @@ SCREEN_RESOLUTIONS = [(1920, 1080, ""), (2560, 1440, ""), (3840, 2160, "4K"), (2
|
|||||||
(2560, 1600, "16:10"), (1080, 1920, "portrait"), (1440, 2560, "portrait"),
|
(2560, 1600, "16:10"), (1080, 1920, "portrait"), (1440, 2560, "portrait"),
|
||||||
(2160, 3840, "portrait 4K")]
|
(2160, 3840, "portrait 4K")]
|
||||||
FT_SCREENS = "\0ft_screens"
|
FT_SCREENS = "\0ft_screens"
|
||||||
|
FT_POWERD = "\0ft_powerd"
|
||||||
|
# Steam's default for "When Plugged In and Idle -> Sleep after", to go back to when
|
||||||
|
# nothing was saved.
|
||||||
|
STEAM_SLEEP_AC_DEFAULT = 3600
|
||||||
SCALES = [0.75, 1.0, 1.25, 4 / 3, 1.5, 1.75, 2.0]
|
SCALES = [0.75, 1.0, 1.25, 4 / 3, 1.5, 1.75, 2.0]
|
||||||
ROTATIONS = [("normal", "Landscape"), ("left", "Portrait"), ("right", "Portrait (flipped)")]
|
ROTATIONS = [("normal", "Landscape"), ("left", "Portrait"), ("right", "Portrait (flipped)")]
|
||||||
|
|
||||||
@@ -83,7 +93,9 @@ def host_command(*cmd):
|
|||||||
class Backend(QObject):
|
class Backend(QObject):
|
||||||
changed = Signal()
|
changed = Signal()
|
||||||
busyChanged = Signal()
|
busyChanged = Signal()
|
||||||
|
powerChanged = Signal()
|
||||||
message = Signal(str, bool) # text, is error
|
message = Signal(str, bool) # text, is error
|
||||||
|
_steamDone = Signal(object, object, str) # Steam's sleep settings or None, error or None, what was done
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -95,6 +107,14 @@ class Backend(QObject):
|
|||||||
self._sock.bind("") # an abstract address ft-screens can reply to
|
self._sock.bind("") # an abstract address ft-screens can reply to
|
||||||
self._sock.settimeout(1.0)
|
self._sock.settimeout(1.0)
|
||||||
self._started = {} # conf values the running desktop started with
|
self._started = {} # conf values the running desktop started with
|
||||||
|
self._psock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||||
|
self._psock.bind("") # for ft-powerd's replies
|
||||||
|
self._psock.settimeout(0.5)
|
||||||
|
self._powerd = None # ft-powerd's status: (state, seconds unused, timeout seconds); None: not running
|
||||||
|
self._steam = None # Steam's sleep settings: {"ac": seconds, "battery": seconds}
|
||||||
|
self._steam_error = ""
|
||||||
|
self._steam_busy = False
|
||||||
|
self._steamDone.connect(self._steam_done, Qt.QueuedConnection)
|
||||||
self.poll = QTimer(interval=3000, timeout=self._check_running)
|
self.poll = QTimer(interval=3000, timeout=self._check_running)
|
||||||
self.poll.start()
|
self.poll.start()
|
||||||
self._check_running()
|
self._check_running()
|
||||||
@@ -120,6 +140,7 @@ class Backend(QObject):
|
|||||||
self._running_count = count
|
self._running_count = count
|
||||||
self._started = self._conf() if running else {}
|
self._started = self._conf() if running else {}
|
||||||
self.changed.emit()
|
self.changed.emit()
|
||||||
|
self._check_powerd()
|
||||||
|
|
||||||
def _ask_screens(self, text):
|
def _ask_screens(self, text):
|
||||||
"""Request/reply to ft-screens; None if it isn't running."""
|
"""Request/reply to ft-screens; None if it isn't running."""
|
||||||
@@ -385,6 +406,97 @@ class Backend(QObject):
|
|||||||
if not (reply and reply.startswith("ok")):
|
if not (reply and reply.startswith("ok")):
|
||||||
self.message.emit(f"Couldn't unpin: {reply or 'the desktop is not running'}", True)
|
self.message.emit(f"Couldn't unpin: {reply or 'the desktop is not running'}", True)
|
||||||
|
|
||||||
|
# --- power: ft-powerd and Steam's sleep setting ---
|
||||||
|
def _check_powerd(self):
|
||||||
|
try:
|
||||||
|
self._psock.sendto(b"status", FT_POWERD)
|
||||||
|
reply = self._psock.recv(256).decode().split()
|
||||||
|
status = (reply[1], float(reply[2]), float(reply[3])) if reply[:1] == ["ok"] else None
|
||||||
|
except (OSError, IndexError, ValueError):
|
||||||
|
status = None
|
||||||
|
if status != self._powerd:
|
||||||
|
self._powerd = status
|
||||||
|
self.powerChanged.emit()
|
||||||
|
|
||||||
|
@Property("QVariantMap", notify=powerChanged)
|
||||||
|
def power(self):
|
||||||
|
try:
|
||||||
|
off_min = float(ft_layout.read_conf().get("DISPLAY_OFF_MIN") or 0)
|
||||||
|
except ValueError:
|
||||||
|
off_min = 0.0
|
||||||
|
state, unused, _ = self._powerd or ("", 0, 0)
|
||||||
|
return {"offMinutes": off_min, "service": self._powerd is not None, "state": state, "unused": unused,
|
||||||
|
"steam": self._steam is not None, "steamBusy": self._steam_busy, "steamError": self._steam_error,
|
||||||
|
"acSleep": self._steam["ac"] if self._steam else -1,
|
||||||
|
"batterySleep": self._steam["battery"] if self._steam else -1}
|
||||||
|
|
||||||
|
@Slot(float)
|
||||||
|
def setDisplayOffMinutes(self, minutes):
|
||||||
|
"""ft-powerd re-reads frametop.conf within 2 s."""
|
||||||
|
write_conf_value("DISPLAY_OFF_MIN", f"{max(0.0, minutes):g}")
|
||||||
|
self.powerChanged.emit()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def displaysOffNow(self):
|
||||||
|
try:
|
||||||
|
self._psock.sendto(b"off", FT_POWERD)
|
||||||
|
reply = self._psock.recv(256).decode()
|
||||||
|
except OSError:
|
||||||
|
reply = "error the power service isn't running"
|
||||||
|
if not reply.startswith("ok"):
|
||||||
|
self.message.emit(f"Couldn't turn the displays off: {reply.split(' ', 1)[-1]}", True)
|
||||||
|
self._check_powerd()
|
||||||
|
|
||||||
|
def _steam_call(self, what, fn):
|
||||||
|
"""Runs fn, which talks to Steam (up to a few seconds), off the UI thread, then reads
|
||||||
|
Steam's sleep settings; _steam_done gets them on the UI thread."""
|
||||||
|
if self._steam_busy:
|
||||||
|
return
|
||||||
|
self._steam_busy = True
|
||||||
|
self.powerChanged.emit()
|
||||||
|
|
||||||
|
def work():
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
self._steamDone.emit(steam_settings.sleep_settings(), None, what)
|
||||||
|
except (steam_settings.SteamUnreachable, OSError, ValueError) as e:
|
||||||
|
self._steamDone.emit(None, str(e), what)
|
||||||
|
|
||||||
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
|
def _steam_done(self, settings, error, what):
|
||||||
|
self._steam_busy = False
|
||||||
|
if settings is not None:
|
||||||
|
self._steam, self._steam_error = settings, ""
|
||||||
|
else:
|
||||||
|
self._steam, self._steam_error = None, error
|
||||||
|
if what:
|
||||||
|
self.message.emit(f"Couldn't change Steam's sleep setting: {error}", True)
|
||||||
|
self.powerChanged.emit()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def refreshPower(self):
|
||||||
|
self._check_powerd()
|
||||||
|
self._steam_call("", lambda: None)
|
||||||
|
|
||||||
|
@Slot(bool)
|
||||||
|
def setStayAwake(self, on):
|
||||||
|
"""Steam's "When Plugged In and Idle -> Sleep after" is Never while this is on. The value
|
||||||
|
from before is kept in frametop.conf and goes back when it's turned off."""
|
||||||
|
def change():
|
||||||
|
ac = steam_settings.sleep_settings()["ac"]
|
||||||
|
if on:
|
||||||
|
if ac > 0:
|
||||||
|
write_conf_value("STEAM_SLEEP_AC_BEFORE", str(ac))
|
||||||
|
steam_settings.set_sleep_setting("system_idle_suspend_ac_sec", 0)
|
||||||
|
elif ac == 0:
|
||||||
|
try:
|
||||||
|
before = int(ft_layout.read_conf().get("STEAM_SLEEP_AC_BEFORE") or STEAM_SLEEP_AC_DEFAULT)
|
||||||
|
except ValueError:
|
||||||
|
before = STEAM_SLEEP_AC_DEFAULT
|
||||||
|
steam_settings.set_sleep_setting("system_idle_suspend_ac_sec", before if before > 0 else STEAM_SLEEP_AC_DEFAULT)
|
||||||
|
self._steam_call("stay awake" if on else "sleep", change)
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def restartDesktop(self):
|
def restartDesktop(self):
|
||||||
"""Restart the Frametop desktop (this app closes with it) to apply count and resolution."""
|
"""Restart the Frametop desktop (this app closes with it) to apply count and resolution."""
|
||||||
|
|||||||
+134
-8
@@ -12,11 +12,13 @@ Kirigami.ApplicationWindow {
|
|||||||
|
|
||||||
// Pages as tabs across the top (a side drawer was easy to miss).
|
// Pages as tabs across the top (a side drawer was easy to miss).
|
||||||
readonly property var pages: backend.backend === "screens"
|
readonly property var pages: backend.backend === "screens"
|
||||||
? [{ text: "Screens", icon: "video-display", page: screensPage },
|
? [{ name: "screens", text: "Screens", icon: "video-display", page: screensPage },
|
||||||
{ text: "Layout", icon: "view-grid", page: layoutPage },
|
{ name: "layout", text: "Layout", icon: "view-grid", page: layoutPage },
|
||||||
{ text: "Visibility & wrist", icon: "view-visible", page: visibilityPage }]
|
{ name: "visibility", text: "Visibility & wrist", icon: "view-visible", page: visibilityPage },
|
||||||
: [{ text: "Screens", icon: "video-display", page: screensPage },
|
{ name: "power", text: "Power", icon: "preferences-system-power-management", page: powerPage }]
|
||||||
{ text: "Layout", icon: "view-grid", page: layoutPage }]
|
: [{ name: "screens", text: "Screens", icon: "video-display", page: screensPage },
|
||||||
|
{ name: "layout", text: "Layout", icon: "view-grid", page: layoutPage },
|
||||||
|
{ name: "power", text: "Power", icon: "preferences-system-power-management", page: powerPage }]
|
||||||
|
|
||||||
header: Controls.TabBar {
|
header: Controls.TabBar {
|
||||||
id: tabs
|
id: tabs
|
||||||
@@ -29,7 +31,7 @@ Kirigami.ApplicationWindow {
|
|||||||
onClicked: root.show(modelData.page)
|
onClicked: root.show(modelData.page)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Component.onCompleted: currentIndex = ({ layout: 1, visibility: 2 })[startPage] || 0
|
Component.onCompleted: currentIndex = Math.max(0, root.pages.findIndex(p => p.name === startPage))
|
||||||
}
|
}
|
||||||
|
|
||||||
function show(page) {
|
function show(page) {
|
||||||
@@ -37,8 +39,16 @@ Kirigami.ApplicationWindow {
|
|||||||
pageStack.push(page)
|
pageStack.push(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FT_DISPLAY_PAGE=layout|visibility opens the app on that page.
|
// FT_DISPLAY_PAGE=layout|visibility|power opens the app on that page.
|
||||||
pageStack.initialPage: ({ layout: layoutPage, visibility: visibilityPage })[startPage] || screensPage
|
pageStack.initialPage: ({ layout: layoutPage, visibility: visibilityPage, power: powerPage })[startPage] || screensPage
|
||||||
|
|
||||||
|
// "1 hour", "15 minutes", "30 seconds".
|
||||||
|
function duration(seconds) {
|
||||||
|
const unit = (n, word) => n + " " + word + (n === 1 ? "" : "s")
|
||||||
|
if (seconds >= 3600 && seconds % 3600 === 0) return unit(seconds / 3600, "hour")
|
||||||
|
if (seconds >= 60 && seconds % 60 === 0) return unit(seconds / 60, "minute")
|
||||||
|
return unit(seconds, "second")
|
||||||
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: backend
|
target: backend
|
||||||
@@ -725,4 +735,120 @@ Kirigami.ApplicationWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Power
|
||||||
|
Component {
|
||||||
|
id: powerPage
|
||||||
|
Kirigami.ScrollablePage {
|
||||||
|
id: ppage
|
||||||
|
title: "Power"
|
||||||
|
property var p: backend.power
|
||||||
|
// The timeout choices, plus a value set by hand in frametop.conf.
|
||||||
|
property var offChoices: {
|
||||||
|
const list = [{ text: "Never", value: 0 }].concat([1, 2, 5, 10, 15, 30, 60].map(
|
||||||
|
m => ({ text: root.duration(m * 60), value: m })))
|
||||||
|
if (!list.some(c => c.value === p.offMinutes))
|
||||||
|
list.push({ text: root.duration(Math.round(p.offMinutes * 60)), value: p.offMinutes })
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: backend.refreshPower()
|
||||||
|
|
||||||
|
actions: [
|
||||||
|
Kirigami.Action {
|
||||||
|
text: "Turn displays off now"
|
||||||
|
icon.name: "system-suspend"
|
||||||
|
tooltip: "To try it: they come back on when the headset moves or any input is used"
|
||||||
|
enabled: ppage.p.service && ppage.p.state === "on"
|
||||||
|
onTriggered: backend.displaysOffNow()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
header: Kirigami.InlineMessage {
|
||||||
|
position: Kirigami.InlineMessage.Position.Header
|
||||||
|
visible: !ppage.p.service
|
||||||
|
type: Kirigami.MessageType.Warning
|
||||||
|
text: "The power service (frametop-power) isn't running, so the displays won't turn off on their own. "
|
||||||
|
+ "It starts with SteamVR once it's installed: power/run.sh install, or run ./install.sh again."
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
spacing: Kirigami.Units.largeSpacing
|
||||||
|
|
||||||
|
Kirigami.FormLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Displays" }
|
||||||
|
|
||||||
|
Controls.ComboBox {
|
||||||
|
Kirigami.FormData.label: "Turn off when unused for:"
|
||||||
|
model: ppage.offChoices
|
||||||
|
textRole: "text"
|
||||||
|
valueRole: "value"
|
||||||
|
Component.onCompleted: currentIndex = Math.max(0, indexOfValue(ppage.p.offMinutes))
|
||||||
|
onActivated: backend.setDisplayOffMinutes(currentValue)
|
||||||
|
}
|
||||||
|
Controls.Label {
|
||||||
|
text: "Unused means the headset and controllers haven't moved and no mouse, keyboard, or button "
|
||||||
|
+ "was used. This works even when the headset seems to be worn, like on a display mount "
|
||||||
|
+ "that covers its proximity sensor. Moving the headset or using any input turns the "
|
||||||
|
+ "displays back on. Taking the headset off still turns them off within seconds."
|
||||||
|
opacity: 0.7
|
||||||
|
font: Kirigami.Theme.smallFont
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
||||||
|
}
|
||||||
|
Controls.Label {
|
||||||
|
Kirigami.FormData.label: "Now:"
|
||||||
|
visible: ppage.p.service
|
||||||
|
text: ppage.p.state === "off" ? "Off. Move the headset or use any input to turn them on."
|
||||||
|
: ppage.p.state === "away" ? "Off. SteamVR turned them off because the headset isn't being worn."
|
||||||
|
: ppage.p.offMinutes > 0
|
||||||
|
? "On, unused for " + (ppage.p.unused < 60 ? Math.floor(ppage.p.unused) + " s"
|
||||||
|
: Math.floor(ppage.p.unused / 60) + " min " + Math.floor(ppage.p.unused % 60) + " s")
|
||||||
|
: "On"
|
||||||
|
}
|
||||||
|
|
||||||
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Sleep" }
|
||||||
|
|
||||||
|
Controls.Switch {
|
||||||
|
id: awake
|
||||||
|
Kirigami.FormData.label: "While plugged in:"
|
||||||
|
text: "Stay awake"
|
||||||
|
checked: ppage.p.acSleep === 0
|
||||||
|
enabled: ppage.p.steam && !ppage.p.steamBusy
|
||||||
|
onToggled: {
|
||||||
|
backend.setStayAwake(checked)
|
||||||
|
checked = Qt.binding(() => ppage.p.acSleep === 0) // follow what Steam has
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Controls.Label {
|
||||||
|
text: !ppage.p.steam
|
||||||
|
? (ppage.p.steamBusy ? "Checking Steam's setting…" : "Couldn't reach Steam: " + ppage.p.steamError)
|
||||||
|
: "Keeps the Frame awake and connected while it charges, for remote access, downloads, and "
|
||||||
|
+ "anything else running. This is Steam's own setting (Settings → Power → When Plugged In "
|
||||||
|
+ "and Idle), so the power button still puts the Frame to sleep. "
|
||||||
|
+ (ppage.p.acSleep > 0 ? "Now Steam puts it to sleep after " + root.duration(ppage.p.acSleep)
|
||||||
|
+ " without input, even while it charges. " : "")
|
||||||
|
+ "On battery, Steam's battery setting still applies ("
|
||||||
|
+ (ppage.p.batterySleep > 0 ? "sleep after " + root.duration(ppage.p.batterySleep) : "never sleep")
|
||||||
|
+ ")."
|
||||||
|
opacity: 0.7
|
||||||
|
font: Kirigami.Theme.smallFont
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Controls.Label {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
opacity: 0.7
|
||||||
|
text: "With the displays off, the headset keeps tracking and drawing, so it can wake the moment "
|
||||||
|
+ "it moves. It still uses most of its power, so leave it on a charger that keeps up with it "
|
||||||
|
+ "in use."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Steam's sleep settings, read and written through Steam's own UI.
|
||||||
|
|
||||||
|
Steam, not systemd, puts the Frame to sleep: after "When Plugged In and Idle -> Sleep after"
|
||||||
|
(an hour by default) without input, even while it charges. That's a Steam client setting,
|
||||||
|
`system_idle_suspend_ac_sec` (0 = never), with no file or command line to change it. Steam
|
||||||
|
on the Frame runs with -cef-enable-debugging, so its UI's JavaScript context
|
||||||
|
(SharedJSContext) is reachable over the Chrome DevTools Protocol on 127.0.0.1:8080. There
|
||||||
|
`settingsStore.clientSettings` has the current values, and `SteamClient.Settings.SetSetting`
|
||||||
|
takes a change as a serialized CMsgClientSettings protobuf, which is what Steam's own
|
||||||
|
Settings -> Power page sends. Standard library only (a minimal WebSocket client).
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
CDP_PORT = 8080
|
||||||
|
# CMsgClientSettings field numbers (Steam's UI bundle maps the names to these).
|
||||||
|
FIELDS = {"system_idle_suspend_ac_sec": 24004, "system_idle_suspend_battery_sec": 24003}
|
||||||
|
|
||||||
|
|
||||||
|
class SteamUnreachable(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _WebSocket:
|
||||||
|
def __init__(self, url, timeout=5):
|
||||||
|
host_port, path = url[len("ws://"):].split("/", 1)
|
||||||
|
host, port = host_port.rsplit(":", 1)
|
||||||
|
self.sock = socket.create_connection((host, int(port)), timeout=timeout)
|
||||||
|
key = base64.b64encode(os.urandom(16)).decode()
|
||||||
|
self.sock.sendall((f"GET /{path} HTTP/1.1\r\nHost: {host_port}\r\nUpgrade: websocket\r\n"
|
||||||
|
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode())
|
||||||
|
head = b""
|
||||||
|
while b"\r\n\r\n" not in head:
|
||||||
|
chunk = self.sock.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
raise SteamUnreachable("Steam closed the connection")
|
||||||
|
head += chunk
|
||||||
|
if b" 101 " not in head.split(b"\r\n", 1)[0]:
|
||||||
|
raise SteamUnreachable(head.split(b"\r\n", 1)[0].decode(errors="replace"))
|
||||||
|
self.buf = head.split(b"\r\n\r\n", 1)[1]
|
||||||
|
|
||||||
|
def send(self, text):
|
||||||
|
data, mask = text.encode(), os.urandom(4)
|
||||||
|
n = len(data)
|
||||||
|
if n < 126:
|
||||||
|
head = struct.pack(">BB", 0x81, 0x80 | n)
|
||||||
|
elif n < 65536:
|
||||||
|
head = struct.pack(">BBH", 0x81, 0x80 | 126, n)
|
||||||
|
else:
|
||||||
|
head = struct.pack(">BBQ", 0x81, 0x80 | 127, n)
|
||||||
|
self.sock.sendall(head + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
|
||||||
|
|
||||||
|
def _take(self, n):
|
||||||
|
while len(self.buf) < n:
|
||||||
|
chunk = self.sock.recv(65536)
|
||||||
|
if not chunk:
|
||||||
|
raise SteamUnreachable("Steam closed the connection")
|
||||||
|
self.buf += chunk
|
||||||
|
out, self.buf = self.buf[:n], self.buf[n:]
|
||||||
|
return out
|
||||||
|
|
||||||
|
def recv(self):
|
||||||
|
message = b""
|
||||||
|
while True:
|
||||||
|
b0, b1 = self._take(2)
|
||||||
|
n = b1 & 0x7F
|
||||||
|
if n == 126:
|
||||||
|
n = struct.unpack(">H", self._take(2))[0]
|
||||||
|
elif n == 127:
|
||||||
|
n = struct.unpack(">Q", self._take(8))[0]
|
||||||
|
message += self._take(n)
|
||||||
|
if b0 & 0x80:
|
||||||
|
return message.decode(errors="replace")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.sock.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate(expression):
|
||||||
|
"""Runs JavaScript in Steam's SharedJSContext and returns its (awaited) value."""
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f"http://127.0.0.1:{CDP_PORT}/json", timeout=3) as r:
|
||||||
|
targets = json.load(r)
|
||||||
|
except OSError as e:
|
||||||
|
raise SteamUnreachable(f"Steam isn't reachable on port {CDP_PORT} ({e})") from e
|
||||||
|
url = next((t["webSocketDebuggerUrl"] for t in targets if t.get("title") == "SharedJSContext"), None)
|
||||||
|
if not url:
|
||||||
|
raise SteamUnreachable("Steam's UI isn't running")
|
||||||
|
try:
|
||||||
|
ws = _WebSocket(url)
|
||||||
|
try:
|
||||||
|
ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate",
|
||||||
|
"params": {"expression": expression, "awaitPromise": True, "returnByValue": True}}))
|
||||||
|
while True:
|
||||||
|
reply = json.loads(ws.recv())
|
||||||
|
if reply.get("id") == 1:
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
ws.close()
|
||||||
|
except OSError as e:
|
||||||
|
raise SteamUnreachable(str(e)) from e
|
||||||
|
result = reply.get("result", {})
|
||||||
|
if "exceptionDetails" in result:
|
||||||
|
details = result["exceptionDetails"]
|
||||||
|
raise SteamUnreachable(details.get("exception", {}).get("description") or details.get("text", "error"))
|
||||||
|
return result.get("result", {}).get("value")
|
||||||
|
|
||||||
|
|
||||||
|
def sleep_settings():
|
||||||
|
"""{"ac": seconds, "battery": seconds}: when Steam puts the Frame to sleep without input,
|
||||||
|
plugged in and on battery (0 = never)."""
|
||||||
|
value = _evaluate("(() => { const c = settingsStore.clientSettings; "
|
||||||
|
"return {ac: c.system_idle_suspend_ac_sec, battery: c.system_idle_suspend_battery_sec}; })()")
|
||||||
|
if not isinstance(value, dict) or not all(isinstance(value.get(k), int) for k in ("ac", "battery")):
|
||||||
|
raise SteamUnreachable("Steam's settings don't have the sleep timeouts")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def set_sleep_setting(name, seconds):
|
||||||
|
"""Sets one of FIELDS to a whole number of seconds and checks that Steam took it."""
|
||||||
|
field, seconds = FIELDS[name], int(seconds)
|
||||||
|
if seconds < 0:
|
||||||
|
raise ValueError("seconds must be 0 (never) or more")
|
||||||
|
ok = _evaluate(f"""(async () => {{
|
||||||
|
const bytes = [];
|
||||||
|
const varint = n => {{ while (n > 127) {{ bytes.push((n & 127) | 128); n = Math.floor(n / 128); }} bytes.push(n); }};
|
||||||
|
varint({field} * 8); varint({seconds});
|
||||||
|
await SteamClient.Settings.SetSetting(btoa(String.fromCharCode(...bytes)));
|
||||||
|
for (let i = 0; i < 40; i++) {{
|
||||||
|
if (settingsStore.clientSettings.{name} === {seconds}) return true;
|
||||||
|
await new Promise(r => setTimeout(r, 50));
|
||||||
|
}}
|
||||||
|
return false;
|
||||||
|
}})()""")
|
||||||
|
if ok is not True:
|
||||||
|
raise SteamUnreachable(f"Steam didn't take {name} = {seconds}")
|
||||||
+12
-1
@@ -117,7 +117,7 @@ The session is modeled on SteamOS's `steamos-nested-desktop` and runs beside it.
|
|||||||
|
|
||||||
The VR launcher starts the session from the Steam client, and the client's environment came along: `LD_LIBRARY_PATH` pointing at Steam's own runtime, whose `libavcodec` has no H.264 decoder, so VLC in the desktop couldn't play most videos, plus the client's overlay and launch settings. The session script drops the client's variables before it starts anything. SteamOS's global Mesa settings (`/usr/share/deckard/mesavars.sh`) stay, and the gamescope session's Vulkan layer (`ENABLE_GAMESCOPE_WSI`) is only kept for the gamescope backend.
|
The VR launcher starts the session from the Steam client, and the client's environment came along: `LD_LIBRARY_PATH` pointing at Steam's own runtime, whose `libavcodec` has no H.264 decoder, so VLC in the desktop couldn't play most videos, plus the client's overlay and launch settings. The session script drops the client's variables before it starts anything. SteamOS's global Mesa settings (`/usr/share/deckard/mesavars.sh`) stay, and the gamescope session's Vulkan layer (`ENABLE_GAMESCOPE_WSI`) is only kept for the gamescope backend.
|
||||||
|
|
||||||
Steam, not systemd, suspends the Frame: after `system_idle_suspend_ac_sec` (an hour by default) without input on AC power, it logs `Switching to power state: k_ESystemPowerState_Sleep` and suspends, even while charging. It's a Steam setting (Settings → Power → When Plugged In and Idle → Sleep after), so the README recommends setting it to Never. SteamVR's standby, which turns the displays off when the headset comes off, is separate.
|
Steam, not systemd, suspends the Frame: after `system_idle_suspend_ac_sec` (an hour by default) without input on AC power, it logs `Switching to power state: k_ESystemPowerState_Sleep` and suspends, even while charging. It's a Steam setting (Settings → Power → When Plugged In and Idle → Sleep after), which the Stay awake while plugged in switch in Frametop Display Settings sets to Never. SteamVR's standby, which turns the displays off when the headset comes off, is separate; see below.
|
||||||
|
|
||||||
Flatpak apps need `XDG_DATA_DIRS` to include Flatpak's exports, or Plasma opens Discover instead of launching them, so the session sources `/etc/profile.d/flatpak.sh`.
|
Flatpak apps need `XDG_DATA_DIRS` to include Flatpak's exports, or Plasma opens Discover instead of launching them, so the session sources `/etc/profile.d/flatpak.sh`.
|
||||||
|
|
||||||
@@ -127,6 +127,16 @@ A podman container's monitor process (conmon) stays in the cgroup of whatever st
|
|||||||
|
|
||||||
Program names stay within 15 characters, because Linux truncates process names there and the scripts find programs with `pgrep -x` and `pkill -x`. That's why the prefix is `ft-`.
|
Program names stay within 15 characters, because Linux truncates process names there and the scripts find programs with `pgrep -x` and `pkill -x`. That's why the prefix is `ft-`.
|
||||||
|
|
||||||
|
## Displays off on a stand
|
||||||
|
|
||||||
|
SteamVR decides the headset is off from its proximity sensor, which the driver reads through the DSP, and turns the displays off 5 seconds later. On a display mount that covered the sensor, that never happened: SteamVR kept the headset in use all night (no `entering standby` for device 0 in vrserver.txt, and XRService's user presence stayed at 1), and Steam didn't sleep either, because its idle count treats a present user as active. The battery went from 100% to 12% overnight on a 5 V, 3 A charger, with the headset drawing about 17 W.
|
||||||
|
|
||||||
|
There's no client call that puts the headset in standby. The cv driver's `teststandby` debug request (`IVRDebug::DriverDebugRequest`) only answers "Standby unknown hmd" on the Frame. But what the driver does for the displays in standby is write `/sys/class/backlight/ae94000.dsi.0/brightness` ("cv: Set displays off" writes 0, "Set displays on" the old value), and the `video` group can write that file, from the container too. So `ft-powerd` goes by use instead of the sensor and turns the backlight off itself. Tracking and rendering keep running. Turning the backlight off moved the battery current by only about 75 mA (0.5 W), so they're most of the load, but they're also why the displays can wake the moment the headset moves.
|
||||||
|
|
||||||
|
Movement is judged within 10-second windows. On the mount, the head pose jittered within 0.5 mm and 0.1 degrees over 20 seconds, and its position drifted 1.7 mm (0.16 degrees) in 4 minutes. Compared with a fixed reference, that drift would count as movement sooner or later and keep the displays on; within 10 seconds it never reaches the 5 mm and 0.5 degree thresholds, and anyone wearing the headset passes them now and then.
|
||||||
|
|
||||||
|
Staying awake while charging uses Steam's own setting rather than a logind sleep inhibitor. Steam suspends with `dbus-send ... login1.Manager.Suspend boolean:true`, and a block inhibitor does stop that (`CanSuspend` answers "challenge" while one is held), but it stops the power button too. `system_idle_suspend_ac_sec` is field 24004 of Steam's CMsgClientSettings. In Steam's SharedJSContext, reachable over CDP on port 8080 because Steam runs with `-cef-enable-debugging`, `SteamClient.Settings.SetSetting` takes a change as a base64 protobuf, the way Steam's Power page sends it (0 is never), and `settingsStore.clientSettings` has the current values.
|
||||||
|
|
||||||
## Approaches we dropped
|
## Approaches we dropped
|
||||||
|
|
||||||
- WayVR, an existing Wayland desktop for VR. It built and connected to SteamVR on the Frame, but nothing showed in the headset. It has no bindings for the Frame's controllers, and its KDE screen capture needs `xdg-desktop-portal-kde`, which SteamOS doesn't ship.
|
- WayVR, an existing Wayland desktop for VR. It built and connected to SteamVR on the Frame, but nothing showed in the headset. It has no bindings for the Frame's controllers, and its KDE screen capture needs `xdg-desktop-portal-kde`, which SteamOS doesn't ship.
|
||||||
@@ -140,3 +150,4 @@ Program names stay within 15 characters, because Linux truncates process names t
|
|||||||
- Drawing KWin's cursor on the screens.
|
- Drawing KWin's cursor on the screens.
|
||||||
- Plasma can lose its panels when the number of screens goes down, because they're saved against a screen that no longer exists. Removing `plasma-org.kde.plasma.desktop-appletsrc` and `plasmashellrc` from `~/.config/frametop` brings the default panels back.
|
- Plasma can lose its panels when the number of screens goes down, because they're saved against a screen that no longer exists. Removing `plasma-org.kde.plasma.desktop-appletsrc` and `plasmashellrc` from `~/.config/frametop` brings the default panels back.
|
||||||
- Frame pacing and GPU cost with several busy screens haven't been measured.
|
- Frame pacing and GPU cost with several busy screens haven't been measured.
|
||||||
|
- Real standby on a stand, with rendering and tracking paused, not just the backlight off. SteamVR has no call for it, and its activity level follows the proximity sensor.
|
||||||
+23
-1
@@ -121,11 +121,12 @@ When the desktop starts, its screens arrange themselves around where you're faci
|
|||||||
|
|
||||||
The desktop's own screen arrangement follows where the screens are around you, whatever their numbers: a screen you see to the left of another is to its left in Plasma too, so the pointer and dragged windows cross straight to it. Screens one above the other stack, and screens pinned to a wrist come last. It's updated at startup, after arranging or saving the layout, and half a second after you let go of a screen you moved. With the headset off there's no head pose to go by, and the arrangement stays as it was.
|
The desktop's own screen arrangement follows where the screens are around you, whatever their numbers: a screen you see to the left of another is to its left in Plasma too, so the pointer and dragged windows cross straight to it. Screens one above the other stack, and screens pinned to a wrist come last. It's updated at startup, after arranging or saving the layout, and half a second after you let go of a screen you moved. With the headset off there's no head pose to go by, and the arrangement stays as it was.
|
||||||
|
|
||||||
Frametop Display Settings has three tabs:
|
Frametop Display Settings has four tabs (three with the gamescope backend, which has no Visibility & wrist):
|
||||||
|
|
||||||
- Screens: add and remove screens, and set each one's resolution (presets from 1080p to 4K, ultrawide, super ultrawide, portrait, or custom), its width in VR (0.5 to 6 m), its scale, whether it's curved, and whether it has the taskbar. Resolution, width, and curve apply at once. Adding or removing a screen takes a desktop restart, which the app offers.
|
- Screens: add and remove screens, and set each one's resolution (presets from 1080p to 4K, ultrawide, super ultrawide, portrait, or custom), its width in VR (0.5 to 6 m), its scale, whether it's curved, and whether it has the taskbar. Resolution, width, and curve apply at once. Adding or removing a screen takes a desktop restart, which the app offers.
|
||||||
- Layout: a curve around you, with the screens hinged edge to edge like monitors on a desk and each turned to face you, or a flat wall. Both take rows, distance, gap, and height. Save current arrangement keeps the positions and sizes you set by hand instead. A preview shows the layout from above and from the front, and a switch turns auto-arrange at startup on or off.
|
- Layout: a curve around you, with the screens hinged edge to edge like monitors on a desk and each turned to face you, or a flat wall. Both take rows, distance, gap, and height. Save current arrangement keeps the positions and sizes you set by hand instead. A preview shows the layout from above and from the front, and a switch turns auto-arrange at startup on or off.
|
||||||
- Visibility & wrist: the visibility, game, and controller settings described above, the wrist angle, and buttons to pin all screens to a wrist or unpin them.
|
- Visibility & wrist: the visibility, game, and controller settings described above, the wrist angle, and buttons to pin all screens to a wrist or unpin them.
|
||||||
|
- Power: when the displays turn off while the headset isn't used, their state now, Turn displays off now (to try it), and Stay awake while plugged in. See [Displays off and sleep](#displays-off-and-sleep).
|
||||||
|
|
||||||
`layout/ft-layout` does the arranging. It's a Python script that uses only the standard library and runs on the host:
|
`layout/ft-layout` does the arranging. It's a Python script that uses only the standard library and runs on the host:
|
||||||
|
|
||||||
@@ -140,6 +141,27 @@ display-settings/install.sh # menu entries and the Meta+Shift+R and Meta+Shift+H
|
|||||||
|
|
||||||
The layout is stored relative to your head when it's applied. `/tmp/frametop-layout.log` has the run from the last desktop start.
|
The layout is stored relative to your head when it's applied. `/tmp/frametop-layout.log` has the run from the last desktop start.
|
||||||
|
|
||||||
|
## Displays off and sleep
|
||||||
|
|
||||||
|
SteamVR turns the displays off a few seconds after the headset's proximity sensor says it came off. A stand or display mount that covers the sensor makes the headset seem worn, so its displays stay on, and Steam, which then counts someone as present, never puts it to sleep either.
|
||||||
|
|
||||||
|
`power/ft-powerd` goes by use instead. It runs in the `dev` container as `frametop-power.service` and starts with SteamVR. Once the headset has gone unused for `DISPLAY_OFF_MIN` minutes (0, the default, is never), it turns the displays' backlight off, and it turns it back on at the next use. Use is any of these:
|
||||||
|
|
||||||
|
- The headset, a Frame controller, or the 3D mouse's virtual controller moving more than `DISPLAY_MOVE_MM` (5 mm) or turning more than `DISPLAY_MOVE_DEG` (0.5 degrees) within 10 seconds.
|
||||||
|
- A key, button, or mouse motion on any input device on the host, including the headset's own buttons and the input relay's virtual mouse and keyboard.
|
||||||
|
- The headset going back on after SteamVR's own standby, or something else turning the backlight back on.
|
||||||
|
|
||||||
|
While SteamVR has the headset in standby, SteamVR owns the displays and ft-powerd waits. The backlight is `/sys/class/backlight/ae94000.dsi.0/brightness`, the same file SteamVR's driver writes for standby. With the backlight off, tracking and rendering keep running, which lets the displays wake the moment the headset moves, but the headset still uses most of its power. ft-powerd puts the backlight back when it stops, and if it was killed with the displays off, the next start does (the value is kept in `~/.cache/frametop/powerd-brightness` meanwhile).
|
||||||
|
|
||||||
|
Stay awake while plugged in is Steam's own setting, When Plugged In and Idle → Sleep after (`system_idle_suspend_ac_sec`), set to Never. Frametop Display Settings changes it the way Steam's Settings → Power page does, through Steam's UI on its debugging port (`display-settings/steam_settings.py`), and keeps the value from before in `STEAM_SLEEP_AC_BEFORE` to put back when the switch goes off. The power button still puts the Frame to sleep, and Steam's battery setting still applies.
|
||||||
|
|
||||||
|
```
|
||||||
|
power/build.sh && power/run.sh install
|
||||||
|
power/run.sh status # "ok on|off|away <seconds unused> <timeout seconds>"
|
||||||
|
power/run.sh off | on # the displays off now, or back on
|
||||||
|
power/run.sh log
|
||||||
|
```
|
||||||
|
|
||||||
## Remote desktop over VNC
|
## Remote desktop over VNC
|
||||||
|
|
||||||
With `REMOTE=1` in the config (`desktops.sh remote on`), the desktop is also served over VNC, for RealVNC Viewer or macOS Screen Sharing. `desktops.sh remote info` prints the address and password.
|
With `REMOTE=1` in the config (`desktops.sh remote on`), the desktop is also served over VNC, for RealVNC Viewer or macOS Screen Sharing. `desktops.sh remote info` prints the address and password.
|
||||||
|
|||||||
+15
-10
@@ -45,7 +45,7 @@ else
|
|||||||
"$root/scripts/sync.sh" >/dev/null
|
"$root/scripts/sync.sh" >/dev/null
|
||||||
fi
|
fi
|
||||||
|
|
||||||
step "1/7 distrobox (container tool, installed in your home folder)"
|
step "1/8 distrobox (container tool, installed in your home folder)"
|
||||||
if on_frame 'test -x ~/.local/bin/distrobox'; then
|
if on_frame 'test -x ~/.local/bin/distrobox'; then
|
||||||
echo "already installed: $(on_frame '~/.local/bin/distrobox version | head -1')"
|
echo "already installed: $(on_frame '~/.local/bin/distrobox version | head -1')"
|
||||||
else
|
else
|
||||||
@@ -55,21 +55,25 @@ else
|
|||||||
cd ~/dev/src/distrobox && ./install --prefix ~/.local'
|
cd ~/dev/src/distrobox && ./install --prefix ~/.local'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
step "2/7 build container (Fedora 44 'dev', about 1-2 GB the first time)"
|
step "2/8 build container (Fedora 44 'dev', about 1-2 GB the first time)"
|
||||||
"$root/setup/dev-container.sh"
|
"$root/setup/dev-container.sh"
|
||||||
|
|
||||||
step "3/7 input relay (keeps Bluetooth mice working in SteamVR, device roles, button maps)"
|
step "3/8 input relay (keeps Bluetooth mice working in SteamVR, device roles, button maps)"
|
||||||
"$root/desktops.sh" relay install
|
"$root/desktops.sh" relay install
|
||||||
|
|
||||||
step "4/7 3D mouse: SteamVR driver"
|
step "4/8 3D mouse: SteamVR driver"
|
||||||
"$root/pointer/driver/build.sh"
|
"$root/pointer/driver/build.sh"
|
||||||
"$root/pointer/driver/install.sh" install 2>&1 | grep -v xdg-open
|
"$root/pointer/driver/install.sh" install 2>&1 | grep -v xdg-open
|
||||||
|
|
||||||
step "5/7 3D mouse: pointer helper service"
|
step "5/8 3D mouse: pointer helper service"
|
||||||
"$root/pointer/helper/build.sh"
|
"$root/pointer/helper/build.sh"
|
||||||
"$root/pointer/helper/run.sh" install
|
"$root/pointer/helper/run.sh" install
|
||||||
|
|
||||||
step "6/7 multi-screen desktop (ft-screens), Frametop Input Settings, and Frametop Display Settings"
|
step "6/8 power service (turns the displays off while the headset isn't used, even on a stand)"
|
||||||
|
"$root/power/build.sh"
|
||||||
|
"$root/power/run.sh" install
|
||||||
|
|
||||||
|
step "7/8 multi-screen desktop (ft-screens), Frametop Input Settings, and Frametop Display Settings"
|
||||||
"$root/screens/build.sh"
|
"$root/screens/build.sh"
|
||||||
"$root/desktops.sh" install >/dev/null
|
"$root/desktops.sh" install >/dev/null
|
||||||
"$root/input-settings/install.sh"
|
"$root/input-settings/install.sh"
|
||||||
@@ -77,7 +81,7 @@ step "6/7 multi-screen desktop (ft-screens), Frametop Input Settings, and Framet
|
|||||||
on_frame "sed -i 's/^POINTER=0/POINTER=1/' ~/.config/frametop.conf; grep -q '^POINTER=' ~/.config/frametop.conf || echo 'POINTER=1' >> ~/.config/frametop.conf"
|
on_frame "sed -i 's/^POINTER=0/POINTER=1/' ~/.config/frametop.conf; grep -q '^POINTER=' ~/.config/frametop.conf || echo 'POINTER=1' >> ~/.config/frametop.conf"
|
||||||
echo "the launcher's Desktop entry now opens the multi-screen desktop; 3D mouse on (POINTER=1 in ~/.config/frametop.conf)"
|
echo "the launcher's Desktop entry now opens the multi-screen desktop; 3D mouse on (POINTER=1 in ~/.config/frametop.conf)"
|
||||||
|
|
||||||
step "7/7 Bluetooth fixes (optional; they let LE mice and keyboards like the Swiftpoint Z3 reconnect)"
|
step "8/8 Bluetooth fixes (optional; they let LE mice and keyboards like the Swiftpoint Z3 reconnect)"
|
||||||
if [ "$bluetooth" = 1 ] && ask "Install the Bluetooth fixes? They need your password (sudo)." n; then
|
if [ "$bluetooth" = 1 ] && ask "Install the Bluetooth fixes? They need your password (sudo)." n; then
|
||||||
"$root/setup/bluetooth/install.sh" install
|
"$root/setup/bluetooth/install.sh" install
|
||||||
else
|
else
|
||||||
@@ -90,9 +94,10 @@ SteamVR has to restart once, to load the 3D mouse driver and to start the input
|
|||||||
before it. Restarting SteamVR closes everything open in VR, including this terminal if
|
before it. Restarting SteamVR closes everything open in VR, including this terminal if
|
||||||
it's in a VR desktop. Rebooting the headset works too.
|
it's in a VR desktop. Rebooting the headset works too.
|
||||||
|
|
||||||
Recommended: stop Steam from putting the headset to sleep while it's plugged in. In Steam,
|
Recommended: in Frametop Display Settings > Power, choose when the displays turn off
|
||||||
open Settings > Power, and under "When Plugged In and Idle" set "Sleep after" to Never.
|
while the headset isn't used (for a stand or mount that covers its proximity sensor), and
|
||||||
The displays still turn off when you take the headset off.
|
turn on Stay awake while plugged in, so Steam doesn't put the headset to sleep while it
|
||||||
|
charges. The displays still turn off when you take the headset off.
|
||||||
EOF
|
EOF
|
||||||
if ask "Restart SteamVR now?" n; then
|
if ask "Restart SteamVR now?" n; then
|
||||||
on_frame 'systemctl --user restart steamvr.service'
|
on_frame 'systemctl --user restart steamvr.service'
|
||||||
|
|||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build ft-powerd on the Frame, in the dev container (it also runs there).
|
||||||
|
set -euo pipefail
|
||||||
|
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
"$root/scripts/sync.sh" >/dev/null
|
||||||
|
exec "$root/scripts/frame.sh" -C power 'set -e; mkdir -p build
|
||||||
|
g++ -std=c++17 -O2 -Wall -Wno-unused-parameter -I/opt/steamvr/tools/hellovr_vulkan_linux/src/openvr/headers \
|
||||||
|
-o build/ft-powerd ft-powerd.cpp -L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64
|
||||||
|
echo "built build/ft-powerd"'
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Template: the installer replaces @REPO@ with the repo path on the Frame.
|
||||||
|
[Unit]
|
||||||
|
Description=Frametop power: turns the headset's displays off while nobody is using it
|
||||||
|
Documentation=file://@REPO@/docs/reference.md
|
||||||
|
# Needs SteamVR's IPC; it starts and stops with SteamVR.
|
||||||
|
After=steamvr.service
|
||||||
|
PartOf=steamvr.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
# Runs in the dev container (built there against its libraries). Start the container in a
|
||||||
|
# scope of its own first (see scripts/container-up.sh), and clean up the process explicitly
|
||||||
|
# around distrobox enter.
|
||||||
|
ExecStartPre=-@REPO@/scripts/container-up.sh
|
||||||
|
ExecStartPre=-/usr/bin/pkill -x ft-powerd
|
||||||
|
ExecStart=%h/.local/bin/distrobox enter dev -- @REPO@/power/build/ft-powerd
|
||||||
|
# SIGTERM makes ft-powerd turn the displays back on before it exits.
|
||||||
|
ExecStopPost=-/usr/bin/pkill -x ft-powerd
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
# The container session (conmon) ignores SIGTERM; don't wait 90 s for it.
|
||||||
|
TimeoutStopSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=steamvr.service
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
// ft-powerd: turns the headset's displays off while nobody is using it (OpenVR background
|
||||||
|
// client, runs in the dev container as frametop-power.service).
|
||||||
|
//
|
||||||
|
// SteamVR turns the displays off 5 s after the proximity sensor says the headset came off
|
||||||
|
// (power.turnOffScreensTimeout). With something in front of the sensor, like a display
|
||||||
|
// mount, that never happens: the displays stay on all night, and Steam, which then thinks
|
||||||
|
// someone is wearing the headset, never puts it to sleep either. ft-powerd goes by use
|
||||||
|
// instead of the sensor. After DISPLAY_OFF_MIN minutes in which neither the headset nor a
|
||||||
|
// controller moved and no input device sent a key, button, or motion, it turns the
|
||||||
|
// backlight off, and it turns it back on at the next movement or input.
|
||||||
|
//
|
||||||
|
// Moving: more than DISPLAY_MOVE_MM (5 mm) or DISPLAY_MOVE_DEG (0.5 degrees) within 10 s.
|
||||||
|
// On a mount the headset's pose jitters by about 0.5 mm and 0.1 degrees, and its position
|
||||||
|
// drifts about 1 mm in two minutes (tracking), so a fixed reference would count the drift
|
||||||
|
// as moving sooner or later; a 10 s window never does, and anyone wearing the headset
|
||||||
|
// moves that much in 10 s now and then. The headset, the Frame controllers, trackers, and
|
||||||
|
// the 3D mouse's virtual controller count; the last moves only when the mouse or the gaze
|
||||||
|
// moves it.
|
||||||
|
// Input: every /dev/input device with keys or relative axes (mice, keyboards, the headset's
|
||||||
|
// buttons, the relay's virtual devices), read without grabbing and rescanned every few
|
||||||
|
// seconds for new ones. Keyboards the relay grabs for the desktop aren't seen, but typing
|
||||||
|
// in the headset moves it anyway.
|
||||||
|
//
|
||||||
|
// The displays: /sys/class/backlight/ae94000.dsi.0/brightness, the file SteamVR's own driver
|
||||||
|
// writes for standby ("cv: Set displays off" in vrserver.txt); 0 turns both panels'
|
||||||
|
// backlights off. The value from before goes back when they wake. The GPU keeps rendering
|
||||||
|
// and tracking keeps running, which is why they can wake the moment the headset moves, but
|
||||||
|
// it also means this saves the panels' power and light, not the whole headset's.
|
||||||
|
// If something else turns the backlight back on while it's off (SteamVR leaving standby,
|
||||||
|
// the brightness setting), that counts as use. While SteamVR has the headset in standby
|
||||||
|
// (taken off), SteamVR owns the displays and ft-powerd waits. The value to put back is
|
||||||
|
// also kept in ~/.cache/frametop/powerd-brightness until the displays wake, so a crash or
|
||||||
|
// a stop while they're off doesn't leave them dark: the next start puts it back.
|
||||||
|
//
|
||||||
|
// Control (datagrams on @ft_powerd; the reply goes to the sender's address):
|
||||||
|
// status -> "ok on|off|away <seconds since use> <timeout seconds, 0 = never>"
|
||||||
|
// (away: SteamVR has the headset in standby)
|
||||||
|
// off -> "ok": the displays off now, to try it (for 2 s nothing counts as use)
|
||||||
|
// on -> "ok": back on
|
||||||
|
// Settings (~/.config/frametop.conf, re-read when the file changes): DISPLAY_OFF_MIN (0 =
|
||||||
|
// never, the default), DISPLAY_MOVE_MM (5), DISPLAY_MOVE_DEG (0.5).
|
||||||
|
#include <openvr.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cerrno>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <csignal>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <fstream>
|
||||||
|
#include <map>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <dirent.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <linux/input.h>
|
||||||
|
#include <poll.h>
|
||||||
|
#include <sys/ioctl.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <sys/un.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using Clock = std::chrono::steady_clock;
|
||||||
|
|
||||||
|
const char *kBacklight = "/sys/class/backlight/ae94000.dsi.0/brightness";
|
||||||
|
|
||||||
|
std::string Home() {
|
||||||
|
const char *home = std::getenv("HOME");
|
||||||
|
return home ? home : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ConfPath() { return Home() + "/.config/frametop.conf"; }
|
||||||
|
std::string SavedPath() { return Home() + "/.cache/frametop/powerd-brightness"; }
|
||||||
|
|
||||||
|
std::map<std::string, std::string> ReadConfig() {
|
||||||
|
std::map<std::string, std::string> conf;
|
||||||
|
std::ifstream in(ConfPath());
|
||||||
|
std::string line;
|
||||||
|
while (std::getline(in, line)) {
|
||||||
|
line = line.substr(0, line.find('#'));
|
||||||
|
const auto eq = line.find('=');
|
||||||
|
if (eq == std::string::npos) continue;
|
||||||
|
auto trim = [](std::string s) {
|
||||||
|
s.erase(0, s.find_first_not_of(" \t"));
|
||||||
|
s.erase(s.find_last_not_of(" \t") + 1);
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
conf[trim(line.substr(0, eq))] = trim(line.substr(eq + 1));
|
||||||
|
}
|
||||||
|
return conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
double ConfDouble(const std::map<std::string, std::string> &c, const char *key, double fallback) {
|
||||||
|
auto it = c.find(key);
|
||||||
|
return it == c.end() || it->second.empty() ? fallback : std::atof(it->second.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
int ReadInt(const std::string &path, int fallback) {
|
||||||
|
std::ifstream in(path);
|
||||||
|
int v;
|
||||||
|
return in >> v ? v : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WriteInt(const std::string &path, int v) {
|
||||||
|
std::ofstream out(path);
|
||||||
|
out << v << "\n";
|
||||||
|
out.flush();
|
||||||
|
return bool(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- input devices ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct InputDev {
|
||||||
|
int fd; // -1: not open (no keys or relative axes, or it stopped reading)
|
||||||
|
std::string name;
|
||||||
|
bool ignored; // no keys or relative axes: not opened again
|
||||||
|
};
|
||||||
|
|
||||||
|
bool HasBit(const unsigned long *bits, int bit) {
|
||||||
|
return bits[bit / (8 * sizeof(long))] & (1UL << (bit % (8 * sizeof(long))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opens /dev/input/event* nodes we don't have open yet that have keys or relative axes.
|
||||||
|
void ScanInputs(std::map<std::string, InputDev> &devs) {
|
||||||
|
DIR *dir = opendir("/dev/input");
|
||||||
|
if (!dir) return;
|
||||||
|
while (dirent *e = readdir(dir)) {
|
||||||
|
if (std::strncmp(e->d_name, "event", 5) != 0) continue;
|
||||||
|
const std::string path = std::string("/dev/input/") + e->d_name;
|
||||||
|
const auto known = devs.find(path);
|
||||||
|
if (known != devs.end() && (known->second.fd >= 0 || known->second.ignored)) continue;
|
||||||
|
const int fd = open(path.c_str(), O_RDONLY | O_NONBLOCK | O_CLOEXEC);
|
||||||
|
if (fd < 0) continue;
|
||||||
|
unsigned long ev[(EV_MAX + 8 * sizeof(long)) / (8 * sizeof(long))] = {};
|
||||||
|
char name[128] = "?";
|
||||||
|
ioctl(fd, EVIOCGNAME(sizeof name), name);
|
||||||
|
if (ioctl(fd, EVIOCGBIT(0, sizeof ev), ev) < 0 || !(HasBit(ev, EV_KEY) || HasBit(ev, EV_REL))) {
|
||||||
|
close(fd);
|
||||||
|
devs[path] = {-1, name, true};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
devs[path] = {fd, name, false};
|
||||||
|
std::printf("watching %s (%s)\n", path.c_str(), name);
|
||||||
|
}
|
||||||
|
closedir(dir);
|
||||||
|
// Forget nodes that went away, so a new device reusing the number gets opened.
|
||||||
|
for (auto it = devs.begin(); it != devs.end();) {
|
||||||
|
if (access(it->first.c_str(), F_OK) != 0) {
|
||||||
|
if (it->second.fd >= 0) close(it->second.fd);
|
||||||
|
it = devs.erase(it);
|
||||||
|
} else {
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::fflush(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads everything waiting; returns the name of a device that sent a key or relative
|
||||||
|
// motion, or "".
|
||||||
|
std::string DrainInputs(std::map<std::string, InputDev> &devs) {
|
||||||
|
std::string used;
|
||||||
|
input_event evs[64];
|
||||||
|
for (auto &[path, d] : devs) {
|
||||||
|
if (d.fd < 0) continue;
|
||||||
|
while (true) {
|
||||||
|
const ssize_t n = read(d.fd, evs, sizeof evs);
|
||||||
|
if (n <= 0) {
|
||||||
|
if (n < 0 && errno != EAGAIN) { // unplugged: the next scan forgets it or opens it again
|
||||||
|
close(d.fd);
|
||||||
|
d.fd = -1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for (size_t i = 0; i < size_t(n) / sizeof(input_event); ++i)
|
||||||
|
if (evs[i].type == EV_KEY || evs[i].type == EV_REL) used = d.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return used;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- poses ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Where a device was at the start of the current window.
|
||||||
|
struct Anchor {
|
||||||
|
bool set = false;
|
||||||
|
float m[3][4];
|
||||||
|
std::chrono::steady_clock::time_point at;
|
||||||
|
};
|
||||||
|
constexpr auto kMoveWindow = std::chrono::seconds(10);
|
||||||
|
|
||||||
|
// How far a pose is from an anchor: metres and degrees.
|
||||||
|
void Distance(const Anchor &a, const vr::HmdMatrix34_t &p, double &metres, double °rees) {
|
||||||
|
const double dx = p.m[0][3] - a.m[0][3], dy = p.m[1][3] - a.m[1][3], dz = p.m[2][3] - a.m[2][3];
|
||||||
|
metres = std::sqrt(dx * dx + dy * dy + dz * dz);
|
||||||
|
double trace = 0; // trace(A^T P): the angle of the rotation between them
|
||||||
|
for (int r = 0; r < 3; ++r)
|
||||||
|
for (int c = 0; c < 3; ++c) trace += a.m[r][c] * p.m[r][c];
|
||||||
|
degrees = std::acos(std::clamp((trace - 1) / 2, -1.0, 1.0)) * 180 / M_PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetAnchor(Anchor &a, const vr::HmdMatrix34_t &p, std::chrono::steady_clock::time_point now) {
|
||||||
|
std::memcpy(a.m, p.m, sizeof a.m);
|
||||||
|
a.set = true;
|
||||||
|
a.at = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the displays ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
volatile std::sig_atomic_t stopSignal = 0;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
std::setvbuf(stdout, nullptr, _IOLBF, 0);
|
||||||
|
for (int s : {SIGTERM, SIGINT, SIGHUP}) std::signal(s, [](int sig) { stopSignal = sig; });
|
||||||
|
|
||||||
|
// Put back a value left behind by a run that ended with the displays off.
|
||||||
|
const int saved = ReadInt(SavedPath(), -1);
|
||||||
|
if (saved > 0) {
|
||||||
|
if (ReadInt(kBacklight, -1) == 0) {
|
||||||
|
WriteInt(kBacklight, saved);
|
||||||
|
std::printf("displays back on (brightness %d, left off by an earlier run)\n", saved);
|
||||||
|
}
|
||||||
|
std::remove(SavedPath().c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
const int ctl = socket(AF_UNIX, SOCK_DGRAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
|
||||||
|
{
|
||||||
|
sockaddr_un addr{};
|
||||||
|
addr.sun_family = AF_UNIX;
|
||||||
|
const char *name = "ft_powerd";
|
||||||
|
std::memcpy(addr.sun_path + 1, name, std::strlen(name));
|
||||||
|
if (bind(ctl, reinterpret_cast<sockaddr *>(&addr), offsetof(sockaddr_un, sun_path) + 1 + std::strlen(name)) != 0) {
|
||||||
|
std::perror("bind @ft_powerd (already running?)");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vr::EVRInitError err = vr::VRInitError_None;
|
||||||
|
vr::VR_Init(&err, vr::VRApplication_Background);
|
||||||
|
if (err != vr::VRInitError_None) {
|
||||||
|
std::fprintf(stderr, "VR_Init: %s\n", vr::VR_GetVRInitErrorAsEnglishDescription(err));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
vr::IVRSystem *sys = vr::VRSystem();
|
||||||
|
|
||||||
|
double offAfter = 0, moveMetres = 0.005, moveDegrees = 0.5;
|
||||||
|
timespec confTime{};
|
||||||
|
auto loadConfig = [&](bool force) {
|
||||||
|
struct stat st{};
|
||||||
|
const bool exists = stat(ConfPath().c_str(), &st) == 0;
|
||||||
|
if (!force && exists && st.st_mtim.tv_sec == confTime.tv_sec && st.st_mtim.tv_nsec == confTime.tv_nsec) return;
|
||||||
|
confTime = exists ? st.st_mtim : timespec{};
|
||||||
|
const auto conf = ReadConfig();
|
||||||
|
const double was = offAfter;
|
||||||
|
offAfter = std::max(0.0, ConfDouble(conf, "DISPLAY_OFF_MIN", 0)) * 60;
|
||||||
|
moveMetres = std::max(0.001, ConfDouble(conf, "DISPLAY_MOVE_MM", 5) / 1000);
|
||||||
|
moveDegrees = std::max(0.05, ConfDouble(conf, "DISPLAY_MOVE_DEG", 0.5));
|
||||||
|
if (force || was != offAfter)
|
||||||
|
std::printf("displays off after %s; moving means %.1f mm or %.2f deg\n",
|
||||||
|
offAfter > 0 ? (std::to_string(int(offAfter)) + " s without use").c_str() : "never (DISPLAY_OFF_MIN=0)",
|
||||||
|
moveMetres * 1000, moveDegrees);
|
||||||
|
};
|
||||||
|
loadConfig(true);
|
||||||
|
|
||||||
|
std::map<std::string, InputDev> inputs;
|
||||||
|
ScanInputs(inputs);
|
||||||
|
|
||||||
|
Anchor anchors[vr::k_unMaxTrackedDeviceCount];
|
||||||
|
vr::ETrackedDeviceClass classes[vr::k_unMaxTrackedDeviceCount] = {};
|
||||||
|
vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount];
|
||||||
|
|
||||||
|
auto now = Clock::now();
|
||||||
|
auto lastUse = now, lastScan = now, lastClasses = now - std::chrono::hours(1), lastConf = now;
|
||||||
|
auto graceUntil = now; // after "off", nothing counts as use until then
|
||||||
|
auto awaySince = now; // when the headset's activity level last agreed with `away`
|
||||||
|
bool off = false, away = false;
|
||||||
|
int restore = 0; // the brightness to put back
|
||||||
|
|
||||||
|
auto turnOff = [&](const char *why) {
|
||||||
|
const int b = ReadInt(kBacklight, -1);
|
||||||
|
if (b <= 0) return; // already dark (SteamVR standby) or unreadable
|
||||||
|
mkdir((Home() + "/.cache").c_str(), 0755);
|
||||||
|
mkdir((Home() + "/.cache/frametop").c_str(), 0755);
|
||||||
|
WriteInt(SavedPath(), b);
|
||||||
|
if (!WriteInt(kBacklight, 0)) {
|
||||||
|
std::printf("couldn't write %s\n", kBacklight);
|
||||||
|
std::remove(SavedPath().c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restore = b;
|
||||||
|
off = true;
|
||||||
|
std::printf("displays off: %s (brightness was %d)\n", why, b);
|
||||||
|
};
|
||||||
|
auto turnOn = [&](const std::string &why) {
|
||||||
|
if (!off) return;
|
||||||
|
off = false;
|
||||||
|
if (ReadInt(kBacklight, -1) == 0) WriteInt(kBacklight, restore);
|
||||||
|
std::remove(SavedPath().c_str());
|
||||||
|
std::printf("displays on: %s\n", why.c_str());
|
||||||
|
};
|
||||||
|
|
||||||
|
std::vector<pollfd> fds;
|
||||||
|
while (!stopSignal) {
|
||||||
|
// Sleep until input or 100 ms: input wakes the displays at once, poses every tick.
|
||||||
|
fds.clear();
|
||||||
|
fds.push_back({ctl, POLLIN, 0});
|
||||||
|
for (auto &[path, d] : inputs)
|
||||||
|
if (d.fd >= 0) fds.push_back({d.fd, POLLIN, 0});
|
||||||
|
poll(fds.data(), fds.size(), 100);
|
||||||
|
now = Clock::now();
|
||||||
|
const bool grace = now < graceUntil;
|
||||||
|
std::string use; // why this tick counts as use
|
||||||
|
|
||||||
|
const std::string dev = DrainInputs(inputs);
|
||||||
|
if (!dev.empty() && !grace) use = "input from " + dev;
|
||||||
|
if (now - lastScan > std::chrono::seconds(3)) {
|
||||||
|
ScanInputs(inputs);
|
||||||
|
lastScan = now;
|
||||||
|
}
|
||||||
|
if (now - lastConf > std::chrono::seconds(2)) {
|
||||||
|
loadConfig(false);
|
||||||
|
lastConf = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
vr::VREvent_t ev;
|
||||||
|
while (sys->PollNextEvent(&ev, sizeof ev)) {
|
||||||
|
if (ev.eventType == vr::VREvent_Quit) {
|
||||||
|
stopSignal = SIGTERM;
|
||||||
|
sys->AcknowledgeQuit_Exiting();
|
||||||
|
} else if (ev.eventType == vr::VREvent_TrackedDeviceActivated ||
|
||||||
|
ev.eventType == vr::VREvent_TrackedDeviceDeactivated) {
|
||||||
|
lastClasses = now - std::chrono::hours(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (now - lastClasses > std::chrono::seconds(5)) {
|
||||||
|
for (vr::TrackedDeviceIndex_t i = 0; i < vr::k_unMaxTrackedDeviceCount; ++i)
|
||||||
|
classes[i] = sys->GetTrackedDeviceClass(i);
|
||||||
|
lastClasses = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SteamVR's standby (headset taken off) turns the displays off itself; putting the
|
||||||
|
// headset on again (or the mount covering the sensor again) counts as use.
|
||||||
|
// A change counts once it has held for a second (the first reading after connecting
|
||||||
|
// is Idle).
|
||||||
|
const auto level = sys->GetTrackedDeviceActivityLevel(vr::k_unTrackedDeviceIndex_Hmd);
|
||||||
|
const bool awayNow = level == vr::k_EDeviceActivityLevel_Idle || level == vr::k_EDeviceActivityLevel_Standby ||
|
||||||
|
level == vr::k_EDeviceActivityLevel_Idle_Timeout;
|
||||||
|
if (awayNow == away) awaySince = now;
|
||||||
|
if (awayNow != away && now - awaySince >= std::chrono::seconds(1)) {
|
||||||
|
away = awayNow;
|
||||||
|
std::printf("SteamVR: headset %s\n", away ? "off (SteamVR's standby has the displays)" : "on");
|
||||||
|
if (!away) use = "headset on again";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw poses: recentering or a new play area doesn't move anything.
|
||||||
|
sys->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseRawAndUncalibrated, 0, poses, vr::k_unMaxTrackedDeviceCount);
|
||||||
|
for (vr::TrackedDeviceIndex_t i = 0; i < vr::k_unMaxTrackedDeviceCount; ++i) {
|
||||||
|
const auto c = classes[i];
|
||||||
|
if (c != vr::TrackedDeviceClass_HMD && c != vr::TrackedDeviceClass_Controller &&
|
||||||
|
c != vr::TrackedDeviceClass_GenericTracker)
|
||||||
|
continue;
|
||||||
|
const auto &p = poses[i];
|
||||||
|
if (!p.bPoseIsValid || p.eTrackingResult != vr::TrackingResult_Running_OK) {
|
||||||
|
anchors[i].set = false; // tracking back or a controller turned on: start over
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!anchors[i].set || grace || now - anchors[i].at > kMoveWindow) {
|
||||||
|
SetAnchor(anchors[i], p.mDeviceToAbsoluteTracking, now);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
double metres, degrees;
|
||||||
|
Distance(anchors[i], p.mDeviceToAbsoluteTracking, metres, degrees);
|
||||||
|
if (metres > moveMetres || degrees > moveDegrees) {
|
||||||
|
SetAnchor(anchors[i], p.mDeviceToAbsoluteTracking, now);
|
||||||
|
if (use.empty()) {
|
||||||
|
char why[96];
|
||||||
|
std::snprintf(why, sizeof why, "%s %u moved %.1f mm, %.2f deg",
|
||||||
|
c == vr::TrackedDeviceClass_HMD ? "headset" : "device", i, metres * 1000, degrees);
|
||||||
|
use = why;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (off && ReadInt(kBacklight, -1) > 0) {
|
||||||
|
// Something else lit them (SteamVR leaving standby, the brightness setting).
|
||||||
|
off = false;
|
||||||
|
std::remove(SavedPath().c_str());
|
||||||
|
use = "turned on elsewhere";
|
||||||
|
std::printf("displays on: turned on elsewhere\n");
|
||||||
|
}
|
||||||
|
if (!use.empty()) {
|
||||||
|
lastUse = now;
|
||||||
|
turnOn(use);
|
||||||
|
}
|
||||||
|
if (!off && !away && offAfter > 0 && now - lastUse >= std::chrono::duration<double>(offAfter)) {
|
||||||
|
char why[64];
|
||||||
|
if (offAfter >= 60) std::snprintf(why, sizeof why, "%g min without use", offAfter / 60);
|
||||||
|
else std::snprintf(why, sizeof why, "%.0f s without use", offAfter);
|
||||||
|
turnOff(why);
|
||||||
|
if (!off) lastUse = now; // couldn't: try again after another timeout, not every tick
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control socket.
|
||||||
|
char buf[256];
|
||||||
|
sockaddr_un from{};
|
||||||
|
socklen_t fromLen = sizeof from;
|
||||||
|
ssize_t n;
|
||||||
|
while ((n = recvfrom(ctl, buf, sizeof buf - 1, 0, reinterpret_cast<sockaddr *>(&from), &fromLen)) > 0) {
|
||||||
|
buf[n] = 0;
|
||||||
|
std::string cmd(buf), reply = "ok";
|
||||||
|
while (!cmd.empty() && (cmd.back() == '\n' || cmd.back() == ' ')) cmd.pop_back();
|
||||||
|
if (cmd == "status") {
|
||||||
|
char s[96];
|
||||||
|
std::snprintf(s, sizeof s, "ok %s %.0f %.0f", off ? "off" : away ? "away" : "on",
|
||||||
|
std::chrono::duration<double>(now - lastUse).count(), offAfter);
|
||||||
|
reply = s;
|
||||||
|
} else if (cmd == "off") {
|
||||||
|
graceUntil = now + std::chrono::seconds(2);
|
||||||
|
turnOff("asked to");
|
||||||
|
if (!off) reply = "error the displays are already off, or the backlight can't be written";
|
||||||
|
} else if (cmd == "on") {
|
||||||
|
lastUse = now;
|
||||||
|
turnOn("asked to");
|
||||||
|
} else {
|
||||||
|
reply = "error unknown command: " + cmd;
|
||||||
|
}
|
||||||
|
if (fromLen > offsetof(sockaddr_un, sun_path))
|
||||||
|
sendto(ctl, reply.data(), reply.size(), MSG_DONTWAIT, reinterpret_cast<sockaddr *>(&from), fromLen);
|
||||||
|
fromLen = sizeof from;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
turnOn("stopping");
|
||||||
|
std::printf("stopped (signal %d)\n", int(stopSignal));
|
||||||
|
vr::VR_Shutdown();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install, start, stop, or inspect ft-powerd on the Frame (runs in the dev container).
|
||||||
|
# Usage: power/run.sh install|uninstall # user service, starts with SteamVR
|
||||||
|
# power/run.sh start|stop|restart|status|log [lines]
|
||||||
|
# power/run.sh off|on # the displays off now (to try it) or back on
|
||||||
|
set -euo pipefail
|
||||||
|
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
. "$root/scripts/_env.sh"
|
||||||
|
frame="$root/scripts/frame.sh"
|
||||||
|
unit=frametop-power.service
|
||||||
|
case ${1:-status} in
|
||||||
|
install)
|
||||||
|
"$root/scripts/sync.sh" >/dev/null
|
||||||
|
fill_template "$root/power/$unit" | on_frame "mkdir -p ~/.config/systemd/user && cat > ~/.config/systemd/user/$unit"
|
||||||
|
"$frame" --host "set -e; pkill -x ft-powerd || true
|
||||||
|
systemctl --user daemon-reload; systemctl --user enable --now $unit
|
||||||
|
# It comes up once SteamVR runs (it starts with it); distrobox enter takes a few seconds.
|
||||||
|
for i in \$(seq 20); do systemctl --user is-active --quiet $unit && break; sleep 1; done
|
||||||
|
echo \"$unit: \$(systemctl --user is-active $unit)\"; journalctl --user -u $unit --no-pager -o cat -n 3" ;;
|
||||||
|
uninstall) "$frame" --host "systemctl --user disable --now $unit 2>/dev/null; rm -f ~/.config/systemd/user/$unit; systemctl --user daemon-reload; echo removed" ;;
|
||||||
|
start|stop|restart) "$frame" --host "systemctl --user $1 $unit; systemctl --user is-active $unit" ;;
|
||||||
|
status) "$frame" --host "systemctl --user is-active $unit; python3 -c 'import socket; s=socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM); s.bind(\"\"); s.settimeout(1); s.sendto(b\"status\", \"\\0ft_powerd\"); print(s.recv(256).decode())' 2>/dev/null || echo 'ft-powerd not answering'" ;;
|
||||||
|
log) "$frame" --host "journalctl --user -u $unit --no-pager -o cat -n ${2:-30}" ;;
|
||||||
|
off|on) "$frame" --host "python3 -c 'import socket; s=socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM); s.bind(\"\"); s.settimeout(1); s.sendto(b\"$1\", \"\\0ft_powerd\"); print(s.recv(256).decode())'" ;;
|
||||||
|
*) echo "usage: $0 install|uninstall|start|stop|restart|status|log|off|on" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
+3
-1
@@ -21,7 +21,7 @@ git -C "$repo" log -1 --format='Frametop %h (%cd)' --date=short 2>/dev/null || e
|
|||||||
podman container inspect -f 'dev container: running={{.State.Running}} image={{.ImageName}}' dev 2>/dev/null || echo "dev container: missing"
|
podman container inspect -f 'dev container: running={{.State.Running}} image={{.ImageName}}' dev 2>/dev/null || echo "dev container: missing"
|
||||||
|
|
||||||
section Services
|
section Services
|
||||||
for u in frametop-input-relay frametop-pointer; do
|
for u in frametop-input-relay frametop-pointer frametop-power; do
|
||||||
echo "$u: $(systemctl --user is-enabled $u 2>/dev/null) / $(systemctl --user is-active $u 2>/dev/null)"
|
echo "$u: $(systemctl --user is-enabled $u 2>/dev/null) / $(systemctl --user is-active $u 2>/dev/null)"
|
||||||
done
|
done
|
||||||
echo "desktop: $(pgrep -x ft-screens >/dev/null && echo running || echo 'not running'), plasmashell: $(pgrep -c plasmashell || true)"
|
echo "desktop: $(pgrep -x ft-screens >/dev/null && echo running || echo 'not running'), plasmashell: $(pgrep -c plasmashell || true)"
|
||||||
@@ -42,6 +42,8 @@ section "Input relay (last 60 lines)"
|
|||||||
journalctl --user -u frametop-input-relay -n 60 --no-pager -o short 2>/dev/null
|
journalctl --user -u frametop-input-relay -n 60 --no-pager -o short 2>/dev/null
|
||||||
section "Pointer helper (last 60 lines)"
|
section "Pointer helper (last 60 lines)"
|
||||||
journalctl --user -u frametop-pointer -n 60 --no-pager -o short 2>/dev/null
|
journalctl --user -u frametop-pointer -n 60 --no-pager -o short 2>/dev/null
|
||||||
|
section "Power service (last 30 lines)"
|
||||||
|
journalctl --user -u frametop-power -n 30 --no-pager -o short 2>/dev/null
|
||||||
for f in /tmp/frametop-session.log /tmp/frametop-screens.log /tmp/frametop-layout.log; do
|
for f in /tmp/frametop-session.log /tmp/frametop-screens.log /tmp/frametop-layout.log; do
|
||||||
section "$f (last 60 lines)"
|
section "$f (last 60 lines)"
|
||||||
tail -n 60 "$f" 2>/dev/null || echo "missing"
|
tail -n 60 "$f" 2>/dev/null || echo "missing"
|
||||||
|
|||||||
@@ -31,5 +31,8 @@ POINTER_GAZE_HOLD=0.5 # gaze mode: a press held this long (s) without movin
|
|||||||
POINTER_GAZE_SHOW=1 # gaze mode: the dot shows this long (s) after the mouse moves it; also while a press is held, and a pulse per click
|
POINTER_GAZE_SHOW=1 # gaze mode: the dot shows this long (s) after the mouse moves it; also while a press is held, and a pulse per click
|
||||||
META_DASHBOARD=0 # 1 = a Meta tap on a pass-through keyboard toggles the SteamVR dashboard (pointer mode only)
|
META_DASHBOARD=0 # 1 = a Meta tap on a pass-through keyboard toggles the SteamVR dashboard (pointer mode only)
|
||||||
SHARE_KEYS=0 # 1 = keys of keyboards grabbed for the desktop also go to @frametop_keys, for hotkey tools (any local process can listen)
|
SHARE_KEYS=0 # 1 = keys of keyboards grabbed for the desktop also go to @frametop_keys, for hotkey tools (any local process can listen)
|
||||||
|
DISPLAY_OFF_MIN=0 # minutes without use before frametop-power turns the displays off, even if the headset seems worn (0 = never)
|
||||||
|
DISPLAY_MOVE_MM=5 # displays off: the headset or a controller moving this far (mm) within 10 s counts as use
|
||||||
|
DISPLAY_MOVE_DEG=0.5 # displays off: or turning this far (degrees) within 10 s
|
||||||
LAYOUT_GRAB_OFFSET=0.075 # arranging screens: where the floating window's grab bar is, in metres below the screen
|
LAYOUT_GRAB_OFFSET=0.075 # arranging screens: where the floating window's grab bar is, in metres below the screen
|
||||||
LAYOUT_SLIDE_SPEED=0.5 # arranging screens: how fast the invisible controller slides a grabbed screen (m/s)
|
LAYOUT_SLIDE_SPEED=0.5 # arranging screens: how fast the invisible controller slides a grabbed screen (m/s)
|
||||||
Reference in new issue
Block a user