diff --git a/CHANGELOG.md b/CHANGELOG.md index 6852c4b..4dfc6d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,3 +7,16 @@ and permission/missing-drive states. Includes instructions for Steam's own Move UI; Frame Control does not move or delete game files. The read-only parser was checked on a real Frame; the connected UI and game moves were not. + +- Add `scripts/frame-doctor.sh` for read-only setup and pairing diagnostics, + with verified SSH identity, private summaries and actionable next steps + (issue #34). Connection steps links to the diagnostic guide. Repairs remain + manual; restart-loop evidence stops deeper diagnostics. The real Frame + verified the SSH connection and safety stop; deeper checks were skipped. +- Fixed headset smoke cleanup reporting success while a smoke-title process + could remain running after its files were removed. Cleanup now stops and + rechecks processes tied to exact reserved test-title paths, uses PID handles + for safe TERM/KILL escalation, and records stopped PIDs. Unrelated titles and + shared Wine services are left alone. Verified with local Linux process + regressions and a 13/13 real Frame smoke run that stopped five test-owned + Proton process PIDs before removing the Windows test title. diff --git a/docs/backup-and-recovery.md b/docs/backup-and-recovery.md index dc7ad26..d420c08 100644 --- a/docs/backup-and-recovery.md +++ b/docs/backup-and-recovery.md @@ -14,6 +14,13 @@ reported Game Mode, so **Return to Game Mode** changed nothing. Not tested on a headset: a restore after a real factory reset, a restore that replaces files, Android data restore, and the switch out of a real Desktop Mode session. Unit tests and the fake Frame cover those (see [Verification](#verification)). +On 2026-10-24 a read-only session check on the same build again reported Game +Mode (`gamescope=true`, `plasma=false`, `nested_desktop=false`) and the +`steamos-session-select` command was present. Opening the nested desktop for a +clipboard test did not enter full Desktop Mode. A full session switch was not +attempted because it restarts the headset session and a reliable return path +was not established; the **Return to Game Mode** branch for a real full +Desktop Mode session remains unverified. ## Back up diff --git a/docs/battery-health.md b/docs/battery-health.md new file mode 100644 index 0000000..4f80458 --- /dev/null +++ b/docs/battery-health.md @@ -0,0 +1,59 @@ +# Battery and health monitoring + +Open **Control Center → Battery & thermal health**, or expand **System +information**, to read the Frame's charge, charging state, signed battery +power, battery temperature and driver-reported health. Optional readings show +charge cycles, full charge capacity, design capacity and full / design capacity. +The existing battery chip also shows charging state and temperature. + +These are read-only readings from the Linux host's `/sys/class/power_supply`, +not Android's container battery view. The probe prefers the documented Frame +fuel gauge, `max1720x_bat_7-36`, over other battery nodes, excludes peripheral +(`scope=Device`) and absent batteries, and otherwise uses a deterministic +battery fallback. The UI shows the selected source path. + +| Reading | Linux attribute | Interpretation | +|---|---|---| +| Charge | `capacity` | Integer 0–100 percent; zero is a real reading | +| Charging state | `status` | Charging, Full, Discharging or Not charging; missing/unknown states stay unavailable | +| Battery power | `voltage_now × current_now` | Microvolts and microamps converted to signed watts; positive means charging on the Frame | +| Temperature | `temp` | Tenths of a degree Celsius, including valid negative readings | +| Battery health | `health` | Driver status such as Good or Overheat; not a wear percentage | +| Charge cycles | `cycle_count` | Driver-reported count, including zero | +| Full/design capacity | `charge_full`, `charge_full_design` | Microamp-hours converted to mAh | +| Full / design capacity | Ratio of those two capacities | Fuel-gauge estimate; not a diagnosis or a guaranteed runtime | +| Time estimate | `time_to_full_now`, `time_to_empty_now` | Seconds; displayed only for the corresponding charging/discharging state | + +Unavailable means the field is absent, unreadable or invalid. One missing +sensor does not hide the others. Zero design capacity cannot produce a ratio. Time estimates outside 1 second +to 7 days and battery temperatures outside −273.15 to 200 °C are rejected +as invalid readings; these bounds are not thermal warning thresholds. +The ratio may exceed 100% because full capacity is a gauge estimate, so the app +does not clamp it or invent a wear warning. The app does not estimate health +from charge percentage or assign temperature thresholds. Existing thermal +alerts use the kernel's own hot/critical trip points. + +A connected charger does not necessarily mean charging: the Frame can still +drain under load or sit idle near full charge. A 100% capacity reading alone +does not prove the driver reports Full. Missing status is explicitly unknown; +near-zero measured battery power with a charger is shown as not charging. +Failed, stale or disconnected status samples clear Control Center readings. + +**No charge controls are provided.** Frame's SteamOS charge-limit backend is +unsupported; see [charge-limit research](charge-limit.md). Monitoring never +writes to power supplies, pauses charging, changes current or sets a limit. + +## Verification + +`tests/test_battery.py` uses temporary sysfs-like files for conversion, +malformed/missing values, permission failures, source selection and partial +sensor support. `tests/control_center.cjs` exercises charging states, optional +metrics and failed/stale/disconnected sample handling. These tests do not +connect to a headset. On 2026-10-24, the PR's uninstalled parser was also +piped over SSH to a real Frame running SteamOS 0.5.6 (build +`20261015.6141207`). Its read-only sysfs result selected +`max1720x_bat_7-36` and reported 99% charge, Not charging, 24.9 °C, +Good driver health, 14 cycles, 2,920 mAh full and 2,700 mAh design capacity +(108.1% fuel-gauge estimate). The Control Center UI was not run against this +headset; Windows and macOS were not tested. Optional fields are not claimed to +be available on every firmware build. diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..10f7bd8 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,9 @@ +# Changelog + +## Unreleased + +- Battery monitoring now selects the Frame fuel gauge predictably, handles + missing or invalid readings, and shows optional cycle count, full/design + capacity and estimated capacity health in Control Center. Unknown charging + status stays explicit. Monitoring is read-only; charge limits remain + unsupported on Frame. The read-only parser was checked on a real Frame; the UI was not. diff --git a/docs/charge-limit.md b/docs/charge-limit.md index 1bf351b..eef5d9e 100644 --- a/docs/charge-limit.md +++ b/docs/charge-limit.md @@ -18,6 +18,9 @@ read-only checks below on a Frame running SteamOS 0.5.6 (build 20261015.6141207) `charging_enable` and `charging_pause`, both `root`-only (`-rw-r--r--`). - `deckard-charger.service` (Deckard Battery Charger service) is enabled and running. +For safe read-only readings in Control Center, see [battery and health +monitoring](battery-health.md). It does not provide charging controls. + ## How Steam does it on other devices - Steam's **Settings → Power → Battery Charge Limit** shipped to the Deck in diff --git a/docs/file-transfer.md b/docs/file-transfer.md index 8b9e206..63b937b 100644 --- a/docs/file-transfer.md +++ b/docs/file-transfer.md @@ -62,6 +62,12 @@ refused with a message, so send big things as a file. An empty clipboard, or one holding an image, says so. Neither the server nor its log ever records what was on the clipboard. -**Not tested on a headset yet.** Reading uses the same Klipper interface as -sending, which was verified on 2026-09-25, but `getClipboardContents` itself -hasn't been tried on a Frame. The fake Frame (`tests/fakeframe`) answers it. +**Verified on a Frame, 2026-10-24:** SteamOS 0.5.6, build +`20261015.6141207`. With a temporary nested desktop open, Frame Control sent +a 47-character test marker to Klipper, then **Copy the headset's clipboard** +read it through `getClipboardContents` and set a Linux PC's X11 clipboard. +Reading that PC clipboard returned the exact marker. The prior headset +clipboard was restored and the test desktop was closed. The PC side used a +temporary X display on lxso2; macOS and Windows clipboard backends, a user's +copy action in a headset app, and image/size error cases remain untested on a +real headset. The fake Frame (`tests/fakeframe`) covers the protocol cases. diff --git a/docs/frame-doctor.md b/docs/frame-doctor.md new file mode 100644 index 0000000..7b43bca --- /dev/null +++ b/docs/frame-doctor.md @@ -0,0 +1,121 @@ +# Frame doctor + +Run `scripts/frame-doctor.sh` from a checkout on macOS or Linux to diagnose +first-time setup, pairing and common Frame failures without applying repairs. +It needs Bash, Python 3 and OpenSSH on the computer, and Python 3 on the Frame. +Windows users can run `python scripts/frame_doctor.py` with Python and OpenSSH +installed; the desktop app does not bundle this command. + +```sh +scripts/frame-doctor.sh +scripts/frame-doctor.sh --alias bedroom --lan frame.local +scripts/frame-doctor.sh --alias frame --lan 192.168.1.40 +``` + +Use only addresses belonging to your intended headset. The command tries the +existing `frame` SSH alias first (Tailscale if setup configured it that way), +then `frame.local`, then USB-C at `10.86.200.233`. `--alias`, `--lan` and `--usb` +override those defaults. It reports the successful path without printing its +address. It neither discovers other devices nor enables Tailscale. + +The existing alias supplies the user, port, keys and known-hosts file. LAN and +USB overrides preserve its `HostKeyAlias`, or its configured hostname if no +alias was set. A first connection therefore needs trusted setup first. The +doctor never accepts a new host key, deletes a saved key, generates keys or +sends a pairing request. It uses your trusted OpenSSH configuration; OpenSSH +can evaluate `Match exec` conditions in that configuration. Proxy commands, +jumps, local commands, forwarding and connection sharing are disabled for +these direct diagnostic connections. + +## Reading the result + +Each of the eleven runbook sections has one line: `OK`, `WARN`, `BROKEN` or +`UNKNOWN`. `OK` means only that the stated check passed, not that the whole +feature works. Optional features that are off may produce a warning. + +| Section | Read-only evidence and limits | +|---|---| +| 1. Boot and crash history | Previous-boot kernel-oops signatures; one day of coredump history. An oops alone does not prove an unclean reset. | +| 2. Wi-Fi | NetworkManager's wlan0 state, without SSIDs or connection profiles. | +| 3. Displays and SteamVR | DSI timeout signatures in the recent kernel journal; no display, compositor or game is launched. | +| 4. Steam and storage | Free space in home and `devkit-game`, including separate mounts; missing Steam install marker. No CRC scan or restart. | +| 5. Idle sleep and keep-awake | Existing keep-awake unit state; never takes ownership or changes idle timers. | +| 6. Remote wake | WoWLAN state, without arming it or testing suspend. Wake with the power button. | +| 7. Lepton | Recent port-clash, binder and data-clearing signatures; no Android/container commands. | +| 8. Chromium XR | Crash history; no browser profile or environment inspection. | +| 9. KDE Connect | Process presence; absence is normal when sharing is off. | +| 10. Streaming and capture | ffmpeg crash history; no capture or encoder is started. | +| 11. Panels | Always `UNKNOWN`: placement requires the user's visual confirmation. | + +Journal samples are capped at 2,000 entries; current-boot samples cover the +last ten minutes. This can miss older or high-volume failures. Empty, +inaccessible or permission-limited journals are `UNKNOWN`, never zero errors. +Commands time out individually, and each SSH probe is bounded to 90 seconds. + +Exit codes: **0** if all reported checks are OK; **1** for warnings, broken or +unknown checks after connecting; **2** if no authenticated SSH path exists or +local SSH setup is unavailable. Panel verification currently means a connected +run normally exits 1. Automation should read the labelled checks rather than +treating a warning as proof the headset is unusable. + +## First-time setup and pairing + +- **No SSH response:** wake the headset with its power button. Check the cable + or Steam Settings → Internet, then enable Developer Mode. Sleep and network + failure can look alike; a timeout cannot distinguish them. +- **Key rejected:** open **Set Up Connection** in Frame Control. In the headset, + open Steam Settings → Developer → **Pair new host**, then approve setup's + request. The doctor only checks whether devkit port 32000 responds; that + does not prove pairing mode is open or the service is healthy. +- **Unknown or changed identity:** verify the intended headset through setup + or Devices → **Check its key**. Do not delete `known_hosts` to bypass this. +- **No devkit response:** check Developer Mode and the address. See + [SSH setup](ssh.md) for the manual fallback. The doctor cannot turn SSH on. + +No password is requested. Enter any password required by the separate setup +flow only in its local terminal; never paste it into an issue or report. + +## Restart safety and recovery + +Before deeper checks, and again afterwards, the doctor samples Steam and +SteamVR `NRestarts` and health-check tracker sizes two seconds apart. Rising +counters stop diagnostics immediately. Nonempty health-check trackers also stop +deeper checks even when stable, since a short sample could miss a slower loop. +Missing counters or counters that reset stop deeper checks as `UNKNOWN`. Stable +counters with empty trackers still cannot rule out a slower restart loop. + +The [historical runbook from PR #33](https://github.com/saphid/frame-control/blob/cce3d940302054ab5246c92d740afaefc4e58d38/docs/frame-doctor.md) +is the source for these section numbers and observed signatures (September +2026 builds). Its section 4 describes freezing and verifying health-check +trackers before prolonged recovery during a live loop. That changes headset +state, so this read-only implementation stops and directs you there instead. +It does not implement issue #34's automatic fixes, before/after repair runs, +or full recovery automation. Review build-specific recovery instructions with +the person using the headset; a repair can remove data. + +Never reload `ath12k`, suspend remotely, kill gamescope to diagnose a loop, +reset or force power off during extraction/repair, or write passwords to disk. +The doctor never restarts services, signals processes, repairs Steam, changes +WoWLAN/standby settings, takes keep-awake ownership, installs anything or writes +a report. Nothing needs restoring after its checks. Manual/device tests must +follow the [shared-device lock and battery procedure](testing.md#headset-smoke-test). + +## Privacy and verification + +Only fixed diagnostic summaries leave the remote probe. Raw journals, process +arguments, environment variables, SSH errors, account IDs, network addresses, +SSIDs, keys and tokens are not printed. No telemetry or HTTP request is sent; +the only pairing-service probe is a TCP connection. SSH produces normal access +logs. Review summaries before sharing them; feature availability can still be +personal information. Redirecting stdout saves a report only if you choose it. + +Unit tests use mocked boundaries. `scripts/e2e.sh test_doctor` covers asleep, +disk-full, SSH/devkit-off and missing-key faults against the fake Frame. Its +stable restart counters are explicitly simulated, not proof about SteamOS. +On 2026-10-24 the script ran read-only against a real Frame, SteamOS build +`20261015.6141207`. It verified the configured SSH alias and key login, +then reported `WARN Restart safety`: nonempty health-check trackers. It +correctly skipped all eleven deeper sections and exited 1. No raw logs, +addresses or keys were printed, and no repair or restart was attempted. +This verifies the safety stop on that device; the deeper checks are not +claimed as headset-tested. diff --git a/docs/recording.md b/docs/recording.md index 25d831c..963a52f 100644 --- a/docs/recording.md +++ b/docs/recording.md @@ -149,11 +149,12 @@ recording with Steam's file names and export it with the real `ffmpeg`. ## What hasn't been checked -None of this has run on a Steam Frame yet. In particular: +The 2026-10-24 read-only clip list on build `20261015.6141207` again returned +zero clips: its `gamerecordings` directory held only `gamerecording.pb`. No +Steam recording was created during that check, so a real Steam clip export is +still unverified. The recording and Stop path were verified earlier on that +build, as described above. Remaining checks: -- That SteamOS's `ffmpeg` can read `@DEFAULT_MONITOR@`. It has the PulseAudio - output device (the media player plays through it), so the input is expected, - but if it fails the recording is video only, as above. - Audio and video sync. Both inputs are stamped with the wall clock, because the camera device and the audio server otherwise count from different clocks. - The exact layout of `gamerecordings` on the Frame's SteamOS build, and diff --git a/docs/scripts.md b/docs/scripts.md index 451dbfe..214df5f 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -7,6 +7,10 @@ run on Linux. On Windows, use the app. ## Minimum typing on the headset +If setup or pairing fails, run `scripts/frame-doctor.sh` from this checkout. +It checks existing SSH access without requesting passwords, pairing or applying +repairs. See the [Frame doctor guide](frame-doctor.md) for results and next steps. + Valve's own developer docs say SSH, ADB, and RDP are all turned on through a **UI toggle**. You don't need a terminal, `passwd`, or `systemctl`. The only thing you type on the headset is a password you choose. diff --git a/docs/testing.md b/docs/testing.md index f6fdf47..1d8be27 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -44,6 +44,20 @@ them on macOS, Windows and Linux. They don't pick up `tests/e2e`. ## The fake Frame +Frame doctor has focused offline tests in `tests/test_frame_doctor.py` and +fake-Frame fault tests in `tests/e2e/test_doctor.py`: + +```sh +python3 -m unittest discover -s tests -p test_frame_doctor.py -v +scripts/e2e.sh test_doctor +``` + +They cover privacy filtering, host-key preservation, restart safety and missing +journal evidence, plus asleep, full-title-storage, SSH/devkit-off and missing-key +faults. The fake's doctor-only `systemctl show` responses assume stable restart +counters and inactive keep-awake; unsupported queries fail. These tests do not +exercise a real SteamOS user manager or touch a headset. + `tests/fakeframe/` builds a container that stands in for the headset, and a second one for the computer Frame Control runs on. `scripts/e2e.sh` builds both, starts them with `docker compose`, runs `tests/e2e` in the host @@ -164,6 +178,43 @@ does). A cleanup that fails counts as a failed step. Results go to screen; it exits 0 when every step passed, 1 if one failed, 2 if the headset isn't reachable. +Smoke process cleanup runs before and after title removal and during the +initial/final leftover sweeps. It targets only the reserved `fc_smoke_arm64`, +`fc_smoke_x86_64`, `fc_smoke_exe` IDs and their legacy `fc-smoke-` equivalents. +On the target, `tests/smoke/stop_processes.py` requires the SSH user's uid and +an exact title directory in `/proc` cwd/exe or an individual absolute command +argument; similarly named folders and shell command substrings do not count. +Deleted cwd/exe paths still count, so a process left after file removal can be +found. It sends SIGTERM, waits up to three seconds, then uses SIGKILL with +another three-second bound, rescanning for survivors. PID handles prevent PID +reuse from signalling another process. Linux Python 3.9+ with pidfd support +is required; missing support, signal errors and survivors fail the +cleanup step. Stopped PIDs are recorded in the results JSON. +If the initial leftover cleanup fails, the run skips installs and pairing and +records the failure instead of overwriting an active test title. + +This deliberately does not stop shared Wine/Proton services by name or prefix, +Steam, SteamVR, process groups or arbitrary descendants. A process that drops +all title-path evidence cannot safely be attributed and is outside this +check. Unreadable `/proc` fields provide no ownership evidence; other readable +fields are still checked, and a wholly unattributable process is left alone. +Real Frame verification, 2026-10-24, SteamOS build `20261015.6141207`: the +fixed Python smoke entry point passed all 13 steps under the shared device lock. +The `fc_smoke_exe` launch ran under Proton Experimental (ARM64), and its +remove step stopped five test-owned wrapper/Proton PIDs before removing the +title; the following process sweep found none. The final cleanup found no +test titles or processes, the test desktop was closed, and the battery +remained at 99%. The `scripts/frame-smoke.sh` zsh wrapper could not run +on lxso2 because zsh is absent; `python3 tests/smoke/frame_smoke.py` ran +the same entry point instead. +The local regression suite runs real Linux processes in temporary fake title +directories (including deleted folders and a TERM-resistant wrapper); it checks +exit, escalation and survival of unrelated processes without SSH or a headset: + +```sh +python3 -m unittest discover -s tests -p test_smoke_cleanup.py -v +``` + `--pair` asks the devkit service to pair a new RSA key, which needs someone in the headset to open **Settings → Developer → Pair new host** and approve it; the key is checked and then taken out of `authorized_keys` again. @@ -365,3 +416,11 @@ read-only metadata probe, guarded HTTP endpoint and Node renderer/navigation regressions. All headset reads are temporary fixtures or mocked SSH. See [game storage](game-storage.md#verification-and-limits) for coverage and device verification limits. This suite does not move games or contact a headset. + +## Battery monitoring + +`tests/test_battery.py` covers real file parsing with temporary sysfs-like +files, source preference, missing/invalid sensors and permission errors. +`tests/control_center.cjs` covers charging states and optional health fields, +alongside existing stale/offline handling. No headset is used. See +[battery monitoring](battery-health.md) for sources, units and limitations. diff --git a/scripts/frame-doctor.sh b/scripts/frame-doctor.sh new file mode 100755 index 0000000..11e543d --- /dev/null +++ b/scripts/frame-doctor.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Read-only setup and health diagnostics; see docs/frame-doctor.md. +set -euo pipefail +root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +exec python3 "$root/scripts/frame_doctor.py" "$@" diff --git a/scripts/frame_doctor.py b/scripts/frame_doctor.py new file mode 100644 index 0000000..21ca434 --- /dev/null +++ b/scripts/frame_doctor.py @@ -0,0 +1,279 @@ +"""Read-only doctor. Also sent over SSH on stdin; never installed on the Frame. + +Only fixed summaries leave the probe: no raw journals, addresses, account IDs, +process arguments, environment, key material or HTTP response bodies. +""" +import argparse +import json +import os +from pathlib import Path +import re +import shutil +import socket +import subprocess +import sys +import time + +SECTIONS = ('Boot and crash history', 'Wi-Fi', 'Displays and SteamVR', + 'Steam and storage', 'Idle sleep and keep-awake', 'Remote wake', + 'Lepton (Android)', 'Chromium XR', 'KDE Connect', + 'Streaming and capture', 'Panels') + + +def run(args, timeout=4, **kwargs): + try: + return subprocess.run(args, capture_output=True, text=True, timeout=timeout, **kwargs) + except (OSError, UnicodeError, subprocess.TimeoutExpired): + return None + + +def command(*args): + r = run(list(args)) + return r.stdout if r is not None and r.returncode == 0 else None + + +def journal(*args): + # Even exit 0 can mean the account cannot see the system journal. + r = run(['journalctl', '--no-pager', '-q', '-n', '2000', *args]) + if r is None or r.returncode or r.stderr.strip() or not r.stdout.strip(): + return None + return r.stdout + + +def snapshot(): + values = [] + for unit in ('steam', 'steamvr'): + s = command('systemctl', '--user', 'show', unit + '.service', + '-p', 'NRestarts', '--value') + if s is None or not s.strip().isdigit(): + return None + values.append(int(s.strip())) + p = Path('/run/user') / str(os.getuid()) / (unit + '-short-session-tracker') + try: + values.append(p.stat().st_size) + except FileNotFoundError: + values.append(0) + except OSError: + return None + return values + + +def guard(): + first = snapshot() + time.sleep(2) + second = snapshot() + if first is None or second is None: + return 'unknown' + if any(b > a for a, b in zip(first, second)): + return 'loop' + if first != second: + return 'unknown' # counters reset or tracker replaced during the sample + if second[1] or second[3]: + return 'risk' # avoid a long run even if a short sample missed the loop + return 'stable' + + +def signal_check(text, pattern, problem, healthy): + if text is None: + return ['UNKNOWN', 'Evidence unavailable; consult the runbook locally.'] + return ['WARN', problem] if re.search(pattern, text, re.I) else ['OK', healthy] + + +def storage(): + # devkit-game may be a separate mount (including in the fake Frame). + try: + paths = [Path.home()] + games = Path.home() / 'devkit-game' + if games.exists(): + paths.append(games) + free = min(shutil.disk_usage(p).free for p in paths) + if free < 16 * 1024**2: + return ['BROKEN', 'Less than 16 MiB free; review storage in Frame Control before installing.'] + if free < 256 * 1024**2: + return ['WARN', 'Less than 256 MiB free; review storage before large installs.'] + return ['OK', 'Home and title storage have at least 256 MiB free.'] + except OSError: + return ['UNKNOWN', 'Storage could not be read.'] + + +def probe(): + state = guard() + if state != 'stable': + return {'guard': state, 'checks': []} + kernel = journal('-b', '-k', '--since=-10min') + recent = journal('-b', '--since=-10min') + previous = journal('-b', '-1', '-k') + crashes = command('coredumpctl', 'list', '--no-pager', '--since=-1d') + checks = [] + boot = signal_check(previous, r'Unable to handle kernel|Internal error|Kernel panic', + 'Previous boot has a kernel oops; this alone does not prove an unclean reset.', + 'No kernel-oops signature in the sampled previous boot.') + if crashes is None and boot[0] != 'WARN': + boot = ['UNKNOWN', 'Crash history unavailable; inspect boot history locally.'] + elif crashes is not None and boot[0] != 'WARN' and re.search(r'SIG(SEGV|ABRT)|\b(SEGV|ABRT)\b', crashes): + boot = ['WARN', 'Crash records exist in the last day; inspect locally using the runbook.'] + checks.append(boot) + wifi = command('nmcli', '-t', '-f', 'DEVICE,TYPE,STATE', 'device') + checks.append(signal_check(wifi if wifi and 'wlan0:wifi:' in wifi else None, r'wlan0:wifi:(unavailable|disconnected|failed)', + 'Wi-Fi is unavailable or disconnected; check Steam Settings > Internet. Never reload ath12k.', + 'No unavailable/disconnected wlan0 reported; other interfaces may differ.')) + checks.append(signal_check(kernel, r'wait for video done', + 'Display timeouts observed; follow display recovery before touching Steam.', + 'No display-timeout signature in recent kernel sample.')) + if checks[-1][0] != 'WARN' and recent is not None and re.search(r'left-over process|steamvr.service: Failed|!modifiers.empty', recent): + checks[-1] = ['WARN', 'SteamVR/session failure signature observed; inspect locally before restarting anything.'] + disk = storage() + if disk[0] == 'OK' and not (Path.home() / '.local/share/Steam/.install-complete').exists(): + disk = ['WARN', 'Steam install marker is missing; do not restart Steam for diagnosis. Consult the runbook.'] + checks.append(disk) + awake = command('systemctl', '--user', 'show', 'fc-keep-awake.service', '-p', 'ActiveState', '--value') + checks.append(['UNKNOWN', 'Keep-awake state unavailable.'] if awake is None or not awake.strip() else + ['WARN', 'Keep-awake is active; leave it with its owner.'] if awake.strip() == 'active' else + ['OK', 'Keep-awake is inactive; diagnostics do not inhibit sleep.']) + wow = command('iw', 'phy', 'phy0', 'wowlan', 'show') + checks.append(['UNKNOWN', 'WoWLAN state unavailable; remote wake is not verified.'] if wow is None else + ['OK', 'WoWLAN is disabled; wake the headset with its power button.'] if 'WoWLAN is disabled' in wow else + ['WARN', 'WoWLAN may be armed; review the runbook. Remote wake is not reliable.']) + checks.append(signal_check(recent, r'Listen failed for HOST TCP|binder_user_error|Clearing baked app data', + 'Android failure signature observed; inspect Lepton locally before retrying.', + 'No known Android failure signature in recent journal sample.')) + checks.append(signal_check(crashes, r'chromium|/chrome\b', + 'Chromium crash history found; update/relaunch the panel through its normal UI.', + 'No Chromium crash signature in available crash history.')) + kde = run(['pgrep', '-f', 'frame-control/kdeconnect/root/usr/lib/kdeconnectd']) + checks.append(['UNKNOWN', 'KDE Connect process check unavailable.'] if kde is None or kde.returncode not in (0, 1) else + ['OK', 'KDE Connect process found.'] if kde.returncode == 0 else + ['WARN', 'KDE Connect is not running; normal if keyboard/trackpad sharing is off.']) + checks.append(signal_check(crashes, r'ffmpeg', + 'Capture crash history found; consult software-encoder guidance in the runbook.', + 'No ffmpeg crash signature in available crash history.')) + checks.append(['UNKNOWN', 'Panel placement needs visual confirmation; open one panel at a time.']) + # A short sample is not proof that Steam is healthy. Recheck after collecting. + after = guard() + return {'guard': after, 'checks': checks if after == 'stable' else []} + + +SSH_OPTIONS = ['BatchMode=yes', 'NumberOfPasswordPrompts=0', 'StrictHostKeyChecking=yes', + 'UpdateHostKeys=no', 'CheckHostIP=no', 'ConnectTimeout=3', 'ConnectionAttempts=1', + 'ControlMaster=no', 'ControlPath=none', 'ClearAllForwardings=yes', + 'PermitLocalCommand=no', 'RemoteCommand=none', 'RequestTTY=no', + 'ProxyCommand=none', 'ProxyJump=none', 'ForwardAgent=no', 'ForwardX11=no'] + + +def ssh_args(alias, host=None, identity=None): + args = ['ssh'] + for opt in SSH_OPTIONS: + args += ['-o', opt] + if host: + args += ['-o', 'HostName=' + host, '-o', 'HostKeyAlias=' + identity] + return args + [alias] + + +def failure(text): + # Never echo SSH stderr: it can contain addresses, paths or remote text. + if 'REMOTE HOST IDENTIFICATION HAS CHANGED' in text: + return 'SSH identity changed; use Devices > Check its key after verifying the headset. Do not delete known_hosts.' + if 'Host key verification failed' in text: + return 'SSH identity is untrusted; verify it through Set Up Connection or Devices > Check its key.' + if 'Permission denied' in text: + return "SSH key rejected; use Set Up Connection, then Developer > Pair new host in the headset." + return 'No SSH response; wake the headset, check the network/cable and enable Developer Mode.' + + +def port_open(host, port): + try: + with socket.create_connection((host, port), timeout=2): + return True + except OSError: + return False + + +def report(data): + state = data.get('guard') + if state != 'stable': + status = 'BROKEN' if state == 'loop' else 'WARN' if state == 'risk' else 'UNKNOWN' + message = {'loop': 'Counters are rising. Stop diagnostics; use the runbook health-check recovery procedure.', + 'risk': 'Health-check trackers contain failures. Deeper checks skipped; review the runbook before recovery.'} + print(status + ' Restart safety: ' + message.get(state, 'Cannot verify restart counters; deeper checks skipped for safety.')) + for i, label in enumerate(SECTIONS, 1): + print(f'UNKNOWN {i}. {label}: Skipped until restart safety is verified.') + return 1 + checks = data.get('checks') + # Treat the remote endpoint as untrusted. Only accept pairs from our own + # fixed vocabulary by matching against the production probe's messages. + if not isinstance(checks, list) or len(checks) != len(SECTIONS): + print('UNKNOWN Probe: Incomplete diagnostic response; no raw output displayed.') + return 1 + for check in checks: + if (not isinstance(check, list) or len(check) != 2 or check[0] not in ('OK', 'WARN', 'BROKEN', 'UNKNOWN') + or not isinstance(check[1], str) or check[1] not in MESSAGES): + print('UNKNOWN Probe: Unexpected diagnostic response; no raw output displayed.') + return 1 + print('OK Restart safety: No rising counters during short samples; this is not a health guarantee.') + for i, (label, (status, message)) in enumerate(zip(SECTIONS, checks), 1): + print(f'{status} {i}. {label}: {message}') + return int(any(c[0] != 'OK' for c in checks)) + + +def main(argv=None): + parser = argparse.ArgumentParser(description='Read-only Frame setup diagnostics. Never pairs, repairs or asks for a password.') + parser.add_argument('--alias', default='frame', help='Existing trusted SSH alias (default: frame)') + parser.add_argument('--lan', default='frame.local', help='LAN fallback host/IP') + parser.add_argument('--usb', default='10.86.200.233', help='USB fallback IP; use only with the intended headset connected') + args = parser.parse_args(argv) + for value in (args.alias, args.lan, args.usb): + if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._:-]{0,252}', value): + parser.error('Use a plain alias, hostname or IP address.') + print('Frame doctor: read-only; no passwords, pairing, repairs or raw logs. No report is saved.') + if not shutil.which('ssh'): + print('BROKEN Setup: Install an OpenSSH client, then run Set Up Connection.') + return 2 + config = run(ssh_args(args.alias)[:-1] + ['-G', args.alias]) + if config is None or config.returncode: + print('UNKNOWN Setup: SSH configuration could not be read; check it locally.') + return 2 + settings = dict(line.split(' ', 1) for line in config.stdout.splitlines() if ' ' in line) + identity = settings.get('hostkeyalias') or settings.get('hostname', args.alias) + source = Path(__file__).read_text() + for label, host in (('configured alias (Tailscale if configured)', None), ('LAN', args.lan), ('USB-C', args.usb)): + result = run(ssh_args(args.alias, host, identity) + ['python3 -B - --probe'], timeout=90, input=source) + if result is not None and result.returncode == 0: + try: + data = json.loads(result.stdout) + if not isinstance(data, dict): + raise ValueError() + except ValueError: + print('UNKNOWN Probe: Invalid response; check Python 3 availability on the headset. No raw output displayed.') + return 1 + print('OK Connection: ' + label + '; verified SSH identity and key login.') + return report(data) + if result is not None and result.returncode != 255: + print('UNKNOWN Probe: SSH connected but diagnostics failed; check Python 3 on the headset.') + return 1 + reason = failure(result.stderr if result is not None else '') + print('WARN Connection (' + label + '): ' + reason) + # No /register call: an open devkit port cannot prove pairing mode is enabled. + hosts = (settings.get('hostname', args.alias), args.lan, args.usb) + if any(port_open(host, 32000) for host in hosts): + print('WARN Pairing: Devkit port responds. Use Set Up Connection and Developer > Pair new host; no pairing request was sent.') + else: + print('UNKNOWN Pairing: Devkit port did not respond. Needs your hands: wake headset, enable Developer Mode and check its address.') + for i, label in enumerate(SECTIONS, 1): + print(f'UNKNOWN {i}. {label}: No authenticated SSH path.') + return 2 + + +# Derive the output allowlist from literal strings, not remote data. This module +# has no dynamic diagnostic messages. AST stays stdlib and runs only locally. +def messages(): + import ast + tree = ast.parse(Path(__file__).read_text()) + return {node.value for node in ast.walk(tree) if isinstance(node, ast.Constant) and isinstance(node.value, str)} + + +if __name__ == '__main__': + if sys.argv[1:] == ['--probe']: + print(json.dumps(probe())) + else: + MESSAGES = messages() + sys.exit(main()) diff --git a/tests/control_center.cjs b/tests/control_center.cjs index e7565d9..80fa3f2 100644 --- a/tests/control_center.cjs +++ b/tests/control_center.cjs @@ -165,6 +165,50 @@ test('metrics use real fields, preserve zero, and distinguish unavailable values assert.equal(cc.metrics({battery:{percent:0,status:'Discharging'}}).battery,'0%'); assert.match(cc.metrics({thermalAlerts:[{zone:'battery',tempC:45,limitC:44}]}).thermal,/battery 45 °C ≥ 44 °C/); }); +test('battery health distinguishes charging states and optional sensors', () => { + for (const status of [null, 'Unknown', 'Unexpected']) { + const m = cc.metrics({battery:{percent:100,status,timeToEmpty:3600}}); + assert.equal(m.batteryDetail,'Charging state unavailable'); + assert.equal(m.estimate,'Unavailable'); + } + assert.equal(cc.metrics({battery:{status:'Not charging'}}).batteryDetail,'Not charging'); + assert.equal(cc.metrics({battery:{status:'Full'}}).batteryDetail,'Fully charged'); + assert.equal(cc.metrics({battery:{status:'Charging',timeToFull:3600}}).estimate,'1 h 0 min to full'); + assert.equal(cc.metrics({battery:{status:'Discharging',timeToEmpty:600}}).estimate,'10 min remaining'); + const m = cc.metrics({battery:{cycleCount:0,fullMah:4500,designMah:5000,capacityHealthPercent:90, + source:'/sys/class/power_supply/max1720x_bat_7-36',health:'Unknown',tempC:0}}); + assert.equal(m.cycles,'0'); assert.equal(m.capacityHealth,'90%'); + assert.equal(m.fullCapacity,'4,500 mAh'); assert.equal(m.batteryTemp,'0 °C'); + assert.equal(m.batteryHealth,'Unavailable'); assert.match(m.batterySource,/max1720x/); + for (const key of ['cycles','fullCapacity','designCapacity','capacityHealth','batterySource','batteryHealth','batteryTemp']) + assert.equal(cc.metrics(null)[key],'Unavailable'); +}); +test('battery chip preserves unknown status and does not infer Full at 100%', () => { + const f=fixture(); + const start=pageSource.indexOf('function battery(b, power)'); + const end=pageSource.indexOf('// hidePopover', start); + const context={$:f.element,dur:String}; vm.createContext(context); + vm.runInContext(pageSource.slice(start,end),context); + context.battery({percent:100,status:'Unknown'},{watts:20}); + assert.equal(f.element('battState').textContent,'Charging state unavailable'); + context.battery({percent:50,status:'Not charging'},null); + assert.equal(f.element('battState').textContent,'Not charging'); + context.battery({percent:50,status:'Charging',watts:2,tempC:0},null); + assert.match(f.element('battState').textContent,/Charging/); + assert.match(f.element('battDetail').textContent,/battery 0°C/); + context.battery(null,null); + assert.equal(f.element('battState').textContent,'Battery unknown'); +}); +test('new health readings clear after a failed status read', () => { + const f=mounted(); + f.ui.status({...sample(),battery:{...sample().battery,cycleCount:42,capacityHealthPercent:92, + fullMah:4600,designMah:5000,source:'/sys/class/power_supply/max1720x_bat_7-36'}}); + assert.equal(f.element('metric-capacityHealth').textContent,'92%'); + f.ui.unavailable(Error('Read failed')); + for (const key of ['cycles','fullCapacity','designCapacity','capacityHealth','batterySource']) + assert.equal(f.element('metric-'+key).textContent,'Unavailable'); + f.ui.destroy(); +}); test('audio controller validates before any API call and rejects offline or expired readings', async () => { let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},calls=[]; const controller=cc.createController({api:async(...args)=>{calls.push(args);return {};},getContext:()=>context}); diff --git a/tests/e2e/test_doctor.py b/tests/e2e/test_doctor.py new file mode 100644 index 0000000..54011f2 --- /dev/null +++ b/tests/e2e/test_doctor.py @@ -0,0 +1,43 @@ +"""Real doctor/SSH against fake-Frame faults; never a real headset.""" +import subprocess +import sys + +import harness +from harness import ROOT, ctl + +harness.require() + + +class Doctor(harness.FrameTestCase): + def doctor(self): + return subprocess.run([sys.executable, str(ROOT / 'scripts/frame_doctor.py'), + '--lan', harness.FAKE_HOST, '--usb', harness.FAKE_HOST], + capture_output=True, text=True, timeout=120) + + def test_disk_full(self): + before = self.doctor() + self.assertNotIn('BROKEN 4. Steam and storage', before.stdout) + ctl('disk-full', 'on') + r = self.doctor() + self.assertEqual(r.returncode, 1, r.stdout + r.stderr) + self.assertIn('BROKEN 4. Steam and storage: Less than 16 MiB free', r.stdout) + + def test_unreachable_asleep(self): + ctl('sleep', 'on') + r = self.doctor() + self.assertEqual(r.returncode, 2, r.stdout + r.stderr) + self.assertIn('No authenticated SSH path', r.stdout) + + def test_no_ssh_and_no_devkit(self): + ctl('sshd', 'off') + ctl('devkit-service', 'off') + r = self.doctor() + self.assertEqual(r.returncode, 2, r.stdout + r.stderr) + self.assertIn('Devkit port did not respond', r.stdout) + + def test_no_keys_but_devkit_available(self): + ctl('keys', 'none') + r = self.doctor() + self.assertEqual(r.returncode, 2, r.stdout + r.stderr) + self.assertIn('SSH key rejected', r.stdout) + self.assertIn('no pairing request was sent', r.stdout) diff --git a/tests/fakeframe/rootfs/usr/local/bin/systemctl b/tests/fakeframe/rootfs/usr/local/bin/systemctl new file mode 100755 index 0000000..829d46e --- /dev/null +++ b/tests/fakeframe/rootfs/usr/local/bin/systemctl @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Doctor's read-only systemd queries in a container without a user manager. + +Stable NRestarts=0 and inactive keep-awake are harness assumptions, not observed +device behavior. Real Frame query forms come from the historical doctor runbook +(2026-09-28, BUILD_ID 20260925.6191901). No service control is emulated here. +""" +import sys + +args = sys.argv[1:] +if args in (['--user', 'show', 'steam.service', '-p', 'NRestarts', '--value'], + ['--user', 'show', 'steamvr.service', '-p', 'NRestarts', '--value']): + print('0') +elif args == ['--user', 'show', 'fc-keep-awake.service', '-p', 'ActiveState', '--value']: + print('inactive') +else: + sys.exit(1) diff --git a/tests/smoke/frame_smoke.py b/tests/smoke/frame_smoke.py index 740e048..c8abce6 100644 --- a/tests/smoke/frame_smoke.py +++ b/tests/smoke/frame_smoke.py @@ -13,11 +13,12 @@ BUILD_ID in tests/smoke/results/