Compare commits

...
Author SHA1 Message Date
SAPHID 9542ac1bac Bump Frame Control to 0.4.16 2026-10-24 07:35:07 +00:00
Alex SouthwellandSAPHID 17f6d52717 Document real Steam clip export verification (#147)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 07:34:34 +00:00
Alex SouthwellandSAPHID b4e80cab0a Escape power report CSV metadata for spreadsheets (#146)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 07:28:26 +00:00
Alex SouthwellandSAPHID b98e3e496f Bump Frame Control to 0.4.15 (#145)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 07:08:23 +00:00
Alex SouthwellandSAPHID a148e02d39 Add bounded read-only power and heat reports to desktop Tools (#144)
* Add bounded desktop power and heat observations with offline downloads

* Fix desktop power tests on Python 3.9

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 06:58:19 +00:00
Alex SouthwellandSAPHID 0fb9174c23 Fix Return to Game Mode detection in full Plasma (#143)
* Fix Frame session detection for full Plasma Desktop Mode

* Document real Frame Desktop Mode return verification

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 06:46:17 +00:00
20 changed files with 990 additions and 103 deletions

No files matched your search

+14
View File
@@ -2,6 +2,20 @@
## 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,
+4
View File
@@ -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",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.14",
"version": "0.4.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.14",
"version": "0.4.16",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+4 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.14",
"version": "0.4.16",
"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
View File
@@ -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."
]
}
+50 -3
View File
@@ -46,11 +46,10 @@ 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 nine seconds of local child/reader cleanup. The default
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`. This source-checkout flow is not a desktop Tools panel
and is not added to the packaged app.
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
@@ -67,6 +66,47 @@ 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
@@ -175,3 +215,10 @@ 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).
+28 -16
View File
@@ -8,13 +8,13 @@ Both are on **Tools → Capture & Live**. Everything is saved to
Nothing needs installing on the computer or on the Frame: the work is done by
the `ffmpeg` that SteamOS ships.
**Recording is tested on a headset; Steam clip export isn't yet.** On a Frame
**Recording and Steam clip export have each been tested on a headset.** On a Frame
running SteamOS 0.5.6 (build 20261015.6141207), Record and Stop saved an
8-second 1080p H.264 recording with AAC sound to the computer, Stop took about a
second, and nothing was left running or stored on the Frame. That Frame had no
Steam recordings, so the clip list came back empty and export has only been
tested with files in Steam's format made by `ffmpeg`. The parts we're least sure
of are listed under [What hasn't been checked](#what-hasnt-been-checked).
second, and nothing was left running or stored on the Frame. A later test on
that build created a saved clip with Steam's own recorder and exported it with
Frame Control's production export function. The parts we're least sure of are
listed under [What hasn't been checked](#what-hasnt-been-checked).
## Record
@@ -147,19 +147,31 @@ stand-in `ffmpeg`, so Stop, the time limit and the clean-up are tested as they
run on the Frame. Where `ffmpeg` is installed, they also make a 65-second DASH
recording with Steam's file names and export it with the real `ffmpeg`.
## What hasn't been checked
## Real Steam clip verification and remaining checks
The 2026-10-24 read-only clip list on build `20261015.6141207` again returned
zero clips: its `gamerecordings` directory held only `gamerecording.pb`. No
Steam recording was created during that check, so a real Steam clip export is
still unverified. The recording and Stop path were verified earlier on that
build, as described above. Remaining checks:
On 2026-10-24, v0.4.15 on build `20261015.6141207` recorded a ten-second
reserved test session through Steam's own game recorder, saved it as a clip,
then exported it with `frame_clips.export_clip`, the same implementation used
by the desktop API. The 159,829-byte MP4 decoded completely: 10.29 seconds,
1920×1080 H.264 video and stereo AAC audio (SHA-256
`f8a476eefb2bde7ba5cb464632ec9b952eea3ab1610b5bed2098bf1f11d62cf1`).
All 616 decoded video frames and the decoded audio matched Steam's source
segments. Recording mode was restored
to off; the reserved title and its processes were removed, the shared test lock
was released, and the battery remained at 99%. The Steam source clip was kept.
- Audio and video sync. Both inputs are stamped with the wall clock, because
the camera device and the audio server otherwise count from different clocks.
- The exact layout of `gamerecordings` on the Frame's SteamOS build, and
whether a clip's segments start mid-recording. The parser is deliberately
loose about the layout.
The inspected frame is nearly black in both Steam's source and the export, so
this establishes file creation and faithful export, **not visible gameplay
capture**. The literal desktop **Export** button was not clicked. Remaining
checks include:
- Perceptual audio/video sync and audible content. Frame Control's separate
**Record** path stamps camera and audio inputs with the wall clock because
they otherwise count from different clocks.
- Long saved clips, trims starting partway through a Steam recording, and the
desktop **Export** button. One real saved clip's
directory layout was checked; the parser remains deliberately loose for
other layouts.
- How much x264 at 1080p30 costs the Frame while a game is running.
- Automatic copying: that 30 seconds without a write is a safe sign Steam has
finished saving a clip, and how a once-a-minute listing feels on the Frame.
+1
View File
@@ -36,6 +36,7 @@ thead th,td{{white-space:nowrap}}th[scope=row]{{min-width:240px;max-width:300px;
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>
+58 -12
View File
@@ -141,18 +141,28 @@ def collect(status, count, interval, emit=None):
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()
@@ -178,31 +188,61 @@ def remote_payload():
'sys.modules["frame_status"] = status\n' + Path(__file__).read_text())
def stream_report(command, payload, count, interval):
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()
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=errors, text=True)
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL if discard_errors else errors, text=True)
def receive():
try:
for line in proc.stdout:
events.put(line)
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:
events.put(None)
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:
proc.stdin.write(payload)
proc.stdin.close()
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'
@@ -218,6 +258,8 @@ def stream_report(command, payload, count, interval):
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']
@@ -228,6 +270,7 @@ def stream_report(command, payload, count, interval):
except (OSError, ValueError, subprocess.TimeoutExpired) as exc:
stopped = f'Collection interrupted: {exc}'
finally:
reader_stop.set()
if proc.poll() is None:
proc.terminate()
try:
@@ -235,6 +278,7 @@ def stream_report(command, payload, count, interval):
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=3)
writer.join(timeout=3)
if not proc.stdin.closed:
try:
proc.stdin.close()
@@ -263,6 +307,8 @@ def main(argv=None):
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:
+77
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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'''
+256
View File
@@ -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)
+36
View File
@@ -2,6 +2,7 @@
import sandbox # noqa: F401
import base64
import contextlib
import csv
from html.parser import HTMLParser
import io
import json
@@ -43,6 +44,19 @@ class Report(unittest.TestCase):
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}}}}\')'
@@ -89,6 +103,28 @@ class Report(unittest.TestCase):
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'])
+4 -5
View File
@@ -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):
+45 -9
View File
@@ -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):
+111
View File
@@ -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
View File
@@ -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 &amp; recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display &amp; preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant &amp; 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 &amp; 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 &amp; recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display &amp; preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant &amp; 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 &amp; 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();
}
+39
View File
@@ -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