diff --git a/docs/how-the-frame-works.md b/docs/how-the-frame-works.md index 5c392d1..cdf2498 100644 --- a/docs/how-the-frame-works.md +++ b/docs/how-the-frame-works.md @@ -55,6 +55,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600 | **Tools on the image:** Python 3.12.3, `ffmpeg`, `openssl`, `curl`, `rsync`, `zip`/`unzip`, `flatpak`, `wpctl`, `podman`. **No `adb`.** `steamos` is uid 1000, in `wheel`, and sudoers has `%wheel ALL=(ALL) ALL`, so `sudo -S` takes the Developer Mode password on stdin. **Verified 2026-09-27.** | Running Frame Control's server on the Frame (`FRAME_LOCAL=1`, [iphone.md](iphone.md)) | | **Each Lepton instance is a podman container** named `lepton-steamlaunch-`, labelled with its ADB port (`podman ps --format '{{.Names}} {{.Labels.adb_port}}'`). `podman exec /system/bin/sh -c '…'` runs Android's shell inside it with no adb at all (used for `pidof` and `logcat` by the app tester). Running `wm size`/`wm density` that way is untested. **Verified 2026-09-27.** | `ui/frame_android.py`, the iPhone app's display settings | | **Asleep means off the network.** In standby the Frame stops answering on its LAN address, `frame.local` and Tailscale alike (`Host is down`, `No route to host`, timeouts), and ping fails. It was unreachable for about 2.5 hours until woken. Nothing over SSH can wake it. **Verified 2026-09-27.** | Frame Control's offline banner and retries | +| **What puts it to sleep is Steam's idle timer**, not logind. The journal shows `steamui_system: Switching to power state: [ k_ESystemPowerState_Sleep ] reason: 'ComputeNextPowerState: active: 3600 < 3600 (k_EACState_Connected)'`, then Steam suspends. SSH work doesn't count as activity. The timers are the client settings `system_idle_suspend_ac_sec` (3600) and `system_idle_suspend_battery_sec` (900); 0 means Never (Settings → Power → Sleep after inactivity). They can be written over DevTools the way the settings page does. logind refuses a `systemd-inhibit --mode=block` sleep lock from an SSH session (`Interactive authentication required`) but accepts one started with `systemd-run --user`. `scripts/keep-awake.sh on|off|status` does both and restores the old timers on `off`. **Verified 2026-09-28**, BUILD_ID 20260925.6191901. Whether Steam's suspend honours the inhibitor on its own is **inferred** (polkit gives `steamos` no `suspend-ignore-inhibit`), not tested. | Keeping the Frame awake for agent work | | **Battery at full on a charger** can read `Discharging` at about 0 W (for example 99 %, 0.0 W, USB-C PD 18 W). Treat under 0.5 W on a charger as "not charging", not "draining". **Verified 2026-09-27.** | Frame Control's battery card | | **The OS image is downloadable.** Valve's recovery images for the Frame are at `https://steamdeck-images.steamos.cloud/recovery/`. The root filesystem inside is btrfs, and it runs as an SSH test target on ARM64 Linux without the headset (`tests/frame-container/frame-image.sh`). **Verified 2026-09-27.** | [recovery-and-images.md](recovery-and-images.md) | | **Boot / recovery menu.** Hold Power ~10 s until the LED goes off, then power on while holding the **AUX button on top of the Power button** (not the volume keys) until a text menu appears. Entries: `Current` (SteamOS-A/B + build), `Previous` (the other A/B slot), `Boot from USB`, `Repair Steam Installation`, `Erase User Data` (factory reset), `ADB mode`, `Battery Ship Mode`. It auto-boots `Current` after a ~15 s countdown. **Volume Up/Down (left side) move, AUX (right side) selects.** For a boot loop, Valve says pick `Previous` (keeps user data); then `Repair Steam Installation`; `Erase User Data` wipes `~` (SSH keys, Tailscale, Flatpaks, T3 setup). Last resort is a full re-image, two ways: (1) USB: write `steamframe-oobe-repair-.img.bz2` to an 8 GB+ USB-C stick (Balena Etcher on the Mac), pick `Boot from USB`, then use "Wipe Device & Install SteamOS" / "Repair SteamOS" (keeps games and personal content) from the recovery desktop; (2) cable/EDL: `steamframe-oobe-repair-qdl-.tar.gz`, run `flash.sh` (Linux) or `flash.cmd` (Windows), then with the Frame off for 10 s hold Power + Vol Up + Vol Down for 10 s and plug it in; it reflashes and reboots. Both images: `https://steamdeck-images.steamos.cloud/recovery/` (build 20260922.5153644, 0.3.0, 3.8 GiB each, no published checksums); local copies in `~/Downloads/steam-frame-recovery/`. File names, checksums and what's inside: [recovery-and-images.md](recovery-and-images.md). Source: Valve's [SteamOS Recovery FAQ](https://help.steampowered.com/en/faqs/view/1B71-EDF2-EB6D-2BB3) and [Installation and Repair FAQ](https://help.steampowered.com/en/faqs/view/65B4-2AA3-5F37-4227), plus a menu photo in [EloiStree/HelloSteamFrame#9](https://github.com/EloiStree/HelloSteamFrame/issues/9). **Inferred** (Valve docs, 2026-09-26); not yet tried on our Frame. | Recovering from a boot loop | diff --git a/scripts/keep-awake.sh b/scripts/keep-awake.sh new file mode 100755 index 0000000..c4a3301 --- /dev/null +++ b/scripts/keep-awake.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env zsh +# Mac-side: stop the Steam Frame from going to sleep while an agent works on it. +# +# The Frame sleeps when Steam's own idle timer runs out ("Sleep after +# inactivity": 60 min on AC, 15 min on battery by default). SSH activity +# doesn't count as input, and asleep the Frame is off the network. `on` sets +# both timers to Never through Steam's UI (DevTools on 127.0.0.1:8080, via +# ui/frame_steam.py) and holds a logind sleep inhibitor as a user unit. +# `off` drops the inhibitor and restores the timers `on` saved. +# +# Usage: +# scripts/keep-awake.sh on +# scripts/keep-awake.sh off +# scripts/keep-awake.sh status +set -euo pipefail + +FRAME_ALIAS=${FRAME_ALIAS:-frame} +HERE=${0:A:h} +cmd=${1:-status} +case $cmd in on|off|status) ;; *) echo "usage: keep-awake.sh on|off|status" >&2; exit 2 ;; esac + +ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \ + 'mkdir -p ~/.cache/frame-control && cat > ~/.cache/frame-control/frame_steam.py' < "$HERE/../ui/frame_steam.py" + +# Runs on the Frame. Verified 2026-09-28 (BUILD_ID 20260925.6191901): the +# timers are client settings system_idle_suspend_{ac,battery}_sec (0 = Never), +# written the way Steam's settings page does (steamui module exporting the +# SetSetting wrapper). logind refuses an inhibitor from an SSH session +# ("Interactive authentication required") but allows one from a user unit. +ssh "$FRAME_ALIAS" python3 - "$cmd" <<'EOF' +import json, os, subprocess, sys +sys.path.insert(0, os.path.expanduser("~/.cache/frame-control")) +from frame_steam import Page + +cmd = sys.argv[1] +saved_path = os.path.expanduser("~/.cache/frame-control/keep-awake.json") +unit = "fc-keep-awake" +keys = ("system_idle_suspend_ac_sec", "system_idle_suspend_battery_sec") + +if cmd == "off": # release the lock first, even if Steam's UI is down + subprocess.run(["systemctl", "--user", "stop", unit], stderr=subprocess.DEVNULL) +page = Page() +def read(): + return {k: page.eval(f"settingsStore.clientSettings.{k}") for k in keys} +def write(values): + page.eval("""(async () => { let req; + webpackChunksteamui.push([[Symbol()], {}, r => { req = r }]); + const mod = Object.keys(req.m).map(id => req.m[id].toString().includes("Settings.SetSetting") ? req(id) : null).find(Boolean); + const set = Object.values(mod).find(f => typeof f == "function" && f.toString().includes("SetSetting(")); + for (const [k, v] of Object.entries(%s)) await set(k, v); + await new Promise(r => setTimeout(r, 1000)); })()""" % json.dumps(values)) +def inhibitor(): + return subprocess.run(["systemctl", "--user", "is-active", "-q", unit]).returncode == 0 + +if cmd == "on": + current = read() + if not os.path.exists(saved_path): + with open(saved_path, "w") as f: + json.dump(current, f) + write({k: 0 for k in keys}) + if not inhibitor(): + subprocess.run(["systemd-run", "--user", "-q", f"--unit={unit}", + "--description=Frame Control: keep the Frame awake", + "systemd-inhibit", "--what=sleep:idle:handle-suspend-key:handle-power-key", + "--who=Frame Control", "--why=Keep the Frame awake while an agent works on it", + "--mode=block", "sleep", "infinity"], check=True) +elif cmd == "off": + if os.path.exists(saved_path): # no backup: leave the timers as they are + with open(saved_path) as f: + write(json.load(f)) + os.remove(saved_path) + +print(json.dumps({"timers": read(), "inhibitor": inhibitor()})) +EOF