mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 10:02:26 +02:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a904c95e4 | ||
|
|
b98e3e496f | ||
|
|
a148e02d39 | ||
|
|
0fb9174c23 | ||
|
|
c7489a88eb | ||
|
|
be74850a8e | ||
|
|
dc21428f85 | ||
|
|
fa43c83b06 |
No files matched your search
@@ -2,6 +2,30 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Treat headset aliases and other CSV metadata as plain text in power and heat
|
||||
exports, so spreadsheet formula prefixes cannot run when a report is opened.
|
||||
|
||||
- Add desktop Tools → Power & heat report: bounded read-only one/five-minute
|
||||
observations with progress, stop and HTML/JSON/CSV downloads. Received samples
|
||||
survive stop, disconnect and headset switch, labeled incomplete and tied to the
|
||||
original headset. Shared sampler/report modules ship with the app; no power or
|
||||
thermal controls are added. Download before replacing a report or quitting.
|
||||
|
||||
- Fix Return to Game Mode misidentifying full Plasma Desktop Mode when its VR
|
||||
overlay also runs gamescope (issue #142). Session targets now determine the
|
||||
mode; conflicting or incomplete evidence stays unknown. Use SteamOS's
|
||||
`steamosctl` switch when available, with older selector commands retained.
|
||||
|
||||
- Export read-only power and heat observations to an offline accessible HTML
|
||||
report with JSON/CSV downloads. `--html FILE` limits sessions to ten minutes;
|
||||
Ctrl+C saves received samples with an explicit incomplete state. Units,
|
||||
unknown counts and sources remain visible; no headset controls are added.
|
||||
|
||||
- Add a guarded `frame-doctor.sh --plan` with read-only, evidence-specific next
|
||||
steps and explicit local baseline reports for before/after diagnostics (issue
|
||||
#34). Restart risk blocks plans/comparisons; no automatic repairs, password
|
||||
changes or headset security changes are performed.
|
||||
|
||||
- Recover a tagged release upload when GitHub temporarily displays its draft
|
||||
as `untagged-…` after an edit, without creating a duplicate draft or
|
||||
silently retrying unrelated upload failures.
|
||||
|
||||
@@ -236,6 +236,7 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
|
||||
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
|
||||
| [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel |
|
||||
| [Power and heat measurements](docs/power-and-heat.md) | Read-only sampling and offline HTML reports with JSON/CSV downloads |
|
||||
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, the headset smoke test and a Windows test VM |
|
||||
| [Open questions](docs/open-questions.md) | What's still unchecked |
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ const path = require("path");
|
||||
|
||||
const REQUIRED = [
|
||||
"ui/server.py",
|
||||
"ui/frame_power.py",
|
||||
"ui/frame_status.py",
|
||||
"scripts/frame_power_sample.py",
|
||||
"scripts/frame_power_report.py",
|
||||
"ui/frame_android.py",
|
||||
"frame/android/lepton-app.sh",
|
||||
"frame/openxr-compat/XrApiLayer_FRAME_compat.json",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.13",
|
||||
"version": "0.4.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.13",
|
||||
"version": "0.4.15",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+4
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.13",
|
||||
"version": "0.4.15",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -68,7 +68,9 @@
|
||||
"from": "../scripts",
|
||||
"to": "scripts",
|
||||
"filter": [
|
||||
"*.sh"
|
||||
"*.sh",
|
||||
"frame_power_sample.py",
|
||||
"frame_power_report.py"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+44
-31
@@ -12,15 +12,21 @@ with no sign-in files or keys in the archives. Restoring one game's saves on top
|
||||
of identical files wrote nothing and left nothing behind. The session check
|
||||
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.
|
||||
Android data restore. Unit tests and the fake Frame cover those (see
|
||||
[Verification](#verification)).
|
||||
On 2026-10-24, the real-device reproduction in [issue #142](https://github.com/saphid/frame-control/issues/142)
|
||||
confirmed full Plasma Desktop Mode on the same build: the Plasma session target
|
||||
was active and the Game Mode target inactive. Desktop Mode also ran gamescope
|
||||
for `steamvr-plasma.service`, so the old process check incorrectly reported
|
||||
Game Mode. SteamOS's `steamosctl switch-to-game-mode` restored Game Mode and
|
||||
reversed the target states; battery stayed at 99%, and the rollback timer and
|
||||
shared lock were cleaned up. After #143 passed CI, the corrected code was
|
||||
tested in another full Plasma session under a three-minute rollback timer.
|
||||
`ui/frame_backup.py session` reported Desktop Mode despite gamescope, and
|
||||
`ui/frame_backup.py game-mode` invoked the same guarded Return function used by
|
||||
the button. Game Mode became active and a second call made no switch. Battery
|
||||
remained at 99%; the timer and lock were removed. The literal desktop button
|
||||
was not clicked in this test, and older SteamOS builds remain fixture-tested.
|
||||
|
||||
## Back up
|
||||
|
||||
@@ -118,29 +124,28 @@ normally runs *inside* it as one panel (`steamos-nested-desktop`, see
|
||||
[how-the-frame-works.md](how-the-frame-works.md#the-layer-cake)). If that panel
|
||||
is what you see, you're already in Game Mode: close it from the Steam dashboard.
|
||||
|
||||
Full Desktop Mode is a different session. SteamOS picks the session through
|
||||
SDDM's autologin file, `/etc/sddm.conf.d/zz-steamos-autologin.conf` (or the
|
||||
default `/etc/sddm.conf.d/steamos.conf`), and switches with
|
||||
`steamos-session-select` (`holo-session-select` on newer images). This is what
|
||||
Valve's own devkit tools on the Frame read and run
|
||||
([`steamos-get-status`](../frame/devkit-utils/steamos-get-status),
|
||||
[`steamos-delete`](../frame/devkit-utils/steamos-delete)).
|
||||
Full Desktop Mode is a different session. **Check** reads the main user's
|
||||
systemd session targets over SSH: `gamescope-session.target` and
|
||||
`plasma-workspace-wayland.target`. Game Mode requires the former active and the
|
||||
latter inactive; Desktop Mode requires the reverse. This takes precedence over
|
||||
process names: full Desktop Mode can run gamescope for its VR overlay, and
|
||||
Game Mode can run a nested Plasma panel. Check changes nothing.
|
||||
|
||||
**Check** reads that file and whether gamescope and Plasma are running; it
|
||||
changes nothing. On the Frame gamescope's process is `gamescope-wl`, started by
|
||||
`start-gamescope-session`, and either counts as Game Mode. Only when Plasma is
|
||||
running without gamescope (Desktop Mode) does **Return to Game Mode** run
|
||||
exactly `steamos-session-select gamescope` (or `holo-session-select
|
||||
gamescope`) over SSH, after asking. It closes the desktop and anything open in
|
||||
it. Frame Control never edits the SDDM files itself, and runs nothing when the
|
||||
headset is in Game Mode, when it can't tell which mode it's in, or when the
|
||||
image has neither command.
|
||||
Conflicting, transitional, failed or unreadable targets produce **unknown**.
|
||||
On older images where both targets are explicitly absent, the fallback accepts
|
||||
only gamescope without Plasma/nested desktop as Game Mode, or Plasma without
|
||||
gamescope/nested desktop as Desktop Mode. Mixed processes remain unknown.
|
||||
The SDDM autologin file is still reported as a login preference; it cannot
|
||||
establish which session is running.
|
||||
|
||||
**Uncertain:** whether the Frame offers a full Desktop Mode session at all
|
||||
(only the nested desktop has been seen on BUILD_ID 20260922.6101926 and
|
||||
20260925.6191901), and whether `steamos-session-select` there works without
|
||||
the Developer Mode password. If the switch is refused, Frame Control says so and
|
||||
leaves the headset as it was.
|
||||
After confirmation, **Return to Game Mode** runs exactly
|
||||
`steamosctl switch-to-game-mode` when available, or the older
|
||||
`holo-session-select gamescope` / `steamos-session-select gamescope` command.
|
||||
It closes the desktop and anything open in it. Frame Control never edits the
|
||||
SDDM files itself, and runs no switch when the headset is in Game Mode, when
|
||||
its mode is unknown, or when no supported switch command is installed. A
|
||||
refused command reports an error; a request being sent does not verify that
|
||||
the new session has started.
|
||||
|
||||
### Manual fallback
|
||||
|
||||
@@ -151,7 +156,9 @@ If the button can't help, or the headset isn't reachable:
|
||||
Desktop Mode session, so a restart brings Game Mode back. That the Frame
|
||||
behaves the same is **inferred**, not tested.
|
||||
2. **Over SSH** (Developer Mode on), run Valve's switch yourself:
|
||||
`ssh frame steamos-session-select gamescope`.
|
||||
`ssh frame steamosctl switch-to-game-mode` (verified on SteamOS 0.5.6).
|
||||
Older images may instead provide `steamos-session-select gamescope` or
|
||||
`holo-session-select gamescope`.
|
||||
3. **Boot menu:** hold Power 10 seconds, then power on while holding the AUX
|
||||
button on top of Power. Choose `Current`, or `Previous` to start the other
|
||||
SteamOS slot; both keep your data. Details and the further options
|
||||
@@ -172,3 +179,9 @@ restore replaces only its own files and keeps the old ones, unsafe archives
|
||||
names that try to leave the backups folder, and the exact Game Mode command.
|
||||
`tests/e2e/test_recovery_kit.py` backs up a Proton save from the fake Frame through
|
||||
the server, deletes it and restores it. Neither involves a real headset.
|
||||
|
||||
Isolated session tests in the same suite reproduce full Plasma plus gamescope,
|
||||
Game Mode with and without the nested desktop, conflicting/transitional targets,
|
||||
missing targets on older builds, failed/timed-out systemd reads, selector
|
||||
compatibility, and the confirmed Return action with SSH mocked. Run them with
|
||||
`python3 -m unittest discover -s tests -p test_backup.py -v`.
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"date": "2026-10-24",
|
||||
"base": "c7489a8 (v0.4.14)",
|
||||
"feature": "Desktop Tools power and heat observations",
|
||||
"automated": {
|
||||
"targeted": "python3 -m unittest discover -s tests -p 'test_power*.py': 35 passed",
|
||||
"uiAndResourceGuard": "node --test tests/power_desktop.cjs: 7 passed",
|
||||
"desktopApp": "node --test app/test/*.test.js: 37 passed",
|
||||
"packaging": "Materialized ui/scripts resources selected by app/package.json; isolated Python -I imported frame_power, constructed remote payload including frame_status, and read idle status. Missing resource detection tested. Shared phone bundle import passed.",
|
||||
"syntax": "Python compilation, sampler shell syntax and git diff --check passed",
|
||||
"fullSuite": "python3 -m unittest discover -s tests -v: 1363 tests, OK (skipped=15), 153.274 seconds",
|
||||
"transientFailure": "An earlier full run had one nested sandbox-test failure: the existing OneServer second-server child exited -11 (SIGSEGV), expected 1. Isolated sandbox rerun passed (6 tests), then the complete suite passed. No unrelated server-lifecycle code was changed."
|
||||
},
|
||||
"browser": {
|
||||
"environment": "T3 collaborative Chromium preview, Linux, local Python Handler server; synthetic observation subprocess at accelerated one-second intervals; normal headset SSH disabled during start/stop/download verification",
|
||||
"observations": [
|
||||
"Opened #powerReport and verified labeled duration selector, disabled Stop/downloads before start and accessible sample progress.",
|
||||
"Started a synthetic observation and observed 11 of 12 samples in progress; completion allowed another start.",
|
||||
"Started again, observed progress, stopped, and saw 7 of 12 samples retained with explicit incomplete state and original frame alias.",
|
||||
"Clicked HTML download and saw the UI acknowledge the request; T3's download list did not record the blob download, so filesystem save was not verified.",
|
||||
"Fetched all three authenticated export endpoints in the browser: HTML 200 (7599 characters), JSON 200 (2618 characters, 7 samples), CSV 200 (977 characters); each retained the stop reason."
|
||||
]
|
||||
},
|
||||
"realFrame": {
|
||||
"lock": "ssh frame 'mkdir /tmp/frame-test.lock' succeeded before real-device probe; this task removed only that owned empty lock with rmdir after verification",
|
||||
"probe": "Ran the production desktop Observation using strict, batch SSH to frame. The first attempt exposed a missing structured reason on early remote lock refusal; fixed and reran successfully.",
|
||||
"result": "0 of 12 samples; running=false; stopped='Smoke session active (/tmp/frame-test.lock); no sensors read'. Exported HTML included the same incomplete reason.",
|
||||
"scope": "Lock refusal only. No successful real sensor session, installation, app launch/stop, power control or thermal control was performed for this feature."
|
||||
},
|
||||
"limitations": [
|
||||
"Native Electron installers were not built or launched; resource staging and build-hook contracts were tested.",
|
||||
"One latest report is retained only in server memory. Starting again, restarting the server or quitting discards it; the UI warns users to download first.",
|
||||
"Stopping ends only the owned local SSH process. The bounded remote reader discovers closure on its next write; immediate remote cancellation is not guaranteed.",
|
||||
"Phone-hosted observation starts are unsupported; the shared server bundle still imports without desktop sampler resources."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"date": "2026-10-24",
|
||||
"scope": "Read-only three-sample HTML export and browser download check; no installs, app launches, or remote process-control commands",
|
||||
"preflight": {
|
||||
"smokeLock": "absent",
|
||||
"version": "0.5.6",
|
||||
"build": "20261015.6141207"
|
||||
},
|
||||
"command": "scripts/frame-power-sample.sh --count 3 --interval 1 --html /tmp/frame-power-export-check.html",
|
||||
"exitCode": 0,
|
||||
"sampleCount": 3,
|
||||
"metricCount": 91,
|
||||
"unavailableGroups": [
|
||||
"proximity"
|
||||
],
|
||||
"browser": {
|
||||
"engine": "T3 collaborative Chromium preview",
|
||||
"widths": [
|
||||
1280,
|
||||
390
|
||||
],
|
||||
"jsonDownloadMatches": true,
|
||||
"csvDownloadMatches": true,
|
||||
"csvDataRows": 276,
|
||||
"pageScripts": 0,
|
||||
"horizontalOverflow": "confined to focusable table region"
|
||||
},
|
||||
"limits": [
|
||||
"No real-device Ctrl+C cancellation test; local child termination and retained samples verified with subprocess fixtures",
|
||||
"No screen-reader, Windows or macOS verification",
|
||||
"No controlled baseline, load or worn-headset testing",
|
||||
"Sensor accuracy not established"
|
||||
]
|
||||
}
|
||||
+61
-5
@@ -58,6 +58,44 @@ 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.
|
||||
|
||||
## Read-only repair plan and before/after reports
|
||||
|
||||
```sh
|
||||
scripts/frame-doctor.sh --plan --save-report before.json
|
||||
# Review the plan with the headset user. Any manual action is separate.
|
||||
scripts/frame-doctor.sh --plan --compare-before before.json --save-report after.json
|
||||
```
|
||||
|
||||
`--plan` prints advice; it never executes repair commands or prompts for approval.
|
||||
There is no automatic repair mode. Advice about Wi-Fi, display timeouts, low
|
||||
storage, a missing Steam install marker or keep-awake requires the exact
|
||||
recognized section, severity and evidence. Other warnings request local review;
|
||||
unknown evidence never justifies a repair. A signature or missing marker alone
|
||||
does not establish a cause. Restart risk, rising counters or unknown counters
|
||||
block all section-specific plans and direct you to the recovery runbook.
|
||||
Nothing changes passwords, headset security, pairing, services or power settings.
|
||||
|
||||
Reports are optional **local** JSON files containing only fixed diagnostic
|
||||
summaries, a format version and an opaque SSH-target configuration fingerprint.
|
||||
The fingerprint binds comparisons to the same alias, identity/routing
|
||||
configuration and fallback addresses; it does not prove physical headset
|
||||
identity or that the headset was not replaced behind that configuration. Keep
|
||||
using trusted SSH host keys. Report creation is exclusive (existing files and
|
||||
symlinks are refused), with owner-only permissions on POSIX. Protect reports
|
||||
using your local account permissions on Windows. Choose a fresh output filename
|
||||
for each run. Doctor never writes a report on the headset.
|
||||
|
||||
`--compare-before` validates a bounded baseline before connecting and refuses
|
||||
changed target configuration. It prints each section's before/after status and
|
||||
prints both fixed evidence messages when they differ, even if the status
|
||||
stayed the same. Changes are observations, not proof a repair worked or caused them;
|
||||
recent journal windows can simply age out. There is no intervening repair by
|
||||
doctor, freshness guarantee, or authenticated report signature. If either run
|
||||
skipped deeper checks, comparison is blocked and no improvement is claimed.
|
||||
Unreachable or invalid probes produce no new baseline or comparison. Malformed
|
||||
baseline/target mismatch/report-write errors exit **2**; otherwise normal
|
||||
diagnostic exit codes apply. No saved report is needed for ordinary diagnostics.
|
||||
|
||||
## First-time setup and pairing
|
||||
|
||||
- **No SSH response:** wake the headset with its power button. Check the cable
|
||||
@@ -89,15 +127,16 @@ 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.
|
||||
The optional plan and baseline comparison are a guarded next step for issue
|
||||
#34; automatic fixes and full recovery automation remain unimplemented. 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
|
||||
a remote report. Only `--save-report` creates a local file. Nothing needs
|
||||
restoring on the headset after its checks. Manual/device tests must
|
||||
follow the [shared-device lock and battery procedure](testing.md#headset-smoke-test).
|
||||
|
||||
## Privacy and verification
|
||||
@@ -107,7 +146,9 @@ 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.
|
||||
personal information. Redirecting stdout or using `--save-report` saves a report
|
||||
only if you choose it.
|
||||
Treat the target fingerprint as private metadata; it is a hash, not encryption.
|
||||
|
||||
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
|
||||
@@ -119,3 +160,18 @@ 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.
|
||||
|
||||
Follow-up verification on 2026-10-24: `--plan --save-report` and then
|
||||
`--plan --compare-before` ran read-only on the same real Frame. Both reported
|
||||
nonempty health-check trackers, skipped deeper checks and exited 1. The plan
|
||||
was blocked and the comparison reported `risk -> risk` without claiming
|
||||
improvement. No install, launch, stop or repair was performed; this validates
|
||||
the guarded report path, not the deeper checks or any manual repair.
|
||||
|
||||
The follow-up's 17 focused offline tests passed. All five doctor fake-Frame
|
||||
tests passed, including a disk-full baseline followed by fault removal and a
|
||||
fresh comparison. The final evidence-output change also passed that comparison
|
||||
test again. Because zsh is unavailable on this host, the repository's images
|
||||
were built and its Compose harness run directly under the isolated project
|
||||
`frame-doctor-plan-34`; its containers and volumes were removed afterwards.
|
||||
These storage changes were fake fault controls, not doctor repairs.
|
||||
+104
-2
@@ -24,6 +24,89 @@ The first sample has no CPU utilization: two valid `/proc/stat` observations
|
||||
are required. CPU utilization then covers the interval between observations,
|
||||
averaged across the machine, rather than the utilization of one process.
|
||||
|
||||
## Start, stop and open an accessible report
|
||||
|
||||
From a source checkout with Python 3 and your already trusted `ssh frame`
|
||||
connection, start a local observation:
|
||||
|
||||
```sh
|
||||
scripts/frame-power-sample.sh --count 12 --interval 5 --html power-report.html
|
||||
```
|
||||
|
||||
Progress is printed on your computer as each sample arrives. Wait for completion,
|
||||
or press **Ctrl+C once** to stop local collection and save the samples already
|
||||
received. Open `power-report.html` in your browser using its Open File command.
|
||||
The file is self-contained and works offline: no server, scripts, external
|
||||
resources, account or upload. It includes a table with named metrics, source
|
||||
paths, units, minimum/mean/maximum/last values, and available/unknown counts.
|
||||
Keyboard users can focus the table region to scroll through its columns.
|
||||
Charging states and entirely unavailable sensor families remain explicit.
|
||||
Use **Download JSON samples and summary** or **Download CSV samples** to save
|
||||
raw measurements. The HTML also embeds those measurements; review it before
|
||||
sharing. No automatic browser launch occurs.
|
||||
|
||||
HTML sessions are limited to `count × interval <= 600` seconds, with a 30-second
|
||||
transport allowance and at most twelve seconds of local child/pipe cleanup. The default
|
||||
12 samples at five-second intervals take about 55 seconds. Existing output files
|
||||
are never overwritten; choose a new filename for each run. `--html` cannot be
|
||||
combined with `--csv`. The desktop flow below uses the same sampler and renderer.
|
||||
|
||||
Stop, timeout, SSH failure or a smoke-test lock marks the report incomplete;
|
||||
earlier received samples remain downloadable. Exit status is 75 for incomplete
|
||||
reports, 0 for completion. A connection that cannot start may instead return 1
|
||||
without an HTML file. Empty reports clearly show zero samples, not a successful
|
||||
measurement. If saving the file fails, received JSON is printed on stdout as a
|
||||
recovery path. Do not treat a partial report as a full baseline.
|
||||
|
||||
Stopping terminates only this command's local SSH child. It sends no remote
|
||||
process-control command. The bounded remote Python reader exits when it next
|
||||
writes to the closed connection (normally at the next sample, up to one interval
|
||||
later); it can still read sensors before discovering the disconnect. Stop is
|
||||
not an immediate headset-side cancellation guarantee. The reader still checks
|
||||
the smoke-test lock before every sample. No lock is acquired or removed by this
|
||||
flow, and no power, thermal, service, password or security controls are exposed.
|
||||
|
||||
## Desktop Tools → Power & heat report
|
||||
|
||||
In the packaged desktop app, connect to an already paired Frame, open **Tools →
|
||||
Power & heat report**, choose **1 minute** (12 samples) or **5 minutes** (60 samples),
|
||||
and select **Start observation**. Samples arrive every five seconds, starting at
|
||||
zero (about 55 or 295 seconds total). Progress shows received samples and the
|
||||
original headset alias. There is at most one observation per local app server;
|
||||
viewing progress never starts another SSH connection. No terminal, checkout,
|
||||
extra dependency or password is needed.
|
||||
|
||||
Use **Stop and keep samples** to end local collection. Disconnect, timeout,
|
||||
app shutdown or switching to another headset also ends collection. Switching
|
||||
network addresses for the same headset does not retarget an existing observation:
|
||||
it keeps its captured SSH route and may finish incomplete if that route fails.
|
||||
As with the CLI, only the owned local SSH child is terminated. The remote reader
|
||||
is bounded and discovers a closed connection on its next write. Desktop sessions
|
||||
have a 30-second transport allowance and bounded child cleanup. Stream lines are
|
||||
limited to 262,144 characters, sample events to the requested count, and the receive queue
|
||||
to count + 2 events; desktop SSH stderr is discarded rather than stored unbounded.
|
||||
The existing smoke lock is checked locally before start and remotely before each
|
||||
sample; this feature never creates, bypasses or removes that lock.
|
||||
|
||||
**Download HTML report**, **Download JSON** and **Download CSV** save local
|
||||
snapshots through the browser/app download mechanism. Open the downloaded HTML
|
||||
in your browser to view the offline table and its embedded JSON/CSV downloads.
|
||||
Snapshots taken before completion explicitly say incomplete. Desktop CSV includes
|
||||
`collection.state`, `collection.requestedSamples` and `collection.receivedSamples`
|
||||
metadata rows with the original headset alias in the source column, even when no
|
||||
sensor sample arrived. CSV text fields, including the alias, are escaped if they
|
||||
begin with a spreadsheet formula prefix. JSON records the alias and device ID;
|
||||
HTML names the alias.
|
||||
|
||||
Only the latest observation is retained in server memory, including partial
|
||||
samples after stop or disconnect. **Download before starting another observation,
|
||||
restarting the server or quitting the app**: those discard the previous report.
|
||||
Page reloads retain server state. Download requests include an observation ID, so
|
||||
an old page cannot silently download or stop a replacement observation. Reports
|
||||
are not uploaded or automatically opened, and may contain device identifiers,
|
||||
OS builds and sensor paths. Review before sharing. The phone-hosted UI does not
|
||||
support starting observations.
|
||||
|
||||
## What it reads
|
||||
|
||||
- Battery percentage, driver charging state, signed battery watts and battery
|
||||
@@ -109,14 +192,33 @@ Quiet mode, compositor changes, CPU/fan/charging changes and remote-wake tests
|
||||
are separate work. No controls are offered here. Follow the shared-device
|
||||
procedure in [testing.md](testing.md#headset-smoke-test) for future experiments.
|
||||
|
||||
The accessible HTML export was checked on the same build on **2026-10-24**:
|
||||
three streamed samples, 91 metric summaries and 276 CSV data rows. Chromium
|
||||
preview checks at 1280 px and 390 px confirmed the table stays in its scroll
|
||||
region; both browser downloads matched the original samples. See
|
||||
[export evidence](evidence/power-export-2026-10-24.json). Ctrl+C retention and
|
||||
local process cleanup were tested with local subprocess fixtures, not on the
|
||||
real headset. Screen-reader, Windows and macOS checks were not performed.
|
||||
|
||||
Offline validation:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_power_sample.py -v
|
||||
python3 -m unittest discover -s tests -p 'test_power*.py' -v
|
||||
bash -n scripts/frame-power-sample.sh
|
||||
python3 -m py_compile scripts/frame_power_sample.py
|
||||
python3 -m py_compile scripts/frame_power_sample.py scripts/frame_power_report.py
|
||||
```
|
||||
|
||||
Fixtures cover units, signed and zero values, missing/malformed/permission-denied
|
||||
readings, named/discovered channels, unknown charging state, CPU warmup,
|
||||
summary counts, CSV quoting, strict SSH transport and smoke-lock interruption.
|
||||
|
||||
Export fixtures also cover HTML escaping, download round trips, zero versus unknown,
|
||||
stream completion/failure, smoke-lock stop, timeout, Ctrl+C retention, local child
|
||||
cleanup, duration bounds and refusing to overwrite reports.
|
||||
|
||||
Desktop validation on **2026-10-24** covered API start/stop, a synthetic browser
|
||||
observation with partial exports, shared-module resource staging under isolated
|
||||
Python and missing-resource build guards. A real Frame probe under this task's
|
||||
owned smoke lock correctly returned zero samples with an explicit incomplete
|
||||
reason; no successful live sensor baseline or native installer run was performed.
|
||||
See [desktop evidence and limitations](evidence/power-desktop-2026-10-24.json).
|
||||
+5
-2
@@ -60,8 +60,11 @@ 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
|
||||
faults. The plan/report tests cover exact-evidence advice, blocked plans,
|
||||
private exclusive baselines, malformed input, target mismatches and comparisons
|
||||
without repair claims. The fake-Frame storage test records a full-disk baseline,
|
||||
clears only the fake fault and compares a fresh run. 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Read-only setup and health diagnostics; see docs/frame-doctor.md.
|
||||
# Read-only diagnostics, repair plans and explicit local before/after reports; see docs/frame-doctor.md.
|
||||
set -euo pipefail
|
||||
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
exec python3 "$root/scripts/frame_doctor.py" "$@"
|
||||
+124
-5
@@ -4,6 +4,7 @@ Only fixed summaries leave the probe: no raw journals, addresses, account IDs,
|
||||
process arguments, environment, key material or HTTP response bodies.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -215,16 +216,100 @@ def report(data):
|
||||
return int(any(c[0] != 'OK' for c in checks))
|
||||
|
||||
|
||||
def valid_data(data):
|
||||
"""Validate before saving, comparing or deriving any recommendation."""
|
||||
if (not isinstance(data, dict) or set(data) != {'guard', 'checks'}
|
||||
or data.get('guard') not in ('stable', 'loop', 'risk', 'unknown')):
|
||||
return False
|
||||
checks = data.get('checks')
|
||||
if data['guard'] != 'stable':
|
||||
return checks == []
|
||||
return (isinstance(checks, list) and len(checks) == len(SECTIONS)
|
||||
and all(isinstance(c, list) and len(c) == 2
|
||||
and c[0] in ('OK', 'WARN', 'BROKEN', 'UNKNOWN')
|
||||
and isinstance(c[1], str) and c[1] in MESSAGES for c in checks))
|
||||
|
||||
|
||||
def repair_plan(data):
|
||||
print('Repair plan (READ-ONLY): no commands executed; no automatic repair is available.')
|
||||
if data['guard'] != 'stable':
|
||||
print('BLOCKED: Restart safety is unverified or unsafe. Needs your hands: review the runbook health-check recovery with the headset user; do not restart services.')
|
||||
return
|
||||
# Each action needs the exact section, severity AND recognized evidence.
|
||||
actions = {
|
||||
(1, 'WARN', 'Wi-Fi is unavailable or disconnected; check Steam Settings > Internet. Never reload ath12k.'):
|
||||
'Needs your hands: check Steam Settings > Internet. Do not reload ath12k.',
|
||||
(2, 'WARN', 'Display timeouts observed; follow display recovery before touching Steam.'):
|
||||
'Needs your hands: inspect the display in-headset and review display recovery before any Steam action.',
|
||||
(3, 'BROKEN', 'Less than 16 MiB free; review storage in Frame Control before installing.'):
|
||||
'Needs your hands: review Game storage and use Steam storage management after choosing what to keep; no files are deleted by doctor.',
|
||||
(3, 'WARN', 'Less than 256 MiB free; review storage before large installs.'):
|
||||
'Needs your hands: review Game storage before installing; choose any storage changes yourself.',
|
||||
(3, 'WARN', 'Steam install marker is missing; do not restart Steam for diagnosis. Consult the runbook.'):
|
||||
'Needs your hands: inspect Steam installation/extraction locally with the runbook. A missing marker does not authorize repair or restart.',
|
||||
(4, 'WARN', 'Keep-awake is active; leave it with its owner.'):
|
||||
'Leave keep-awake with its owner; do not take ownership or change idle settings.',
|
||||
}
|
||||
for i, (status, message) in enumerate(data['checks']):
|
||||
if status == 'OK':
|
||||
continue
|
||||
action = actions.get((i, status, message))
|
||||
if action is None:
|
||||
action = ('Collect missing evidence locally; no repair is justified.' if status == 'UNKNOWN'
|
||||
else 'Review this signal locally with the runbook; it does not establish a safe automatic fix.')
|
||||
print(f'PLAN {i + 1}. {SECTIONS[i]}: {action}')
|
||||
|
||||
|
||||
def compare(before, after):
|
||||
print('Before/after diagnostics: observations only; changes do not prove repair or causation.')
|
||||
print('Restart safety: ' + before['guard'] + ' -> ' + after['guard'])
|
||||
if before['guard'] != 'stable' or after['guard'] != 'stable':
|
||||
print('Comparison blocked: deeper checks were skipped in at least one sample; no improvement claimed.')
|
||||
return
|
||||
for i, label in enumerate(SECTIONS):
|
||||
a, b = before['checks'][i], after['checks'][i]
|
||||
detail = 'unchanged' if a == b else 'evidence changed'
|
||||
print(f'{i + 1}. {label}: {a[0]} -> {b[0]} ({detail})')
|
||||
if a != b:
|
||||
print(' Before evidence: ' + a[1])
|
||||
print(' After evidence: ' + b[1])
|
||||
|
||||
|
||||
def load_baseline(path):
|
||||
# Local reports are untrusted too; bound input and never echo file contents.
|
||||
with open(path, 'rb') as stream:
|
||||
raw = stream.read(65537)
|
||||
if len(raw) > 65536:
|
||||
raise ValueError()
|
||||
saved = json.loads(raw)
|
||||
if (not isinstance(saved, dict) or saved.get('version') != 1
|
||||
or not isinstance(saved.get('target'), str)
|
||||
or not re.fullmatch('[a-f0-9]{64}', saved['target'])
|
||||
or not valid_data(saved.get('diagnostics'))):
|
||||
raise ValueError()
|
||||
return saved
|
||||
|
||||
|
||||
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')
|
||||
parser.add_argument('--plan', action='store_true', help='Print a read-only repair plan from recognized evidence')
|
||||
parser.add_argument('--save-report', metavar='FILE', help='Create a local JSON baseline (never overwrite an existing file)')
|
||||
parser.add_argument('--compare-before', metavar='FILE', help='Compare a saved baseline with this fresh read-only run')
|
||||
args = parser.parse_args(argv)
|
||||
baseline = None
|
||||
if args.compare_before:
|
||||
try:
|
||||
baseline = load_baseline(args.compare_before)
|
||||
except (OSError, ValueError, TypeError, UnicodeError, RecursionError):
|
||||
print('UNKNOWN Baseline: Cannot read a valid doctor report; no comparison performed.')
|
||||
return 2
|
||||
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.')
|
||||
print('Frame doctor: read-only; no passwords, pairing, repairs or raw logs. Reports are saved only with --save-report.')
|
||||
if not shutil.which('ssh'):
|
||||
print('BROKEN Setup: Install an OpenSSH client, then run Set Up Connection.')
|
||||
return 2
|
||||
@@ -234,19 +319,42 @@ def main(argv=None):
|
||||
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)
|
||||
# Bind reports to the configured logical SSH target, across LAN/USB fallback.
|
||||
# This is configuration continuity, not proof of physical headset identity.
|
||||
target = hashlib.sha256(json.dumps([args.alias, identity, settings.get('hostname'),
|
||||
settings.get('user'), settings.get('port'), args.lan, args.usb]).encode()).hexdigest()
|
||||
if baseline is not None and baseline['target'] != target:
|
||||
print('UNKNOWN Baseline: SSH target configuration differs; comparison refused.')
|
||||
return 2
|
||||
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):
|
||||
if not valid_data(data):
|
||||
raise ValueError()
|
||||
except ValueError:
|
||||
except (ValueError, RecursionError):
|
||||
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)
|
||||
code = report(data)
|
||||
if args.plan:
|
||||
repair_plan(data)
|
||||
if baseline is not None:
|
||||
compare(baseline['diagnostics'], data)
|
||||
if args.save_report:
|
||||
try:
|
||||
# Exclusive create prevents clobbering a baseline or following a symlink.
|
||||
fd = os.open(args.save_report, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, 'w') as stream:
|
||||
json.dump({'version': 1, 'target': target, 'diagnostics': data}, stream)
|
||||
stream.write('\n')
|
||||
print('OK Report: Local baseline created; review it before sharing.')
|
||||
except OSError:
|
||||
print('UNKNOWN Report: Could not create local baseline; existing files are never overwritten.')
|
||||
return 2
|
||||
return code
|
||||
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
|
||||
@@ -268,7 +376,18 @@ def main(argv=None):
|
||||
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)}
|
||||
allowed = set()
|
||||
for function in tree.body:
|
||||
if not isinstance(function, ast.FunctionDef) or function.name not in ('probe', 'storage', 'signal_check'):
|
||||
continue
|
||||
for node in ast.walk(function):
|
||||
if (isinstance(node, ast.List) and len(node.elts) == 2
|
||||
and all(isinstance(e, ast.Constant) and isinstance(e.value, str) for e in node.elts)
|
||||
and node.elts[0].value in ('OK', 'WARN', 'BROKEN', 'UNKNOWN')):
|
||||
allowed.add(node.elts[1].value)
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == 'signal_check':
|
||||
allowed.update(arg.value for arg in node.args[2:4] if isinstance(arg, ast.Constant))
|
||||
return allowed
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Offline, accessible power report. No network requests or external resources."""
|
||||
import base64
|
||||
import html
|
||||
import json
|
||||
|
||||
|
||||
def render(report, csv_text):
|
||||
def esc(value):
|
||||
return html.escape(str(value), quote=True)
|
||||
|
||||
def download(text, mime, filename, label):
|
||||
data = base64.b64encode(text.encode('utf-8')).decode('ascii')
|
||||
return f'<a download="{filename}" href="data:{mime};base64,{data}">{label}</a>'
|
||||
|
||||
rows = []
|
||||
for metric, item in report['summary'].items():
|
||||
numeric = 'mean' in item
|
||||
values = [item.get(key) for key in ('min', 'mean', 'max', 'last')]
|
||||
cells = ''.join(f'<td>{esc(value) if value is not None else "Unknown"}</td>' for value in values)
|
||||
states = ', '.join(item.get('states', [])) or ('Numeric' if numeric else 'Unknown')
|
||||
rows.append(f'<tr><th scope="row">{esc(metric)}</th><td>{esc(item["unit"])}</td>'
|
||||
f'{cells}<td>{item["available"]} / {item["unavailable"]}</td>'
|
||||
f'<td>{esc(states)}</td><td>{esc(", ".join(item["sources"]))}</td></tr>')
|
||||
missing = sorted({g for sample in report['samples'] for g in sample['unavailableGroups']})
|
||||
json_text = json.dumps(report, allow_nan=False, indent=2)
|
||||
links = download(json_text, 'application/json', 'frame-power.json', 'Download JSON samples and summary')
|
||||
links += ' · ' + download(csv_text, 'text/csv', 'frame-power.csv', 'Download CSV samples')
|
||||
return f'''<!doctype html>
|
||||
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
|
||||
<title>Frame power and heat report</title>
|
||||
<style>body{{font:16px system-ui,sans-serif;line-height:1.5;margin:24px;color:#18212b;background:#fff}}
|
||||
a{{color:#0645ad}}a:focus-visible{{outline:3px solid #0645ad;outline-offset:4px}}
|
||||
table{{border-collapse:collapse;width:100%;min-width:1100px}}th,td{{padding:8px;text-align:left;border:1px solid #adb5bd}}
|
||||
thead th,td{{white-space:nowrap}}th[scope=row]{{min-width:240px;max-width:300px;overflow-wrap:anywhere}}
|
||||
td:last-child{{min-width:220px;max-width:300px;white-space:normal;overflow-wrap:anywhere}}
|
||||
thead{{background:#edf1f5}}.scroll{{overflow:auto}}h1{{line-height:1.2}}</style>
|
||||
<main><h1>Frame power and heat report</h1>
|
||||
<p>Headset: {esc(report.get("headset", {}).get("alias", "frame"))}</p>
|
||||
<p>{len(report['samples'])} of {report['requestedSamples']} requested samples received; target interval {esc(report['intervalSeconds'])} seconds.</p>
|
||||
<p>Collection: {esc(report['stopped'] or 'Completed')}. Results describe only the samples received.</p>
|
||||
<p>{links}</p>
|
||||
<p>Unknown means missing, unreadable or invalid, never zero. Means exclude unknown values and are not time-weighted.
|
||||
Battery watts: positive charging, negative discharge. C means °C, W watts, A amps, MHz megahertz, RPM revolutions per minute.
|
||||
Raw readings have no inferred percentage or worn threshold. Rails overlap: do not sum them.</p>
|
||||
<p>Unavailable sensor groups in one or more samples: {esc(', '.join(missing) or 'None reported')}.</p>
|
||||
<div class="scroll" role="region" aria-label="Metric summary, scroll horizontally for all columns" tabindex="0">
|
||||
<table><caption>Received measurements, with units and availability counts</caption>
|
||||
<thead><tr><th scope="col">Metric</th><th scope="col">Unit</th><th scope="col">Min</th><th scope="col">Mean</th><th scope="col">Max</th><th scope="col">Last</th><th scope="col">Available / unknown</th><th scope="col">States</th><th scope="col">Sources</th></tr></thead>
|
||||
<tbody>{''.join(rows)}</tbody></table></div>
|
||||
<p>Sensor accuracy is unverified. Suspect negative thermistors remain visible. No temperature safety limits,
|
||||
energy use or battery-runtime estimates are inferred. JSON includes actual timing, OS builds and per-sample availability.</p>
|
||||
<p>This file stays on your computer and works offline. Downloaded files contain sensor source paths and OS builds;
|
||||
review them before sharing. No settings, services, security or passwords were changed.</p></main></html>'''
|
||||
+162
-14
@@ -10,8 +10,11 @@ import io
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
LOCK = Path('/tmp/frame-test.lock')
|
||||
@@ -117,7 +120,7 @@ def summarize(samples):
|
||||
return result
|
||||
|
||||
|
||||
def collect(status, count, interval):
|
||||
def collect(status, count, interval, emit=None):
|
||||
samples, before = [], None
|
||||
started = time.monotonic()
|
||||
stopped = None
|
||||
@@ -131,23 +134,35 @@ def collect(status, count, interval):
|
||||
break
|
||||
sample['elapsedSeconds'] = round(time.monotonic() - started, 6)
|
||||
samples.append(sample)
|
||||
if emit:
|
||||
emit({'sample': sample})
|
||||
return {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
|
||||
'stopped': stopped, 'samples': samples, 'summary': summarize(samples)}
|
||||
|
||||
|
||||
def csv_samples(report):
|
||||
# CSV quoting alone does not prevent spreadsheet formulas in text fields.
|
||||
def cell(value):
|
||||
if isinstance(value, str) and (value.startswith(('\t', '\r', '\n')) or
|
||||
value.lstrip(' \t\r\n').startswith(('=', '+', '-', '@'))):
|
||||
return "'" + value
|
||||
return value
|
||||
|
||||
output = io.StringIO(newline='')
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(('time', 'elapsedSeconds', 'metric', 'value', 'unit', 'source', 'state'))
|
||||
if 'headset' in report:
|
||||
for key, value in (('collection.state', report['stopped'] or 'Completed'),
|
||||
('collection.requestedSamples', report['requestedSamples']),
|
||||
('collection.receivedSamples', len(report['samples']))):
|
||||
writer.writerow([cell(v) for v in ('', '', key, value, '', report['headset']['alias'],
|
||||
'incomplete' if report['stopped'] else 'completed')])
|
||||
for sample in report['samples']:
|
||||
for key, row in sample['readings'].items():
|
||||
# csv quoting does not prevent spreadsheet formulas in labels.
|
||||
def cell(value):
|
||||
return "'" + value if isinstance(value, str) and value.startswith(('=', '+', '-', '@')) else value
|
||||
writer.writerow([cell(v) for v in (sample['time'], sample['elapsedSeconds'], key,
|
||||
row['value'], row['unit'], row['source'], row['state'])])
|
||||
for group in sample['unavailableGroups']:
|
||||
writer.writerow((sample['time'], sample['elapsedSeconds'], group, '', '', '', UNAVAILABLE))
|
||||
writer.writerow([cell(v) for v in (sample['time'], sample['elapsedSeconds'], group, '', '', '', UNAVAILABLE)])
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
@@ -156,7 +171,9 @@ def parser():
|
||||
ap.add_argument('--count', type=int, default=12, help='samples, 1..720 (default 12)')
|
||||
ap.add_argument('--interval', type=float, default=5, help='seconds, 1..60 (default 5)')
|
||||
ap.add_argument('--csv', action='store_true', help='CSV samples on stdout; JSON summary on stderr')
|
||||
ap.add_argument('--html', type=Path, metavar='FILE', help='save an offline accessible report with JSON/CSV downloads; Ctrl+C retains received samples (max 10 minutes); never overwrites')
|
||||
ap.add_argument('--probe', action='store_true', help=argparse.SUPPRESS)
|
||||
ap.add_argument('--stream', action='store_true', help=argparse.SUPPRESS)
|
||||
return ap
|
||||
|
||||
|
||||
@@ -171,35 +188,166 @@ def remote_payload():
|
||||
'sys.modules["frame_status"] = status\n' + Path(__file__).read_text())
|
||||
|
||||
|
||||
def stream_report(command, payload, count, interval, stop_event=None, on_sample=None, discard_errors=False):
|
||||
"""Receive each sample before the next wait; stop only our local SSH child.
|
||||
|
||||
The remote bounded reader exits on its next write to the closed connection.
|
||||
No remote process-control command is sent. Keep already received samples on
|
||||
interrupt, transport failure or deadline, clearly marked as incomplete.
|
||||
"""
|
||||
if stop_event is not None and stop_event.is_set():
|
||||
return {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
|
||||
'stopped': 'Stopped before collection; no SSH started', 'samples': [], 'summary': {}}
|
||||
samples, final, stopped = [], None, None
|
||||
events = queue.Queue(maxsize=count + 2)
|
||||
reader_stop = threading.Event()
|
||||
deadline = time.monotonic() + count * interval + 30
|
||||
with tempfile.TemporaryFile(mode='w+') as errors:
|
||||
proc = subprocess.Popen(command + ['--stream'], stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL if discard_errors else errors, text=True)
|
||||
def receive():
|
||||
try:
|
||||
for _ in range(count + 2):
|
||||
line = proc.stdout.readline(262145)
|
||||
if not line:
|
||||
break
|
||||
if len(line) > 262144 or not line.endswith('\n'):
|
||||
line = 'invalid or oversized sample'
|
||||
while not reader_stop.is_set():
|
||||
try:
|
||||
events.put(line, timeout=0.2)
|
||||
break
|
||||
except queue.Full:
|
||||
continue
|
||||
if reader_stop.is_set():
|
||||
return
|
||||
finally:
|
||||
while not reader_stop.is_set():
|
||||
try:
|
||||
events.put(None, timeout=0.2)
|
||||
break
|
||||
except queue.Full:
|
||||
continue
|
||||
reader = threading.Thread(target=receive, daemon=True)
|
||||
reader.start()
|
||||
def feed():
|
||||
try:
|
||||
proc.stdin.write(payload)
|
||||
proc.stdin.close()
|
||||
except OSError:
|
||||
pass # transport exit/EOF below marks the observation incomplete
|
||||
writer = threading.Thread(target=feed, daemon=True)
|
||||
writer.start()
|
||||
try:
|
||||
while True:
|
||||
if stop_event is not None and stop_event.is_set() and events.empty():
|
||||
stopped = 'Stopped; received samples retained'
|
||||
break
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
stopped = 'Timed out; received samples retained'
|
||||
break
|
||||
try:
|
||||
line = events.get(timeout=min(remaining, 0.2))
|
||||
except queue.Empty:
|
||||
continue
|
||||
if line is None:
|
||||
break
|
||||
event = json.loads(line)
|
||||
if 'sample' in event:
|
||||
if len(samples) >= count:
|
||||
raise ValueError('Too many samples received')
|
||||
samples.append(event['sample'])
|
||||
if on_sample:
|
||||
on_sample(event['sample'])
|
||||
print(f'Received {len(samples)}/{count} samples; Ctrl+C to stop and save.', file=sys.stderr)
|
||||
elif 'finished' in event:
|
||||
final = event['finished']
|
||||
if not stopped:
|
||||
proc.wait(timeout=max(0.1, deadline - time.monotonic()))
|
||||
except KeyboardInterrupt:
|
||||
stopped = 'Stopped by user; received samples retained'
|
||||
except (OSError, ValueError, subprocess.TimeoutExpired) as exc:
|
||||
stopped = f'Collection interrupted: {exc}'
|
||||
finally:
|
||||
reader_stop.set()
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=3)
|
||||
writer.join(timeout=3)
|
||||
if not proc.stdin.closed:
|
||||
try:
|
||||
proc.stdin.close()
|
||||
except OSError:
|
||||
pass
|
||||
reader.join(timeout=3)
|
||||
proc.stdout.close()
|
||||
if not stopped:
|
||||
errors.seek(0)
|
||||
if final and final.get('stopped') and proc.returncode in (0, 75):
|
||||
stopped = final['stopped']
|
||||
elif proc.returncode or final is None or len(samples) != count:
|
||||
stopped = f'Incomplete collection (SSH exit {proc.returncode}): ' + errors.read(2000).strip()
|
||||
return {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
|
||||
'stopped': stopped, 'samples': samples, 'summary': summarize(samples)}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = parser()
|
||||
args = ap.parse_args(argv)
|
||||
if not 1 <= args.count <= 720 or not math.isfinite(args.interval) or not 1 <= args.interval <= 60:
|
||||
ap.error('count must be 1..720 and interval must be finite, 1..60 seconds')
|
||||
if args.html and (args.csv or args.probe or args.count * args.interval > 600):
|
||||
ap.error('--html requires a host session, no --csv, and count × interval <= 600 seconds')
|
||||
if args.html and args.html.exists():
|
||||
ap.error('--html output already exists; choose a new file')
|
||||
# Also honor a coordinator lock on the computer before opening SSH.
|
||||
if LOCK.exists():
|
||||
if args.probe and args.stream:
|
||||
print(json.dumps({'finished': {'stopped': 'Smoke session active (/tmp/frame-test.lock); no sensors read'}}), flush=True)
|
||||
print('Smoke session active (/tmp/frame-test.lock); no SSH or sensors accessed.', file=sys.stderr)
|
||||
return 75
|
||||
if args.probe:
|
||||
import frame_status
|
||||
report = collect(frame_status, args.count, args.interval)
|
||||
emit = (lambda event: print(json.dumps(event, allow_nan=False), flush=True)) if args.stream else None
|
||||
report = collect(frame_status, args.count, args.interval, emit)
|
||||
if args.stream:
|
||||
emit({'finished': {'stopped': report['stopped']}})
|
||||
return 75 if report['stopped'] else 0
|
||||
else:
|
||||
command = ['ssh', '-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes',
|
||||
'-o', 'ConnectTimeout=5', '-o', 'ServerAliveInterval=5',
|
||||
'-o', 'ServerAliveCountMax=2', 'frame',
|
||||
'python3', '-', '--probe', '--count', str(args.count), '--interval', str(args.interval)]
|
||||
try:
|
||||
run = subprocess.run(command, input=remote_payload(), text=True, capture_output=True,
|
||||
timeout=args.count * args.interval + 30)
|
||||
if run.returncode:
|
||||
print('Read-only sample failed: ' + (run.stderr.strip() or f'SSH exit {run.returncode}'),
|
||||
file=sys.stderr)
|
||||
return run.returncode
|
||||
report = json.loads(run.stdout)
|
||||
if args.html:
|
||||
report = stream_report(command, remote_payload(), args.count, args.interval)
|
||||
else:
|
||||
run = subprocess.run(command, input=remote_payload(), text=True, capture_output=True,
|
||||
timeout=args.count * args.interval + 30)
|
||||
if run.returncode:
|
||||
print('Read-only sample failed: ' + (run.stderr.strip() or f'SSH exit {run.returncode}'),
|
||||
file=sys.stderr)
|
||||
return run.returncode
|
||||
report = json.loads(run.stdout)
|
||||
except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
|
||||
print(f'Read-only sample unavailable: {exc}', file=sys.stderr)
|
||||
return 1
|
||||
if args.csv:
|
||||
if args.html:
|
||||
from frame_power_report import render
|
||||
try:
|
||||
with args.html.open('x', encoding='utf-8') as output:
|
||||
output.write(render(report, csv_samples(report)))
|
||||
except OSError as exc:
|
||||
print(f'Could not save report: {exc}; received JSON report follows on stdout.', file=sys.stderr)
|
||||
print(json.dumps(report, allow_nan=False))
|
||||
return 1
|
||||
print(f'Saved {len(report["samples"])} samples to {args.html}. Open it in your browser.', file=sys.stderr)
|
||||
elif args.csv:
|
||||
sys.stdout.write(csv_samples(report))
|
||||
print(json.dumps({k: v for k, v in report.items() if k != 'samples'}, allow_nan=False), file=sys.stderr)
|
||||
else:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Real doctor/SSH against fake-Frame faults; never a real headset."""
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import harness
|
||||
from harness import ROOT, ctl
|
||||
@@ -9,9 +11,9 @@ harness.require()
|
||||
|
||||
|
||||
class Doctor(harness.FrameTestCase):
|
||||
def doctor(self):
|
||||
def doctor(self, *args):
|
||||
return subprocess.run([sys.executable, str(ROOT / 'scripts/frame_doctor.py'),
|
||||
'--lan', harness.FAKE_HOST, '--usb', harness.FAKE_HOST],
|
||||
'--lan', harness.FAKE_HOST, '--usb', harness.FAKE_HOST, *args],
|
||||
capture_output=True, text=True, timeout=120)
|
||||
|
||||
def test_disk_full(self):
|
||||
@@ -41,3 +43,17 @@ class Doctor(harness.FrameTestCase):
|
||||
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)
|
||||
|
||||
def test_plan_and_before_after_storage(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
baseline = str(Path(folder) / 'before.json')
|
||||
ctl('disk-full', 'on')
|
||||
before = self.doctor('--plan', '--save-report', baseline)
|
||||
self.assertEqual(before.returncode, 1, before.stdout + before.stderr)
|
||||
self.assertIn('use Steam storage management', before.stdout)
|
||||
ctl('disk-full', 'off') # fake fault control, not a doctor repair
|
||||
after = self.doctor('--plan', '--compare-before', baseline)
|
||||
self.assertEqual(after.returncode, 1, after.stdout + after.stderr)
|
||||
self.assertIn('Steam and storage: BROKEN ->', after.stdout)
|
||||
self.assertNotIn('Steam and storage: BROKEN -> BROKEN', after.stdout)
|
||||
self.assertIn('no automatic repair is available', after.stdout)
|
||||
@@ -0,0 +1,77 @@
|
||||
const {test}=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const {workspaceFixture,html}=require('./workspace_fixture.cjs');
|
||||
function fixture() {
|
||||
const f=workspaceFixture();
|
||||
Object.assign(f.context, {clearTimeout(){},setTimeout(){},UI_KEY:'1',encodeURIComponent});
|
||||
const formats=['html','json','csv'].map(format=>Object.assign(f.el('download-'+format),{dataset:{powerFormat:format}}));
|
||||
const query=f.context.document.querySelectorAll;
|
||||
f.context.document.querySelectorAll=s=>s==='[data-power-format]'?formats:query(s);
|
||||
f.formats=formats;
|
||||
f.run(html.slice(html.indexOf('// ---- Read-only power observation ----'),html.indexOf('// ---- Read-only game storage ----')));
|
||||
return f;
|
||||
}
|
||||
const state={id:'abc',headset:{alias:'old-frame'},requested:12,received:3,running:true,stopped:null};
|
||||
test('Tools navigation loads the report and renders accessible progress',async()=>{
|
||||
const f=fixture();let calls=0;f.context.api=async()=>{calls++;return state;};
|
||||
f.navigate('#powerReport');await new Promise(setImmediate);
|
||||
assert.equal(f.activePage,'tools');assert.equal(f.el('powerReport').hidden,false);
|
||||
assert.equal(f.el('powerReport').parentNode,f.el('toolWorkspace'));
|
||||
assert.equal(calls,1);assert.equal(f.el('powerProgress').value,3);
|
||||
assert.equal(f.el('powerStart').disabled,true);assert.equal(f.el('powerStop').disabled,false);
|
||||
assert.match(f.el('powerStatus').textContent,/Headset: old-frame.*3 of 12.*Observing/);
|
||||
});
|
||||
test('incomplete result preserves original identity and start becomes available',()=>{
|
||||
const f=fixture();f.context.sample={...state,running:false,stopped:'Headset switched; samples retained'};
|
||||
f.run('renderPower(sample)');
|
||||
assert.match(f.el('powerStatus').textContent,/old-frame.*Incomplete: Headset switched/);
|
||||
assert.equal(f.el('powerStart').disabled,false);assert.equal(f.el('powerStop').disabled,true);
|
||||
});
|
||||
test('stop submits observation identity and keeps progress visible',async()=>{
|
||||
const f=fixture(),calls=[];f.context.sample=state;f.run('renderPower(sample)');
|
||||
f.context.api=async(path,body)=>{calls.push([path,body]);return {...state,running:false,stopped:'Stopped by user'};};
|
||||
await f.el('powerStop').onclick();
|
||||
assert.equal(calls[0][0],'/api/power');assert.equal(calls[0][1].action,'stop');assert.equal(calls[0][1].id,'abc');
|
||||
assert.equal(f.el('powerProgress').value,3);assert.match(f.el('powerStatus').textContent,/Incomplete/);
|
||||
});
|
||||
test('old polls cannot overwrite newer report and retry explains retained samples',async()=>{
|
||||
const f=fixture(),pending=[];f.context.api=()=>new Promise((resolve,reject)=>pending.push({resolve,reject}));
|
||||
const first=f.run('loadPower()'),second=f.run('loadPower()');
|
||||
pending[1].resolve({...state,running:false,stopped:'Disconnected'});await second;
|
||||
const current=f.el('powerStatus').textContent;pending[0].resolve(state);await first;
|
||||
assert.equal(f.el('powerStatus').textContent,current);
|
||||
const next=f.run('loadPower()');pending[2].reject(new Error('Offline'));await next;
|
||||
assert.match(f.el('powerStatus').textContent,/Offline.*Received samples remain/);
|
||||
});
|
||||
test('start failure remains actionable and does not imply success',async()=>{
|
||||
const f=fixture();f.context.api=async()=>{throw new Error('Smoke session active');};
|
||||
await f.el('powerStart').onclick();assert.match(f.el('powerStatus').textContent,/Smoke session/);
|
||||
assert.equal(f.el('powerStart').disabled,false);
|
||||
});
|
||||
test('download captures observation ID, fetches an authenticated blob and names the file',async()=>{
|
||||
const f=fixture(),requests=[],saved=[];
|
||||
f.context.sample={...state,running:false,stopped:'Disconnected'};f.run('renderPower(sample)');
|
||||
f.context.fetch=async(url,opts)=>{requests.push([url,opts]);return {ok:true,blob:async()=>({report:true})};};
|
||||
f.context.URL={createObjectURL:()=> 'blob:report',revokeObjectURL(){}};
|
||||
f.context.document.createElement=()=>({click(){saved.push({href:this.href,name:this.download});}});
|
||||
await f.formats[0].onclick();
|
||||
assert.equal(requests[0][0],'/api/power/report?id=abc&format=html');
|
||||
assert.equal(requests[0][1].headers['X-Frame-UI'],'1');
|
||||
assert.deepEqual(saved,[{href:'blob:report',name:'frame-power-abc.html'}]);
|
||||
assert.match(f.el('powerSaveStatus').textContent,/Download requested/);
|
||||
f.context.fetch=async()=>({ok:false,json:async()=>({error:'Report replaced'})});
|
||||
await f.formats[0].onclick();
|
||||
assert.match(f.el('powerSaveStatus').textContent,/Download failed: Report replaced/);
|
||||
assert.equal(f.formats[0].disabled,false);
|
||||
});
|
||||
test('packaging guard requires shared modules and detects missing resources',()=>{
|
||||
const fs=require('node:fs'),os=require('node:os'),path=require('node:path');
|
||||
const guard=require('../app/build/check-resources');const tmp=fs.mkdtempSync(path.join(os.tmpdir(),'power-resources-'));
|
||||
try {
|
||||
for(const rel of guard.REQUIRED) {const file=path.join(tmp,rel);fs.mkdirSync(path.dirname(file),{recursive:true});fs.writeFileSync(file,'test');}
|
||||
assert.deepEqual(guard.missing(tmp),[]);
|
||||
for(const rel of ['ui/frame_power.py','ui/frame_status.py','scripts/frame_power_sample.py','scripts/frame_power_report.py']) {
|
||||
assert.ok(guard.REQUIRED.includes(rel));fs.unlinkSync(path.join(tmp,rel));assert.ok(guard.missing(tmp).includes(rel));
|
||||
}
|
||||
} finally {fs.rmSync(tmp,{recursive:true,force:true});}
|
||||
});
|
||||
+104
-18
@@ -5,6 +5,7 @@ import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -203,23 +204,106 @@ class Remote(unittest.TestCase):
|
||||
self.assertEqual(result['written'], 0)
|
||||
self.assertEqual(result['held'], {'refused': 1, 'reference': 1})
|
||||
|
||||
def test_session_reads_the_mode(self):
|
||||
conf = write(self.home, 'zz-steamos-autologin.conf', '[Autologin]\nSession=plasma-steamos-wayland-oneshot.desktop\n')
|
||||
running = set()
|
||||
with patch.object(remote, 'SDDM_CONFS', (str(conf),)), \
|
||||
patch.object(remote, '_running', side_effect=lambda n, full=False: n in running), \
|
||||
patch.object(remote.shutil, 'which', side_effect=lambda n: '/usr/bin/' + n if n == 'steamos-session-select' else None):
|
||||
state = remote.session()
|
||||
self.assertEqual((state['mode'], state['selector']), ('desktop', 'steamos-session-select'))
|
||||
self.assertEqual(state['configured'], 'plasma-steamos-wayland-oneshot.desktop')
|
||||
running.update({'gamescope', 'plasmashell'}) # the nested desktop panel
|
||||
self.assertEqual(remote.session()['mode'], 'game')
|
||||
running.clear()
|
||||
running.add('gamescope-wl') # gamescope's process name on the Frame, with no desktop panel open
|
||||
self.assertEqual(remote.session()['mode'], 'game')
|
||||
running.clear()
|
||||
running.add('start-gamescope-session')
|
||||
self.assertEqual(remote.session()['mode'], 'game')
|
||||
|
||||
class Session(unittest.TestCase):
|
||||
def state(self, targets, running=(), configured=None):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
conf = write(folder, 'sddm.conf', 'Session=%s\n' % (configured or 'gamescope.desktop'))
|
||||
with patch.object(remote, 'SDDM_CONFS', (str(conf),)), \
|
||||
patch.object(remote, '_session_target', side_effect=targets), \
|
||||
patch.object(remote, '_running', side_effect=lambda n, full=False: n in running), \
|
||||
patch.object(remote.shutil, 'which', return_value='/usr/bin/steamosctl'):
|
||||
return remote.session()
|
||||
|
||||
def test_full_plasma_with_vr_gamescope_is_desktop(self):
|
||||
# Issue #142, SteamOS 0.5.6: steamvr-plasma also starts nested scripts.
|
||||
state = self.state(['inactive', 'active'],
|
||||
{'gamescope-wl', 'plasmashell', 'steamos-nested-desktop'},
|
||||
configured='gamescope.desktop')
|
||||
self.assertEqual(state['mode'], 'desktop')
|
||||
self.assertTrue(state['gamescope'])
|
||||
self.assertTrue(state['nested_desktop'])
|
||||
self.assertEqual(state['selector'], 'steamosctl')
|
||||
self.assertEqual(state['session_targets']['plasma-workspace-wayland.target'], 'active')
|
||||
with patch.object(backup, 'session_status', return_value=state), patch.object(backup, '_ssh') as ssh:
|
||||
self.assertTrue(backup.return_to_game_mode(confirmed=True)['switched'])
|
||||
ssh.assert_called_once_with('steamosctl switch-to-game-mode', timeout=60)
|
||||
|
||||
def test_game_target_wins_with_or_without_nested_desktop(self):
|
||||
for running in ((), {'gamescope-wl'}, {'gamescope', 'plasmashell', 'steamos-nested-desktop'}):
|
||||
state = self.state(['active', 'inactive'], running, configured='plasma.desktop')
|
||||
self.assertEqual(state['mode'], 'game')
|
||||
with patch.object(backup, 'session_status', return_value=state), patch.object(backup, '_ssh') as ssh:
|
||||
self.assertFalse(backup.return_to_game_mode(confirmed=True)['switched'])
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_conflicting_incomplete_and_transitional_targets_block_return(self):
|
||||
for targets in (['active', 'active'], ['inactive', 'inactive'], ['active', 'unknown'],
|
||||
['not-found', 'active'], ['unknown', 'unknown'], ['inactive', 'activating'],
|
||||
['deactivating', 'active'], ['failed', 'active']):
|
||||
with self.subTest(targets=targets):
|
||||
state = self.state(targets, {'gamescope', 'plasmashell'}, 'plasma.desktop')
|
||||
self.assertEqual(state['mode'], 'unknown')
|
||||
with patch.object(backup, 'session_status', return_value=state), patch.object(backup, '_ssh') as ssh:
|
||||
with self.assertRaisesRegex(backup.BackupError, 'nothing was changed'):
|
||||
backup.return_to_game_mode(confirmed=True)
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_older_builds_use_only_unambiguous_running_processes(self):
|
||||
cases = [((), 'unknown'), ({'plasmashell'}, 'desktop'),
|
||||
({'gamescope'}, 'game'), ({'gamescope-wl'}, 'game'),
|
||||
({'start-gamescope-session'}, 'game'),
|
||||
({'gamescope', 'plasmashell'}, 'unknown'),
|
||||
({'gamescope', 'steamos-nested-desktop'}, 'unknown')]
|
||||
for running, expected in cases:
|
||||
with self.subTest(running=running):
|
||||
state = self.state(['not-found', 'not-found'], running, 'plasma.desktop')
|
||||
self.assertEqual(state['mode'], expected)
|
||||
self.assertEqual(state['mode_source'], 'legacy-processes')
|
||||
|
||||
def test_target_probe_parses_systemd_and_uses_main_user_bus(self):
|
||||
cases = [('LoadState=loaded\nActiveState=active\n', 0, 'active'),
|
||||
('ActiveState=inactive\nLoadState=loaded\n', 0, 'inactive'),
|
||||
('LoadState=not-found\nActiveState=inactive\n', 0, 'not-found'),
|
||||
('LoadState=error\nActiveState=active\n', 0, 'unknown'),
|
||||
('garbage', 0, 'unknown'), ('LoadState=loaded\nActiveState=active', 1, 'unknown')]
|
||||
for stdout, code, expected in cases:
|
||||
with self.subTest(stdout=stdout, code=code), \
|
||||
patch.object(remote.os, 'getuid', return_value=1000, create=True), \
|
||||
patch.object(remote.subprocess, 'run', return_value=subprocess.CompletedProcess([], code, stdout)) as run:
|
||||
self.assertEqual(remote._session_target('gamescope-session.target'), expected)
|
||||
self.assertEqual(run.call_args.args[0], ['systemctl', '--user', 'show',
|
||||
'gamescope-session.target', '--property=LoadState', '--property=ActiveState'])
|
||||
self.assertEqual(run.call_args.kwargs['timeout'], 5)
|
||||
self.assertEqual(run.call_args.kwargs['env']['DBUS_SESSION_BUS_ADDRESS'], 'unix:path=/run/user/1000/bus')
|
||||
self.assertEqual(run.call_args.kwargs['env']['XDG_RUNTIME_DIR'], '/run/user/1000')
|
||||
for error in (OSError('no systemctl'), subprocess.TimeoutExpired('systemctl', 5)):
|
||||
with patch.object(remote.os, 'getuid', return_value=1000, create=True), \
|
||||
patch.object(remote.subprocess, 'run', side_effect=error):
|
||||
self.assertEqual(remote._session_target('gamescope-session.target'), 'unknown')
|
||||
|
||||
def test_selector_preference_and_older_builds(self):
|
||||
for available, expected in [({'steamosctl', 'steamos-session-select'}, 'steamosctl'),
|
||||
({'holo-session-select', 'steamos-session-select'}, 'holo-session-select'),
|
||||
({'steamos-session-select'}, 'steamos-session-select'), (set(), None)]:
|
||||
with patch.object(remote, 'SDDM_CONFS', ()), \
|
||||
patch.object(remote, '_session_target', return_value='unknown'), \
|
||||
patch.object(remote, '_running', return_value=False), \
|
||||
patch.object(remote.shutil, 'which', side_effect=lambda n: '/usr/bin/' + n if n in available else None):
|
||||
self.assertEqual(remote.session()['selector'], expected)
|
||||
|
||||
def test_script_probe_does_not_match_names_in_transmitted_python_source(self):
|
||||
with patch.object(remote.subprocess, 'call', return_value=1) as call:
|
||||
self.assertFalse(remote._running('steamos-nested-desktop', full=True))
|
||||
pattern = call.call_args.args[0][-1]
|
||||
for command in ('/usr/bin/steamos-nested-desktop',
|
||||
'/bin/sh /usr/bin/steamos-nested-desktop',
|
||||
'bash steamos-nested-desktop --flag'):
|
||||
self.assertIsNotNone(re.search(pattern, command), command)
|
||||
for command in ('python3 -c ' + backup.REMOTE.read_text(),
|
||||
'/bin/sh -c pgrep -f steamos-nested-desktop',
|
||||
'/usr/bin/steamos-nested-desktop-other'):
|
||||
self.assertIsNone(re.search(pattern, command), command)
|
||||
|
||||
|
||||
def fake_ssh(home, os_release):
|
||||
@@ -393,9 +477,11 @@ class Ssh(unittest.TestCase):
|
||||
|
||||
class GameMode(unittest.TestCase):
|
||||
def test_command_is_only_ever_the_session_switch(self):
|
||||
self.assertEqual(backup.session_command('steamosctl'), 'steamosctl switch-to-game-mode')
|
||||
self.assertEqual(backup.session_command('steamos-session-select'), 'steamos-session-select gamescope')
|
||||
self.assertEqual(backup.session_command('holo-session-select'), 'holo-session-select gamescope')
|
||||
for bad in (None, '', 'rm -rf ~', '/tmp/steamos-session-select', 'steamos-session-select; reboot'):
|
||||
for bad in (None, '', 'rm -rf ~', '/tmp/steamos-session-select', 'steamos-session-select; reboot',
|
||||
'steamosctl switch-to-desktop-mode', 'steamosctl; reboot'):
|
||||
with self.assertRaises(backup.BackupError):
|
||||
backup.session_command(bad)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
@@ -25,7 +26,7 @@ class FrameDoctor(unittest.TestCase):
|
||||
patch.object(doctor, 'run', side_effect=[config, *responses]) as run, \
|
||||
patch.object(doctor, 'port_open', return_value=kwargs.get('devkit', False)), \
|
||||
patch('sys.stdout', new_callable=io.StringIO) as output:
|
||||
code = doctor.main([])
|
||||
code = doctor.main(kwargs.get('argv', []))
|
||||
return code, output.getvalue(), run
|
||||
|
||||
def test_unreachable_is_actionable_without_exposing_stderr(self):
|
||||
@@ -105,6 +106,91 @@ class FrameDoctor(unittest.TestCase):
|
||||
with patch('sys.stdout', new_callable=io.StringIO):
|
||||
self.assertEqual(doctor.report(data), 1)
|
||||
|
||||
def stable(self, disk=None):
|
||||
checks = [['UNKNOWN', 'Evidence unavailable; consult the runbook locally.'] for _ in doctor.SECTIONS]
|
||||
checks[3] = disk or ['BROKEN', 'Less than 16 MiB free; review storage in Frame Control before installing.']
|
||||
return {'guard': 'stable', 'checks': checks}
|
||||
|
||||
def test_plan_requires_exact_evidence_and_never_adds_remote_commands(self):
|
||||
data = self.stable()
|
||||
code, text, calls = self.main([result(json.dumps(data))], argv=['--plan'])
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn('READ-ONLY', text)
|
||||
self.assertIn('use Steam storage management', text)
|
||||
self.assertEqual(calls.call_count, 2) # config read and unchanged read-only probe
|
||||
data['checks'][3] = ['BROKEN', 'Evidence unavailable; consult the runbook locally.']
|
||||
_, text, _ = self.main([result(json.dumps(data))], argv=['--plan'])
|
||||
self.assertNotIn('use Steam storage management', text)
|
||||
self.assertIn('does not establish a safe automatic fix', text)
|
||||
|
||||
def test_plan_guard_blocks_all_section_actions(self):
|
||||
for state in ('loop', 'risk', 'unknown'):
|
||||
_, text, _ = self.main([result(json.dumps({'guard': state, 'checks': []}))], argv=['--plan'])
|
||||
self.assertIn('BLOCKED', text)
|
||||
self.assertNotIn('PLAN 4.', text)
|
||||
|
||||
def test_before_after_and_exclusive_private_baseline(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = Path(folder) / 'before.json'
|
||||
before = self.stable()
|
||||
_, text, _ = self.main([result(json.dumps(before))], argv=['--save-report', str(path)])
|
||||
saved = path.read_text()
|
||||
self.assertNotIn('private-tailnet', saved)
|
||||
self.assertNotIn('pinned-frame', saved)
|
||||
if __import__('os').name != 'nt':
|
||||
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
|
||||
after = self.stable(['OK', 'Home and title storage have at least 256 MiB free.'])
|
||||
_, text, _ = self.main([result(json.dumps(after))], argv=['--compare-before', str(path)])
|
||||
self.assertIn('Steam and storage: BROKEN -> OK', text)
|
||||
self.assertIn('do not prove repair', text)
|
||||
self.assertIn('Before evidence: Less than 16 MiB free', text)
|
||||
self.assertIn('After evidence: Home and title storage', text)
|
||||
code, _, _ = self.main([result(json.dumps(after))], argv=['--save-report', str(path)])
|
||||
self.assertEqual(code, 2)
|
||||
self.assertEqual(path.read_text(), saved)
|
||||
# Same alias with changed routing cannot masquerade as the same baseline.
|
||||
code, text, calls = self.main([], argv=['--compare-before', str(path), '--usb', '192.0.2.9'])
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn('comparison refused', text)
|
||||
self.assertEqual(calls.call_count, 1)
|
||||
|
||||
def test_report_refuses_symlinks(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
target = Path(folder) / 'target'
|
||||
target.write_text('preserve')
|
||||
link = Path(folder) / 'link'
|
||||
try:
|
||||
link.symlink_to(target)
|
||||
except OSError:
|
||||
self.skipTest('symlinks unavailable')
|
||||
code, _, _ = self.main([result(json.dumps(self.stable()))], argv=['--save-report', str(link)])
|
||||
self.assertEqual(code, 2)
|
||||
self.assertEqual(target.read_text(), 'preserve')
|
||||
|
||||
def test_comparison_suppresses_improvement_if_guard_fails(self):
|
||||
with patch('sys.stdout', new_callable=io.StringIO) as output:
|
||||
doctor.compare(self.stable(), {'guard': 'risk', 'checks': []})
|
||||
self.assertIn('Comparison blocked', output.getvalue())
|
||||
self.assertNotIn('BROKEN -> OK', output.getvalue())
|
||||
|
||||
def test_untrusted_baselines_and_probe_are_rejected_without_echo(self):
|
||||
for payload in ('secret', '[]', '{"version":1}', 'x' * 65537, '[' * 2000 + ']' * 2000):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = Path(folder) / 'baseline.json'
|
||||
path.write_text(payload)
|
||||
code, text, calls = self.main([], argv=['--compare-before', str(path)])
|
||||
self.assertEqual(code, 2)
|
||||
self.assertNotIn('secret', text)
|
||||
calls.assert_not_called()
|
||||
for data in ({'guard': 'secret', 'checks': []},
|
||||
{'guard': 'risk', 'checks': [['WARN', 'secret']]},
|
||||
dict(self.stable(), raw='secret'),
|
||||
self.stable(['WARN', 'Never pairs'])):
|
||||
code, text, _ = self.main([result(json.dumps(data))], argv=['--plan'])
|
||||
self.assertEqual(code, 1)
|
||||
self.assertNotIn('secret', text)
|
||||
self.assertNotIn('Repair plan', text)
|
||||
|
||||
def test_help_shell_entry_point_is_offline(self):
|
||||
# This Bash wrapper is for macOS/Linux; Windows uses the Python CLI.
|
||||
import os, shutil
|
||||
|
||||
@@ -9,7 +9,7 @@ import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadTitles', 'showAndroidTab',
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
|
||||
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
|
||||
|
||||
RUN = r'''
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Desktop observation lifecycle, guarded HTTP API and packaged resource contract."""
|
||||
import sandbox # noqa: F401
|
||||
import contextlib
|
||||
import io
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import frame_power
|
||||
import server
|
||||
|
||||
STREAM = frame_power.sampler.stream_report
|
||||
|
||||
SAMPLE = {'time': 1, 'elapsedSeconds': 0, 'osBuild': 'test', 'readings': {
|
||||
'battery.watts': {'value': 0, 'unit': 'W', 'source': '/sys/test', 'state': 'available'}},
|
||||
'unavailableGroups': ['fan']}
|
||||
|
||||
|
||||
class DesktopPower(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.observation = frame_power.Observation()
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.lock = Path(self.temp.name) / 'frame-test.lock'
|
||||
self.patches = [patch.object(frame_power.sampler, 'LOCK', self.lock)]
|
||||
for p in self.patches:
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.addCleanup(self.observation.shutdown)
|
||||
|
||||
def wait(self):
|
||||
self.observation.thread.join(4)
|
||||
self.assertFalse(self.observation.thread.is_alive())
|
||||
|
||||
def stream(self, program, preset='minute'):
|
||||
real = STREAM
|
||||
def collect(command, payload, count, interval, stop, received, **kw):
|
||||
return real([sys.executable, '-u', '-c', program], '', count, interval, stop, received, **kw)
|
||||
mocked_stream = patch.object(frame_power.sampler, 'stream_report', side_effect=collect)
|
||||
mocked_stream.start()
|
||||
self.addCleanup(mocked_stream.stop)
|
||||
return self.observation.start(preset, ('ssh', '-o', 'HostName=old', 'frame-old'),
|
||||
{'alias': 'frame-old', 'deviceId': 'old'})
|
||||
|
||||
def test_complete_exports_match_and_no_background_collection_on_status(self):
|
||||
def collect(command, payload, count, interval, stop, received, **kw):
|
||||
self.assertEqual(count, 12)
|
||||
self.assertEqual(interval, 5)
|
||||
self.assertIn('HostName=old', command)
|
||||
self.assertIn('StrictHostKeyChecking=yes', command)
|
||||
self.assertIn('frame-old', command)
|
||||
self.assertNotIn('pkill', ' '.join(command))
|
||||
for _ in range(count):
|
||||
received(SAMPLE.copy())
|
||||
return {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
|
||||
'stopped': None, 'samples': [SAMPLE.copy() for _ in range(count)], 'summary': {}}
|
||||
with patch.object(frame_power.sampler, 'stream_report', side_effect=collect) as run:
|
||||
self.observation.start('minute', ('ssh', '-o', 'HostName=old', 'frame-old'), {'alias': 'frame-old'})
|
||||
self.wait()
|
||||
state = self.observation.status()
|
||||
self.assertEqual((state['received'], state['running'], state['stopped']), (12, False, None))
|
||||
for _ in range(3):
|
||||
self.observation.status()
|
||||
self.assertEqual(run.call_count, 1)
|
||||
report, _ = self.observation.export(state['id'], 'json')
|
||||
self.assertEqual(json.loads(report)['summary']['battery.watts']['mean'], 0)
|
||||
html, _ = self.observation.export(state['id'], 'html')
|
||||
csv, _ = self.observation.export(state['id'], 'csv')
|
||||
self.assertIn('Headset: frame-old', html)
|
||||
self.assertIn('collection.state,Completed', csv)
|
||||
self.assertIn('collection.receivedSamples,12', csv)
|
||||
self.assertIn('battery.watts,0,W', csv)
|
||||
|
||||
def test_stop_keeps_received_sample_and_reaps_only_owned_child(self):
|
||||
real_popen = subprocess.Popen
|
||||
children = []
|
||||
def spawn(*a, **kw):
|
||||
proc = real_popen(*a, **kw)
|
||||
children.append(proc)
|
||||
return proc
|
||||
event = json.dumps({'sample': SAMPLE})
|
||||
with patch.object(frame_power.sampler.subprocess, 'Popen', side_effect=spawn):
|
||||
state = self.stream(f'import sys,time; sys.stdin.read(); print({event!r}, flush=True); time.sleep(30)')
|
||||
deadline = time.monotonic() + 3
|
||||
while self.observation.status()['received'] < 1 and time.monotonic() < deadline:
|
||||
time.sleep(.01)
|
||||
self.assertEqual(self.observation.status()['received'], 1)
|
||||
with self.assertRaisesRegex(ValueError, 'already running'):
|
||||
self.observation.start('minute', ('ssh', 'frame'), {'alias': 'other'})
|
||||
active, _ = self.observation.export(state['id'], 'json')
|
||||
self.assertIn('in progress', json.loads(active)['stopped'])
|
||||
self.observation.stop()
|
||||
self.wait()
|
||||
self.assertIsNotNone(children[0].poll())
|
||||
data, _ = self.observation.export(state['id'], 'json')
|
||||
self.assertEqual(json.loads(data)['samples'], [SAMPLE])
|
||||
self.assertIn('Stopped by user', json.loads(data)['stopped'])
|
||||
csv, _ = self.observation.export(state['id'], 'csv')
|
||||
self.assertIn('incomplete', csv)
|
||||
|
||||
def test_disconnect_and_oversize_keep_partial_and_incomplete(self):
|
||||
for ending in ('sys.exit(255)', 'print("x"*262145, flush=True)'):
|
||||
with self.subTest(ending=ending):
|
||||
event = json.dumps({'sample': SAMPLE})
|
||||
state = self.stream(f'import sys; sys.stdin.read(); print({event!r}, flush=True); {ending}')
|
||||
self.wait()
|
||||
data, _ = self.observation.export(state['id'], 'json')
|
||||
report = json.loads(data)
|
||||
self.assertEqual(report['samples'], [SAMPLE])
|
||||
self.assertTrue(report['stopped'])
|
||||
|
||||
def test_local_lock_preserves_previous_report_and_invalid_inputs_do_not_spawn(self):
|
||||
with patch.object(frame_power.sampler, 'stream_report') as run:
|
||||
self.lock.mkdir()
|
||||
with self.assertRaisesRegex(ValueError, 'Smoke session'):
|
||||
self.observation.start('minute', ('ssh', 'frame'), {'alias': 'frame'})
|
||||
for preset in ('hour', 1000, None):
|
||||
with self.assertRaises(ValueError):
|
||||
self.observation.start(preset, ('ssh', 'frame'), {'alias': 'frame'})
|
||||
run.assert_not_called()
|
||||
self.assertIsNone(self.observation.status()['id'])
|
||||
|
||||
def test_headset_switch_preserves_original_identity_and_requests_cancel(self):
|
||||
done = threading.Event()
|
||||
def collect(command, payload, count, interval, cancel, received, **kw):
|
||||
received(SAMPLE)
|
||||
done.set()
|
||||
cancel.wait(3)
|
||||
return {'requestedSamples': count, 'intervalSeconds': interval, 'samples': [SAMPLE], 'stopped': None}
|
||||
with patch.object(frame_power.sampler, 'stream_report', side_effect=collect), \
|
||||
patch.object(server, '_power', self.observation):
|
||||
original = server.FRAME, list(server.HOST_OPTS)
|
||||
try:
|
||||
state = self.observation.start('five-minutes', ('ssh', 'old'), {'alias': 'old'})
|
||||
self.assertTrue(done.wait(2))
|
||||
server.route('different-headset', [])
|
||||
self.wait()
|
||||
current = self.observation.status()
|
||||
self.assertEqual(current['headset']['alias'], 'old')
|
||||
self.assertEqual(current['requested'], 60)
|
||||
self.assertIn('Headset switched', current['stopped'])
|
||||
with self.assertRaises(ValueError):
|
||||
self.observation.export('stale-id', 'html')
|
||||
with self.assertRaises(ValueError):
|
||||
self.observation.export(state['id'], '../file')
|
||||
finally:
|
||||
server.route(*original)
|
||||
|
||||
def test_http_guards_stale_target_stop_id_and_download_headers(self):
|
||||
httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
conn = http.client.HTTPConnection(*httpd.server_address)
|
||||
def request(method, path, body=None, device=None, key=True):
|
||||
headers = {'X-Frame-UI': server.UI_KEY} if key else {}
|
||||
if device:
|
||||
headers['X-Frame-Device'] = device
|
||||
conn.request(method, path, json.dumps(body) if body is not None else None, headers)
|
||||
r = conn.getresponse()
|
||||
return r.status, dict(r.getheaders()), r.read()
|
||||
try:
|
||||
with patch.object(server, '_power', self.observation), \
|
||||
patch.object(server, 'LINK', SimpleNamespace(active_device=lambda: {'id': 'new'})), \
|
||||
patch.object(frame_power.sampler, 'stream_report') as run:
|
||||
for path in ('/api/power', '/api/power/report?id=x&format=html'):
|
||||
self.assertEqual(request('GET', path, key=False)[0], 403)
|
||||
self.assertEqual(request('POST', '/api/power', {'action': 'start', 'preset': 'minute'}, key=False)[0], 403)
|
||||
for device, expected in ((None, 400), ('old', 409)):
|
||||
self.assertEqual(request('POST', '/api/power', {'action': 'start', 'preset': 'minute'}, device)[0], expected)
|
||||
run.assert_not_called()
|
||||
self.observation.id = 'test-id'
|
||||
self.observation.report = {'schemaVersion': 1, 'requestedSamples': 12, 'intervalSeconds': 5,
|
||||
'headset': {'alias': 'old'}, 'stopped': 'Disconnected', 'samples': [SAMPLE]}
|
||||
status, headers, data = request('GET', '/api/power/report?id=test-id&format=html')
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn('attachment;', headers['Content-Disposition'])
|
||||
self.assertIn('no-store', headers['Cache-Control'])
|
||||
self.assertIn(b'Disconnected', data)
|
||||
self.assertEqual(request('POST', '/api/power', {'action': 'stop', 'id': 'stale'}, 'new')[0], 409)
|
||||
self.assertEqual(request('POST', '/api/power', {'action': 'stop', 'id': 'test-id'}, 'new')[0], 200)
|
||||
received = threading.Event()
|
||||
def collect(command, payload, count, interval, cancel, emit, **kw):
|
||||
emit(SAMPLE)
|
||||
received.set()
|
||||
cancel.wait(3)
|
||||
return {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
|
||||
'samples': [SAMPLE], 'stopped': 'Stopped'}
|
||||
run.side_effect = collect
|
||||
code, _, data = request('POST', '/api/power', {'action': 'start', 'preset': 'minute'}, 'new')
|
||||
self.assertEqual(code, 200)
|
||||
new_id = json.loads(data)['id']
|
||||
self.assertNotEqual(new_id, 'test-id')
|
||||
self.assertTrue(received.wait(2))
|
||||
self.assertEqual(request('GET', '/api/power/report?id=test-id&format=json')[0], 409)
|
||||
self.assertEqual(request('POST', '/api/power', {'action': 'stop', 'id': new_id}, 'new')[0], 200)
|
||||
self.wait()
|
||||
self.assertEqual(self.observation.status()['received'], 1)
|
||||
self.assertEqual(self.observation.status()['headset']['deviceId'], 'new')
|
||||
finally:
|
||||
conn.close()
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
thread.join()
|
||||
|
||||
def test_early_remote_smoke_lock_has_structured_reason_without_sensor_reads(self):
|
||||
self.lock.mkdir()
|
||||
output = io.StringIO()
|
||||
with patch.object(frame_power.sampler, 'snapshot') as sensor, contextlib.redirect_stdout(output), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(frame_power.sampler.main(['--probe', '--stream']), 75)
|
||||
sensor.assert_not_called()
|
||||
self.assertIn('Smoke session active', json.loads(output.getvalue())['finished']['stopped'])
|
||||
|
||||
def test_phone_can_import_without_desktop_sampler_resources(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
import os
|
||||
import shutil
|
||||
shutil.copyfile(ROOT / 'ui/frame_power.py', Path(tmp) / 'frame_power.py')
|
||||
program = 'import sys; sys.path.insert(0, sys.argv[1]); import frame_power; assert frame_power.Observation().status()["id"] is None'
|
||||
result = subprocess.run([sys.executable, '-I', '-B', '-c', program, tmp],
|
||||
env={**os.environ, 'FRAME_LOCAL': '1'}, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_packaged_resources_under_isolated_python(self):
|
||||
config = json.loads((ROOT / 'app/package.json').read_text())
|
||||
# Materialize exactly the Python resources selected by electron-builder.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
resources = Path(tmp)
|
||||
import shutil
|
||||
for spec in config['build']['extraResources']:
|
||||
if spec.get('to') not in ('ui', 'scripts'):
|
||||
continue
|
||||
source = ROOT / 'app' / spec['from']
|
||||
for pattern in spec['filter']:
|
||||
for file in source.glob(pattern):
|
||||
if file.is_file():
|
||||
dest = resources / spec['to'] / file.name
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(file, dest)
|
||||
program = ('import sys; sys.path.insert(0, sys.argv[1]); import frame_power; '
|
||||
'p=frame_power.sampler.remote_payload(); '
|
||||
'assert "def snapshot" in p and "def battery" in p; '
|
||||
'o=frame_power.Observation(); assert not o.status()["running"]')
|
||||
run = subprocess.run([sys.executable, '-I', '-B', '-c', program, str(resources / 'ui')], capture_output=True, text=True)
|
||||
self.assertEqual(run.returncode, 0, run.stderr)
|
||||
run = subprocess.run(['node', '--test', str(ROOT / 'tests/power_desktop.cjs')], capture_output=True, text=True)
|
||||
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Offline HTML exports and streamed transport, with real local child cleanup."""
|
||||
import sandbox # noqa: F401
|
||||
import base64
|
||||
import contextlib
|
||||
import csv
|
||||
from html.parser import HTMLParser
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'scripts'))
|
||||
import frame_power_sample as power
|
||||
import frame_power_report as page
|
||||
|
||||
|
||||
SAMPLE = {'time': 1, 'elapsedSeconds': 0, 'osBuild': 'test',
|
||||
'readings': {'battery.watts': {'value': 0, 'unit': 'W', 'source': '/sys/battery', 'state': 'available'},
|
||||
'thermal.<script>alert(1)</script>': {'value': None, 'unit': 'C', 'source': '/sys/temp', 'state': power.UNAVAILABLE}},
|
||||
'unavailableGroups': ['proximity']}
|
||||
|
||||
|
||||
class Links(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.links = {}
|
||||
self.scripts = 0
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
attrs = dict(attrs)
|
||||
if tag == 'script':
|
||||
self.scripts += 1
|
||||
if tag == 'a':
|
||||
self.links[attrs['download']] = base64.b64decode(attrs['href'].split(',', 1)[1]).decode()
|
||||
|
||||
|
||||
class Report(unittest.TestCase):
|
||||
def collect(self, program, count=1):
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
return power.stream_report([sys.executable, '-c', program], '', count, 1)
|
||||
|
||||
def test_csv_metadata_headset_alias_is_plain_text_in_spreadsheets(self):
|
||||
for prefix in ('=', '+', '-', '@', ' =', '\t='):
|
||||
with self.subTest(prefix=prefix):
|
||||
alias = prefix + 'HYPERLINK("https://example.invalid")'
|
||||
report = {'headset': {'alias': alias}, 'requestedSamples': 12,
|
||||
'stopped': 'Stopped', 'samples': []}
|
||||
rows = list(csv.reader(io.StringIO(power.csv_samples(report))))
|
||||
self.assertEqual(len(rows), 4)
|
||||
self.assertEqual([row[5] for row in rows[1:]], ["'" + alias] * 3)
|
||||
report['headset']['alias'] = 'living-room-frame'
|
||||
rows = list(csv.reader(io.StringIO(power.csv_samples(report))))
|
||||
self.assertEqual([row[5] for row in rows[1:]], ['living-room-frame'] * 3)
|
||||
|
||||
def test_completed_stream_preserves_zero_unknown_units_and_downloads(self):
|
||||
event = json.dumps({'sample': SAMPLE})
|
||||
program = f'import sys; sys.stdin.read(); print({event!r}); print(\'{{"finished": {{"stopped": null}}}}\')'
|
||||
report = self.collect(program)
|
||||
self.assertIsNone(report['stopped'])
|
||||
self.assertEqual(report['samples'], [SAMPLE])
|
||||
markup = page.render(report, power.csv_samples(report))
|
||||
parser = Links()
|
||||
parser.feed(markup)
|
||||
self.assertEqual(parser.scripts, 0)
|
||||
self.assertIn('<script>', markup)
|
||||
self.assertIn('<td>0</td>', markup)
|
||||
self.assertIn('<td>Unknown</td>', markup)
|
||||
self.assertIn('<td>C</td>', markup)
|
||||
self.assertIn('scope="row"', markup)
|
||||
self.assertEqual(json.loads(parser.links['frame-power.json']), report)
|
||||
self.assertEqual(parser.links['frame-power.csv'], power.csv_samples(report))
|
||||
self.assertIn(power.UNAVAILABLE, parser.links['frame-power.csv'])
|
||||
|
||||
def test_transport_failure_retains_received_samples_as_incomplete(self):
|
||||
event = json.dumps({'sample': SAMPLE})
|
||||
report = self.collect(f'import sys; sys.stdin.read(); print({event!r}); sys.exit(255)', 2)
|
||||
self.assertEqual(report['samples'], [SAMPLE])
|
||||
self.assertIn('SSH exit 255', report['stopped'])
|
||||
|
||||
def test_lock_stop_retains_samples(self):
|
||||
event = json.dumps({'sample': SAMPLE})
|
||||
end = json.dumps({'finished': {'stopped': 'Smoke session active'}})
|
||||
report = self.collect(f'import sys; sys.stdin.read(); print({event!r}); print({end!r}); sys.exit(75)', 2)
|
||||
self.assertEqual(report['stopped'], 'Smoke session active')
|
||||
self.assertEqual(len(report['samples']), 1)
|
||||
|
||||
def test_user_stop_terminates_only_local_child_and_retains_samples(self):
|
||||
children = []
|
||||
real_popen = subprocess.Popen
|
||||
def spawn(*args, **kwargs):
|
||||
proc = real_popen(*args, **kwargs)
|
||||
children.append(proc)
|
||||
return proc
|
||||
with mock.patch.object(power.subprocess, 'Popen', side_effect=spawn), \
|
||||
mock.patch.object(power.queue.Queue, 'get', side_effect=[json.dumps({'sample': SAMPLE}), KeyboardInterrupt]):
|
||||
report = self.collect('import sys,time; sys.stdin.read(); time.sleep(30)', 2)
|
||||
self.assertEqual(report['samples'], [SAMPLE])
|
||||
self.assertIn('Stopped by user', report['stopped'])
|
||||
self.assertIsNotNone(children[0].poll())
|
||||
|
||||
def test_canceled_launch_does_not_open_ssh(self):
|
||||
import threading
|
||||
stop = threading.Event()
|
||||
stop.set()
|
||||
with mock.patch.object(power.subprocess, 'Popen') as spawn:
|
||||
report = power.stream_report(['ssh', 'frame'], '', 12, 5, stop_event=stop)
|
||||
spawn.assert_not_called()
|
||||
self.assertIn('Stopped before collection', report['stopped'])
|
||||
|
||||
def test_stop_interrupts_child_that_never_reads_payload(self):
|
||||
import threading
|
||||
stop = threading.Event()
|
||||
timer = threading.Timer(0.1, stop.set)
|
||||
timer.start()
|
||||
try:
|
||||
report = power.stream_report([sys.executable, '-c', 'import time; time.sleep(30)'],
|
||||
'x' * 1048576, 1, 1, stop_event=stop)
|
||||
finally:
|
||||
timer.cancel()
|
||||
self.assertIn('Stopped', report['stopped'])
|
||||
self.assertEqual(report['samples'], [])
|
||||
|
||||
def test_invalid_stream_is_not_completed(self):
|
||||
report = self.collect('import sys; sys.stdin.read(); print("not json")')
|
||||
self.assertIn('interrupted', report['stopped'])
|
||||
self.assertEqual(report['samples'], [])
|
||||
|
||||
def test_deadline_terminates_child(self):
|
||||
with mock.patch.object(power.time, 'monotonic', side_effect=[0, 40]):
|
||||
report = self.collect('import sys,time; sys.stdin.read(); time.sleep(30)')
|
||||
self.assertIn('Timed out', report['stopped'])
|
||||
|
||||
def test_export_bounds_and_existing_file_fail_before_ssh(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
existing = Path(temp) / 'report.html'
|
||||
existing.write_text('keep')
|
||||
for args in (['--html', str(existing)], ['--html', 'new.html', '--count', '11', '--interval', '60'],
|
||||
['--html', 'new.html', '--csv']):
|
||||
with mock.patch.object(power.subprocess, 'Popen') as spawn, contextlib.redirect_stderr(io.StringIO()):
|
||||
with self.assertRaises(SystemExit):
|
||||
power.main(args)
|
||||
spawn.assert_not_called()
|
||||
self.assertEqual(existing.read_text(), 'keep')
|
||||
|
||||
def test_cli_writes_incomplete_report_and_returns_nonzero(self):
|
||||
report = {'schemaVersion': 1, 'requestedSamples': 2, 'intervalSeconds': 1,
|
||||
'stopped': 'Stopped by user', 'samples': [SAMPLE], 'summary': power.summarize([SAMPLE])}
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / 'report.html'
|
||||
with mock.patch.object(power, 'stream_report', return_value=report), \
|
||||
mock.patch.object(power, 'LOCK', Path(temp) / 'no-lock'), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(power.main(['--html', str(path)]), 75)
|
||||
self.assertIn('Stopped by user', path.read_text())
|
||||
|
||||
def test_collect_emits_each_sample_before_completion(self):
|
||||
status = mock.Mock()
|
||||
with mock.patch.object(power, 'snapshot', return_value=(SAMPLE.copy(), None)):
|
||||
events = []
|
||||
report = power.collect(status, 1, 1, events.append)
|
||||
self.assertEqual(events, [{'sample': report['samples'][0]}])
|
||||
|
||||
def test_failed_save_preserves_json_on_stdout(self):
|
||||
report = {'schemaVersion': 1, 'requestedSamples': 1, 'intervalSeconds': 1,
|
||||
'stopped': None, 'samples': [SAMPLE], 'summary': power.summarize([SAMPLE])}
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(power, 'stream_report', return_value=report), \
|
||||
mock.patch.object(power, 'LOCK', Path(temp) / 'no-lock'), \
|
||||
contextlib.redirect_stderr(io.StringIO()), contextlib.redirect_stdout(output):
|
||||
code = power.main(['--html', str(Path(temp) / 'missing' / 'report.html')])
|
||||
self.assertEqual(code, 1)
|
||||
self.assertEqual(json.loads(output.getvalue()), report)
|
||||
|
||||
def test_premature_completion_is_incomplete_even_with_clean_ssh_exit(self):
|
||||
report = self.collect('import sys; sys.stdin.read(); print(\'{"finished": {"stopped": null}}\')')
|
||||
self.assertIn('Incomplete', report['stopped'])
|
||||
self.assertEqual(report['samples'], [])
|
||||
+4
-5
@@ -30,9 +30,8 @@ ARCHIVE = re.compile(r'^(saves|settings|android)/[A-Za-z0-9._-]{1,200}\.tar\.gz$
|
||||
PART = re.compile(r'^(saves:(\d{1,10}|linux)|settings:(steam|system)|android:[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+)$')
|
||||
KINDS = ('saves', 'settings', 'android')
|
||||
FORMAT = 1
|
||||
# The switch Valve's own devkit tools use (frame/devkit-utils/steamos-delete). Nothing else
|
||||
# is ever run as a session command.
|
||||
SELECTORS = ('holo-session-select', 'steamos-session-select')
|
||||
# Fixed SteamOS switches only; never execute a command supplied by remote status.
|
||||
SELECTORS = ('steamosctl', 'holo-session-select', 'steamos-session-select')
|
||||
REASONS = {
|
||||
'not_installed': 'Install the game first, then restore its saves',
|
||||
'no_prefix': 'Start the game once so Proton sets up its folders, then restore',
|
||||
@@ -344,10 +343,10 @@ def session_status():
|
||||
|
||||
|
||||
def session_command(selector):
|
||||
"""The switch to Game Mode, exactly as Valve's devkit tools run it."""
|
||||
"""Allow only fixed Game Mode commands, including older devkit selectors."""
|
||||
if selector not in SELECTORS:
|
||||
raise BackupError('this SteamOS build has no session switch command; use the steps in the guide')
|
||||
return shlex.join([selector, 'gamescope'])
|
||||
return shlex.join([selector, 'switch-to-game-mode' if selector == 'steamosctl' else 'gamescope'])
|
||||
|
||||
|
||||
def return_to_game_mode(confirmed=False):
|
||||
|
||||
@@ -373,15 +373,40 @@ def restore(part, stamp, source):
|
||||
# The Frame normally runs gamescope (Game Mode) with Plasma nested inside it as one panel
|
||||
# (steamos-nested-desktop). A full Desktop Mode session is SDDM autologin into Plasma
|
||||
# instead. Valve's own devkit tools (frame/devkit-utils: steamos-get-status, steamos-delete)
|
||||
# read the session from these files and switch with holo-session-select or
|
||||
# steamos-session-select, so that's what this reads and suggests.
|
||||
# read the login preference from these files. It is not the running session.
|
||||
|
||||
SDDM_CONFS = ('/etc/sddm.conf.d/zz-steamos-autologin.conf', '/etc/sddm.conf.d/steamos.conf')
|
||||
SESSION_TARGETS = ('gamescope-session.target', 'plasma-workspace-wayland.target')
|
||||
|
||||
|
||||
def _session_target(unit):
|
||||
"""Read the main user's manager, not a nested Plasma session bus over SSH."""
|
||||
runtime = '/run/user/%d' % os.getuid()
|
||||
env = dict(os.environ, XDG_RUNTIME_DIR=runtime,
|
||||
DBUS_SESSION_BUS_ADDRESS='unix:path=%s/bus' % runtime)
|
||||
try:
|
||||
result = subprocess.run(['systemctl', '--user', 'show', unit,
|
||||
'--property=LoadState', '--property=ActiveState'],
|
||||
capture_output=True, text=True, timeout=5, env=env)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return 'unknown'
|
||||
if result.returncode:
|
||||
return 'unknown'
|
||||
properties = dict(line.split('=', 1) for line in result.stdout.splitlines() if '=' in line)
|
||||
if properties.get('LoadState') == 'not-found':
|
||||
return 'not-found'
|
||||
if properties.get('LoadState') == 'loaded':
|
||||
return properties.get('ActiveState', 'unknown')
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def _running(name, full=False):
|
||||
# This module is sent as `python3 -c <source>`: unanchored pgrep -f would
|
||||
# match the script names inside our own source, even when no script runs.
|
||||
pattern = (r'^([^ ]*/)?((ba|da|z)?sh +([^ ]*/)?)?%s( |$)' % re.escape(name)
|
||||
if full else name)
|
||||
try:
|
||||
return subprocess.call(['pgrep', '-f' if full else '-x', name], stdout=subprocess.DEVNULL,
|
||||
return subprocess.call(['pgrep', '-f' if full else '-x', pattern], stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL) == 0
|
||||
except OSError:
|
||||
return False
|
||||
@@ -397,21 +422,32 @@ def session():
|
||||
m = re.search(r'^\s*Session\s*=\s*(\S+)', text, re.M)
|
||||
conf, configured = path, (m.group(1) if m else None)
|
||||
break
|
||||
selector = next((s for s in ('holo-session-select', 'steamos-session-select') if shutil.which(s)), None)
|
||||
selector = next((s for s in ('steamosctl', 'holo-session-select', 'steamos-session-select') if shutil.which(s)), None)
|
||||
# On the Frame gamescope's process is named gamescope-wl (seen on build 20261015.6141207),
|
||||
# under the start-gamescope-session script.
|
||||
gamescope = (_running('gamescope') or _running('gamescope-wl') or
|
||||
_running('start-gamescope-session', full=True))
|
||||
plasma = _running('plasmashell')
|
||||
nested = _running('steamos-nested-desktop', full=True) # a script: its process name is the shell's
|
||||
if gamescope:
|
||||
targets = {unit: _session_target(unit) for unit in SESSION_TARGETS}
|
||||
game, desktop = (targets[unit] for unit in SESSION_TARGETS)
|
||||
mode = 'unknown'
|
||||
source = 'session-targets'
|
||||
if (game, desktop) == ('active', 'inactive'):
|
||||
mode = 'game'
|
||||
elif plasma or (configured and 'plasma' in configured):
|
||||
elif (game, desktop) == ('inactive', 'active'):
|
||||
mode = 'desktop'
|
||||
else:
|
||||
mode = 'unknown'
|
||||
elif game == desktop == 'not-found':
|
||||
# Older images have neither target. A compositor plus Plasma is ambiguous:
|
||||
# Desktop Mode's steamvr-plasma.service also runs gamescope and nested scripts.
|
||||
source = 'legacy-processes'
|
||||
if gamescope and not plasma and not nested:
|
||||
mode = 'game'
|
||||
elif plasma and not gamescope and not nested:
|
||||
mode = 'desktop'
|
||||
return {'mode': mode, 'gamescope': gamescope, 'plasma': plasma, 'nested_desktop': nested,
|
||||
'configured': configured, 'config_file': conf, 'selector': selector}
|
||||
'configured': configured, 'config_file': conf, 'selector': selector,
|
||||
'session_targets': targets, 'mode_source': source}
|
||||
|
||||
|
||||
def main(argv):
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""One desktop observation using the read-only sampler; reports stay in memory."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
# Shared with the source-checkout CLI; shipped as explicit desktop resources.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
|
||||
try:
|
||||
import frame_power_sample as sampler
|
||||
from frame_power_report import render
|
||||
except ModuleNotFoundError as exc:
|
||||
# Phone/headset bundles host the same UI without desktop-only resources.
|
||||
if exc.name not in ('frame_power_sample', 'frame_power_report'):
|
||||
raise
|
||||
sampler = render = None
|
||||
|
||||
PRESETS = {'minute': (12, 5), 'five-minutes': (60, 5)}
|
||||
|
||||
|
||||
class Observation:
|
||||
def __init__(self):
|
||||
self.lock = threading.RLock()
|
||||
self.report = None
|
||||
self.running = False
|
||||
self.thread = None
|
||||
self.cancel = None
|
||||
self.reason = None
|
||||
self.id = None
|
||||
self.started = None
|
||||
|
||||
def status(self):
|
||||
with self.lock:
|
||||
return {'id': self.id, 'running': self.running, 'received': len(self.report['samples']) if self.report else 0,
|
||||
'requested': self.report['requestedSamples'] if self.report else 0,
|
||||
'headset': self.report['headset'] if self.report else None,
|
||||
'started': self.started, 'stopped': self.report['stopped'] if self.report else None}
|
||||
|
||||
def start(self, preset, route, headset):
|
||||
if sampler is None:
|
||||
raise ValueError('Desktop power-report resources are missing; reinstall the desktop app')
|
||||
if preset not in PRESETS:
|
||||
raise ValueError('Choose a one-minute or five-minute observation')
|
||||
with self.lock:
|
||||
if self.running:
|
||||
raise ValueError('An observation is already running')
|
||||
if sampler.LOCK.exists():
|
||||
raise ValueError('Smoke session active (/tmp/frame-test.lock); observation not started')
|
||||
count, interval = PRESETS[preset]
|
||||
self.id = uuid.uuid4().hex
|
||||
self.cancel = threading.Event()
|
||||
self.reason = None
|
||||
self.started = time.time()
|
||||
self.report = {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
|
||||
'headset': headset, 'stopped': 'Observation in progress; received samples only',
|
||||
'samples': [], 'summary': {}}
|
||||
self.running = True
|
||||
# Pin the route before the caller releases the server's route lock.
|
||||
command = [*route[:-1], '-T', '-o', 'StrictHostKeyChecking=yes',
|
||||
'-o', 'ServerAliveInterval=5', '-o', 'ServerAliveCountMax=2', route[-1],
|
||||
'python3', '-', '--probe', '--count', str(count), '--interval', str(interval)]
|
||||
self.thread = threading.Thread(target=self._collect, args=(command, count, interval), daemon=True)
|
||||
self.thread.start()
|
||||
return self.status()
|
||||
|
||||
def _collect(self, command, count, interval):
|
||||
def received(sample):
|
||||
with self.lock:
|
||||
self.report['samples'].append(sample)
|
||||
try:
|
||||
result = sampler.stream_report(command, sampler.remote_payload(), count, interval,
|
||||
self.cancel, received, discard_errors=True)
|
||||
with self.lock:
|
||||
result['headset'] = self.report['headset']
|
||||
result['stopped'] = self.reason or result['stopped']
|
||||
self.report = result
|
||||
except Exception as exc:
|
||||
with self.lock:
|
||||
self.report['stopped'] = self.reason or f'Observation interrupted: {exc}'
|
||||
finally:
|
||||
with self.lock:
|
||||
self.running = False
|
||||
|
||||
def stop(self, reason='Stopped by user; received samples retained'):
|
||||
with self.lock:
|
||||
if self.running:
|
||||
self.reason = reason
|
||||
self.report['stopped'] = reason
|
||||
self.cancel.set()
|
||||
return self.status()
|
||||
|
||||
def export(self, observation_id, format):
|
||||
if format not in ('html', 'json', 'csv'):
|
||||
raise ValueError('Unknown report format')
|
||||
with self.lock:
|
||||
if not self.report or observation_id != self.id:
|
||||
raise ValueError('This report was replaced; refresh the observation')
|
||||
# Independent snapshot, including samples already received while running.
|
||||
report = json.loads(json.dumps(self.report, allow_nan=False))
|
||||
report['summary'] = sampler.summarize(report['samples'])
|
||||
if format == 'json':
|
||||
return json.dumps(report, allow_nan=False, indent=2), 'application/json'
|
||||
csv = sampler.csv_samples(report)
|
||||
return (render(report, csv), 'text/html; charset=utf-8') if format == 'html' else (csv, 'text/csv; charset=utf-8')
|
||||
|
||||
def shutdown(self):
|
||||
self.stop('App closed; received samples retained')
|
||||
if self.thread:
|
||||
self.thread.join(timeout=8)
|
||||
+76
-4
@@ -1430,8 +1430,22 @@
|
||||
|
||||
<div class="page" data-page="tools">
|
||||
<div class="workspace-heading"><div><a href="#tools" id="toolBack" class="workspace-link" hidden>← All tools</a><h1 id="toolTitle">Tools</h1><p id="toolSubtitle">Choose a task for your Frame.</p></div></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power & heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div class="grid-3" id="toolWorkspace">
|
||||
<section class="panel" id="powerReport">
|
||||
<h2>Power & heat report</h2>
|
||||
<p class="sub">Observe battery, CPU, temperatures and available sensors without changing settings. One observation at a time, every five seconds, for at most five minutes.</p>
|
||||
<label for="powerPreset">Observation length</label>
|
||||
<select id="powerPreset"><option value="minute">1 minute · 12 samples</option><option value="five-minutes">5 minutes · 60 samples</option></select>
|
||||
<div class="row"><button class="action" id="powerStart">Start observation</button><button id="powerStop" disabled>Stop and keep samples</button></div>
|
||||
<progress id="powerProgress" max="12" value="0" aria-label="Samples received" hidden></progress>
|
||||
<p id="powerStatus" role="status" aria-live="polite">No observation yet.</p>
|
||||
<div class="row"><button data-power-format="html" disabled>Download HTML report</button><button data-power-format="json" disabled>Download JSON</button><button data-power-format="csv" disabled>Download CSV</button></div>
|
||||
<p id="powerSaveStatus" role="status" aria-live="polite"></p>
|
||||
<p class="hint">Download before starting again or quitting: only the latest observation is kept in memory. Downloads during collection are incomplete snapshots. Stop, disconnect or switching headsets keeps received samples, labeled with the original headset. A smoke session blocks sensor reads.</p>
|
||||
<p class="hint">Open the saved HTML in your browser; it works offline and includes JSON/CSV downloads. Unknown readings are never treated as zero. Rails overlap; do not add them. Sensor accuracy is unverified. Reports include sensor paths and OS builds: review before sharing.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="vrTools">
|
||||
<div class="shelf-head"><h2>VR comfort and performance</h2><span class="spacer"></span><span class="sub" id="vrUpdated">Waiting for the Frame</span></div>
|
||||
<div class="stats" id="vrMetrics"></div>
|
||||
@@ -2158,7 +2172,7 @@ const savesToDevice = () => !!(window.frameApp && window.frameApp.saveImages);
|
||||
// then are about the old one, so they're dropped rather than shown.
|
||||
let devGen = 0;
|
||||
// Answers that don't depend on the headset (this computer's data, the catalogue, jobs).
|
||||
const HEADSET_FREE = /^\/api\/(devices|connection|host|job|titles\/job|webinstall\/job|apk-versions|android\/(catalog|reports)|steam\/search)\b/;
|
||||
const HEADSET_FREE = /^\/api\/(power|devices|connection|host|job|titles\/job|webinstall\/job|apk-versions|android\/(catalog|reports)|steam\/search)\b/;
|
||||
async function api(path, body, device = window.connDevice) {
|
||||
const gen = devGen;
|
||||
const checkGeneration = () => {
|
||||
@@ -5454,6 +5468,63 @@ $("panelList").onclick = e => {
|
||||
act("Show panel", () => api("/api/panels", {action: "focus", key: b.dataset.panel}), b);
|
||||
};
|
||||
|
||||
// ---- Read-only power observation ----
|
||||
let powerState = null, powerTimer = null, powerSeq = 0;
|
||||
function renderPower(s) {
|
||||
powerState = s;
|
||||
$('powerStart').disabled = !!s.running;
|
||||
$('powerPreset').disabled = !!s.running;
|
||||
$('powerStop').disabled = !s.running;
|
||||
$('powerProgress').max = s.requested || 1;
|
||||
$('powerProgress').value = s.received;
|
||||
$('powerProgress').hidden = !s.id;
|
||||
const identity = s.headset ? `Headset: ${s.headset.alias}. ` : '';
|
||||
$('powerStatus').textContent = s.id ? identity + `${s.received} of ${s.requested} samples received. ` +
|
||||
(s.running ? (s.stopped?.startsWith('Stopped') ? 'Stopping…' : 'Observing…') : s.stopped ? `Incomplete: ${s.stopped}` : 'Completed.') : 'No observation yet.';
|
||||
document.querySelectorAll('[data-power-format]').forEach(b => b.disabled = !s.id);
|
||||
}
|
||||
async function loadPower() {
|
||||
const seq = ++powerSeq;
|
||||
clearTimeout(powerTimer);
|
||||
try {
|
||||
const s = await api('/api/power');
|
||||
if (seq !== powerSeq) return;
|
||||
renderPower(s);
|
||||
if (s.running && location.hash === '#powerReport') powerTimer = setTimeout(loadPower, 1000);
|
||||
} catch (e) {
|
||||
if (seq !== powerSeq) return;
|
||||
$('powerStatus').textContent = `Observation status unavailable: ${e.message}. Received samples remain on the local server while it is running.`;
|
||||
if (location.hash === '#powerReport') powerTimer = setTimeout(loadPower, 3000);
|
||||
}
|
||||
}
|
||||
$('powerStart').onclick = async () => {
|
||||
$('powerStart').disabled = true;
|
||||
try {
|
||||
renderPower(await api('/api/power', {action:'start', preset:$('powerPreset').value}));
|
||||
loadPower();
|
||||
} catch (e) { $('powerStatus').textContent = e.message; $('powerStart').disabled = !!powerState?.running; }
|
||||
};
|
||||
$('powerStop').onclick = async () => {
|
||||
if (!powerState?.id) return;
|
||||
$('powerStop').disabled = true;
|
||||
try { renderPower(await api('/api/power', {action:'stop', id:powerState.id})); loadPower(); }
|
||||
catch (e) { $('powerStatus').textContent = e.message; $('powerStop').disabled = false; }
|
||||
};
|
||||
document.querySelectorAll('[data-power-format]').forEach(b => b.onclick = async () => {
|
||||
if (!powerState?.id) return;
|
||||
const id = powerState.id, format = b.dataset.powerFormat;
|
||||
b.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`/api/power/report?id=${encodeURIComponent(id)}&format=${format}`, {headers:{'X-Frame-UI':UI_KEY}});
|
||||
if (!r.ok) throw new Error((await r.json()).error || `HTTP ${r.status}`);
|
||||
const blob = await r.blob(), a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob); a.download = `frame-power-${id}.${format}`; a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
$('powerSaveStatus').textContent = 'Download requested. Open the saved HTML in your browser to view it offline; it includes JSON and CSV downloads.';
|
||||
} catch (e) { $('powerSaveStatus').textContent = `Download failed: ${e.message}`; }
|
||||
finally { b.disabled = !powerState?.id; }
|
||||
});
|
||||
|
||||
// ---- Read-only game storage ----
|
||||
let storageSeq = 0;
|
||||
const storageState = s => ({'permission-denied':'Permission denied', missing:'Missing or disconnected',
|
||||
@@ -5761,10 +5832,10 @@ const PAGES = ["apps", "games", "tools", "settings"];
|
||||
const SECONDARY_PAGES = ["home", "capture", "devices", "control"];
|
||||
const SECTION_PAGE = {library:"games", discover:"apps", android:"apps", view:"capture", shots:"capture", device:"home",
|
||||
vrTools:"tools", comfort:"tools", assistant:"tools", pad:"tools", sideloaded:"games", getgames:"games", steamStore:"games", androidLibrary:"apps", appStore:"apps", linuxApps:"apps",
|
||||
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", storage:"tools", backup:"tools"};
|
||||
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", storage:"tools", powerReport:"tools", backup:"tools"};
|
||||
const TOOL_TITLES = {transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork", autoSync:"Copy to this computer",
|
||||
pad:"Type on the headset", panels:"Panel switcher", vrTools:"VR tools", comfort:"Family & comfort", androidSettings:"App display & reports",
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", backup:"Backup & recovery"};
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", powerReport:"Power & heat report", backup:"Backup & recovery"};
|
||||
const workspace = {source:"all", scope:"installed", apps:"installed", scroll:new Map(), key:null, hash:null, y:0};
|
||||
let page = "apps";
|
||||
// Reuse the existing live lists and form handlers in their new destinations.
|
||||
@@ -5872,6 +5943,7 @@ function showPage(e) {
|
||||
if (tool === 'panels') loadPanelSwitcher();
|
||||
if (tool === 'backup') loadBackups();
|
||||
if (tool === 'storage') loadStorage();
|
||||
if (tool === 'powerReport') loadPower();
|
||||
if (tool === 'androidSettings') {
|
||||
$('repNew').hidden = false; $('androidSettings').prepend($('repNew')); loadDisplays();
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ import frame_media # noqa: E402
|
||||
import frame_panels # noqa: E402
|
||||
import frame_quest # noqa: E402
|
||||
import frame_meta # noqa: E402
|
||||
import frame_power # noqa: E402
|
||||
import frame_report # noqa: E402
|
||||
import frame_store # noqa: E402
|
||||
import frame_telemetry # noqa: E402
|
||||
@@ -109,6 +110,7 @@ HOST_OPTS = []
|
||||
frame_android.SSH_OPTS = SSH[1:]
|
||||
|
||||
|
||||
_power = frame_power.Observation()
|
||||
_route_lock = threading.Lock()
|
||||
|
||||
|
||||
@@ -120,6 +122,7 @@ def route(alias, host_opts):
|
||||
with _route_lock:
|
||||
moved = alias != FRAME
|
||||
if moved:
|
||||
_power.stop('Headset switched; received samples retained for the previous headset')
|
||||
frame_catalog._env.clear() # the SteamOS and Lepton builds reports record are per headset
|
||||
# Invalidate launches before publishing the new route. send/stop share
|
||||
# a writer lock, so an old process cannot receive events after this.
|
||||
@@ -3009,7 +3012,28 @@ def source_manage(body):
|
||||
raise Failure(str(e), 400)
|
||||
|
||||
|
||||
def power_action(body):
|
||||
action = body.get('action')
|
||||
if LOCAL:
|
||||
raise Failure('Power observations are available on the desktop app', 400)
|
||||
if action == 'stop':
|
||||
# ID prevents an old page stopping a replacement observation.
|
||||
with _power.lock:
|
||||
if body.get('id') != _power.id:
|
||||
raise Failure('This observation was replaced; refresh', 409)
|
||||
return _power.stop()
|
||||
if action != 'start':
|
||||
raise Failure('Unknown observation action', 400)
|
||||
with _route_lock:
|
||||
headset = {'alias': FRAME, 'deviceId': LINK.active_device()['id'] if LINK else None}
|
||||
try:
|
||||
return _power.start(body.get('preset'), tuple([*SSH, FRAME]), headset)
|
||||
except ValueError as exc:
|
||||
raise Failure(str(exc), 409) from exc
|
||||
|
||||
|
||||
POST = {
|
||||
"/api/power": power_action,
|
||||
"/api/android/backup": android_backup, "/api/backup": backup_action,
|
||||
"/api/vr": vr,
|
||||
"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call,
|
||||
@@ -3193,6 +3217,18 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif path == "/store":
|
||||
# The Android store as a headset panel (scripts/store-on-frame.sh).
|
||||
self.send_bytes((HERE / "store.html").read_bytes(), "text/html; charset=utf-8")
|
||||
elif path == "/api/power":
|
||||
self.send_json(_power.status())
|
||||
elif path == "/api/power/report":
|
||||
args = parse_qs(url.query)
|
||||
try:
|
||||
format = args.get('format', ['html'])[0]
|
||||
report_id = args.get('id', [''])[0]
|
||||
data, mime = _power.export(report_id, format)
|
||||
except ValueError as exc:
|
||||
raise Failure(str(exc), 409) from exc
|
||||
self.send_bytes(data.encode('utf-8'), mime,
|
||||
headers=[('Content-Disposition', f'attachment; filename="frame-power-{report_id}.{format}"')])
|
||||
elif path == "/api/settings/artwork":
|
||||
self.send_json(frame_steamgriddb.settings())
|
||||
elif path == "/artwork-settings.js":
|
||||
@@ -3368,6 +3404,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
if not isinstance(body, dict):
|
||||
raise Failure("request body must be a JSON object", 400)
|
||||
if path == '/api/power' and body.get('action') == 'start' and LINK and not meant:
|
||||
raise Failure('Observation start needs the intended X-Frame-Device headset', 400)
|
||||
# Not headset work: switching headsets mustn't wait for (or refuse) these.
|
||||
with (contextlib.nullcontext() if path in NOT_HEADSET_WORK else working(meant)):
|
||||
result = handler(body)
|
||||
@@ -3641,6 +3679,7 @@ def main():
|
||||
# mid-cleanup would abort it and leave the SSH master running.
|
||||
if not frame_host.WINDOWS:
|
||||
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||
_power.shutdown()
|
||||
_stop_sync.set()
|
||||
webinstall_shutdown()
|
||||
macview.shutdown() # close the headset's viewers before the agent goes
|
||||
|
||||
Reference in new issue
Block a user