diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c2dccb..613c47b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,3 +7,10 @@ (issue #34). Connection steps links to the diagnostic guide. Repairs remain manual; restart-loop evidence stops deeper diagnostics. The real Frame verified the SSH connection and safety stop; deeper checks were skipped. +- Fixed headset smoke cleanup reporting success while a smoke-title process + could remain running after its files were removed. Cleanup now stops and + rechecks processes tied to exact reserved test-title paths, uses PID handles + for safe TERM/KILL escalation, and records stopped PIDs. Unrelated titles and + shared Wine services are left alone. Verified with local Linux process + regressions and a 13/13 real Frame smoke run that stopped five test-owned + Proton process PIDs before removing the Windows test title. diff --git a/docs/backup-and-recovery.md b/docs/backup-and-recovery.md index dc7ad26..d420c08 100644 --- a/docs/backup-and-recovery.md +++ b/docs/backup-and-recovery.md @@ -14,6 +14,13 @@ reported Game Mode, so **Return to Game Mode** changed nothing. Not tested on a headset: a restore after a real factory reset, a restore that replaces files, Android data restore, and the switch out of a real Desktop Mode session. Unit tests and the fake Frame cover those (see [Verification](#verification)). +On 2026-10-24 a read-only session check on the same build again reported Game +Mode (`gamescope=true`, `plasma=false`, `nested_desktop=false`) and the +`steamos-session-select` command was present. Opening the nested desktop for a +clipboard test did not enter full Desktop Mode. A full session switch was not +attempted because it restarts the headset session and a reliable return path +was not established; the **Return to Game Mode** branch for a real full +Desktop Mode session remains unverified. ## Back up diff --git a/docs/battery-health.md b/docs/battery-health.md new file mode 100644 index 0000000..4f80458 --- /dev/null +++ b/docs/battery-health.md @@ -0,0 +1,59 @@ +# Battery and health monitoring + +Open **Control Center → Battery & thermal health**, or expand **System +information**, to read the Frame's charge, charging state, signed battery +power, battery temperature and driver-reported health. Optional readings show +charge cycles, full charge capacity, design capacity and full / design capacity. +The existing battery chip also shows charging state and temperature. + +These are read-only readings from the Linux host's `/sys/class/power_supply`, +not Android's container battery view. The probe prefers the documented Frame +fuel gauge, `max1720x_bat_7-36`, over other battery nodes, excludes peripheral +(`scope=Device`) and absent batteries, and otherwise uses a deterministic +battery fallback. The UI shows the selected source path. + +| Reading | Linux attribute | Interpretation | +|---|---|---| +| Charge | `capacity` | Integer 0–100 percent; zero is a real reading | +| Charging state | `status` | Charging, Full, Discharging or Not charging; missing/unknown states stay unavailable | +| Battery power | `voltage_now × current_now` | Microvolts and microamps converted to signed watts; positive means charging on the Frame | +| Temperature | `temp` | Tenths of a degree Celsius, including valid negative readings | +| Battery health | `health` | Driver status such as Good or Overheat; not a wear percentage | +| Charge cycles | `cycle_count` | Driver-reported count, including zero | +| Full/design capacity | `charge_full`, `charge_full_design` | Microamp-hours converted to mAh | +| Full / design capacity | Ratio of those two capacities | Fuel-gauge estimate; not a diagnosis or a guaranteed runtime | +| Time estimate | `time_to_full_now`, `time_to_empty_now` | Seconds; displayed only for the corresponding charging/discharging state | + +Unavailable means the field is absent, unreadable or invalid. One missing +sensor does not hide the others. Zero design capacity cannot produce a ratio. Time estimates outside 1 second +to 7 days and battery temperatures outside −273.15 to 200 °C are rejected +as invalid readings; these bounds are not thermal warning thresholds. +The ratio may exceed 100% because full capacity is a gauge estimate, so the app +does not clamp it or invent a wear warning. The app does not estimate health +from charge percentage or assign temperature thresholds. Existing thermal +alerts use the kernel's own hot/critical trip points. + +A connected charger does not necessarily mean charging: the Frame can still +drain under load or sit idle near full charge. A 100% capacity reading alone +does not prove the driver reports Full. Missing status is explicitly unknown; +near-zero measured battery power with a charger is shown as not charging. +Failed, stale or disconnected status samples clear Control Center readings. + +**No charge controls are provided.** Frame's SteamOS charge-limit backend is +unsupported; see [charge-limit research](charge-limit.md). Monitoring never +writes to power supplies, pauses charging, changes current or sets a limit. + +## Verification + +`tests/test_battery.py` uses temporary sysfs-like files for conversion, +malformed/missing values, permission failures, source selection and partial +sensor support. `tests/control_center.cjs` exercises charging states, optional +metrics and failed/stale/disconnected sample handling. These tests do not +connect to a headset. On 2026-10-24, the PR's uninstalled parser was also +piped over SSH to a real Frame running SteamOS 0.5.6 (build +`20261015.6141207`). Its read-only sysfs result selected +`max1720x_bat_7-36` and reported 99% charge, Not charging, 24.9 °C, +Good driver health, 14 cycles, 2,920 mAh full and 2,700 mAh design capacity +(108.1% fuel-gauge estimate). The Control Center UI was not run against this +headset; Windows and macOS were not tested. Optional fields are not claimed to +be available on every firmware build. diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..10f7bd8 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,9 @@ +# Changelog + +## Unreleased + +- Battery monitoring now selects the Frame fuel gauge predictably, handles + missing or invalid readings, and shows optional cycle count, full/design + capacity and estimated capacity health in Control Center. Unknown charging + status stays explicit. Monitoring is read-only; charge limits remain + unsupported on Frame. The read-only parser was checked on a real Frame; the UI was not. diff --git a/docs/charge-limit.md b/docs/charge-limit.md index 1bf351b..eef5d9e 100644 --- a/docs/charge-limit.md +++ b/docs/charge-limit.md @@ -18,6 +18,9 @@ read-only checks below on a Frame running SteamOS 0.5.6 (build 20261015.6141207) `charging_enable` and `charging_pause`, both `root`-only (`-rw-r--r--`). - `deckard-charger.service` (Deckard Battery Charger service) is enabled and running. +For safe read-only readings in Control Center, see [battery and health +monitoring](battery-health.md). It does not provide charging controls. + ## How Steam does it on other devices - Steam's **Settings → Power → Battery Charge Limit** shipped to the Deck in diff --git a/docs/file-transfer.md b/docs/file-transfer.md index 8b9e206..63b937b 100644 --- a/docs/file-transfer.md +++ b/docs/file-transfer.md @@ -62,6 +62,12 @@ refused with a message, so send big things as a file. An empty clipboard, or one holding an image, says so. Neither the server nor its log ever records what was on the clipboard. -**Not tested on a headset yet.** Reading uses the same Klipper interface as -sending, which was verified on 2026-09-25, but `getClipboardContents` itself -hasn't been tried on a Frame. The fake Frame (`tests/fakeframe`) answers it. +**Verified on a Frame, 2026-10-24:** SteamOS 0.5.6, build +`20261015.6141207`. With a temporary nested desktop open, Frame Control sent +a 47-character test marker to Klipper, then **Copy the headset's clipboard** +read it through `getClipboardContents` and set a Linux PC's X11 clipboard. +Reading that PC clipboard returned the exact marker. The prior headset +clipboard was restored and the test desktop was closed. The PC side used a +temporary X display on lxso2; macOS and Windows clipboard backends, a user's +copy action in a headset app, and image/size error cases remain untested on a +real headset. The fake Frame (`tests/fakeframe`) covers the protocol cases. diff --git a/docs/recording.md b/docs/recording.md index 25d831c..963a52f 100644 --- a/docs/recording.md +++ b/docs/recording.md @@ -149,11 +149,12 @@ recording with Steam's file names and export it with the real `ffmpeg`. ## What hasn't been checked -None of this has run on a Steam Frame yet. In particular: +The 2026-10-24 read-only clip list on build `20261015.6141207` again returned +zero clips: its `gamerecordings` directory held only `gamerecording.pb`. No +Steam recording was created during that check, so a real Steam clip export is +still unverified. The recording and Stop path were verified earlier on that +build, as described above. Remaining checks: -- That SteamOS's `ffmpeg` can read `@DEFAULT_MONITOR@`. It has the PulseAudio - output device (the media player plays through it), so the input is expected, - but if it fails the recording is video only, as above. - Audio and video sync. Both inputs are stamped with the wall clock, because the camera device and the audio server otherwise count from different clocks. - The exact layout of `gamerecordings` on the Frame's SteamOS build, and diff --git a/docs/testing.md b/docs/testing.md index 6a1a0e3..03aecf8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -178,6 +178,43 @@ does). A cleanup that fails counts as a failed step. Results go to screen; it exits 0 when every step passed, 1 if one failed, 2 if the headset isn't reachable. +Smoke process cleanup runs before and after title removal and during the +initial/final leftover sweeps. It targets only the reserved `fc_smoke_arm64`, +`fc_smoke_x86_64`, `fc_smoke_exe` IDs and their legacy `fc-smoke-` equivalents. +On the target, `tests/smoke/stop_processes.py` requires the SSH user's uid and +an exact title directory in `/proc` cwd/exe or an individual absolute command +argument; similarly named folders and shell command substrings do not count. +Deleted cwd/exe paths still count, so a process left after file removal can be +found. It sends SIGTERM, waits up to three seconds, then uses SIGKILL with +another three-second bound, rescanning for survivors. PID handles prevent PID +reuse from signalling another process. Linux Python 3.9+ with pidfd support +is required; missing support, signal errors and survivors fail the +cleanup step. Stopped PIDs are recorded in the results JSON. +If the initial leftover cleanup fails, the run skips installs and pairing and +records the failure instead of overwriting an active test title. + +This deliberately does not stop shared Wine/Proton services by name or prefix, +Steam, SteamVR, process groups or arbitrary descendants. A process that drops +all title-path evidence cannot safely be attributed and is outside this +check. Unreadable `/proc` fields provide no ownership evidence; other readable +fields are still checked, and a wholly unattributable process is left alone. +Real Frame verification, 2026-10-24, SteamOS build `20261015.6141207`: the +fixed Python smoke entry point passed all 13 steps under the shared device lock. +The `fc_smoke_exe` launch ran under Proton Experimental (ARM64), and its +remove step stopped five test-owned wrapper/Proton PIDs before removing the +title; the following process sweep found none. The final cleanup found no +test titles or processes, the test desktop was closed, and the battery +remained at 99%. The `scripts/frame-smoke.sh` zsh wrapper could not run +on lxso2 because zsh is absent; `python3 tests/smoke/frame_smoke.py` ran +the same entry point instead. +The local regression suite runs real Linux processes in temporary fake title +directories (including deleted folders and a TERM-resistant wrapper); it checks +exit, escalation and survival of unrelated processes without SSH or a headset: + +```sh +python3 -m unittest discover -s tests -p test_smoke_cleanup.py -v +``` + `--pair` asks the devkit service to pair a new RSA key, which needs someone in the headset to open **Settings → Developer → Pair new host** and approve it; the key is checked and then taken out of `authorized_keys` again. @@ -371,3 +408,11 @@ unsafe archives and backup names, and the exact Game Mode command. `tests/e2e/test_recovery_kit.py` backs up and restores a Proton save on the fake Frame through the server. See [backup-and-recovery.md](backup-and-recovery.md#verification). No backup or restore has been run on a real headset yet. + +## Battery monitoring + +`tests/test_battery.py` covers real file parsing with temporary sysfs-like +files, source preference, missing/invalid sensors and permission errors. +`tests/control_center.cjs` covers charging states and optional health fields, +alongside existing stale/offline handling. No headset is used. See +[battery monitoring](battery-health.md) for sources, units and limitations. diff --git a/tests/control_center.cjs b/tests/control_center.cjs index e7565d9..80fa3f2 100644 --- a/tests/control_center.cjs +++ b/tests/control_center.cjs @@ -165,6 +165,50 @@ test('metrics use real fields, preserve zero, and distinguish unavailable values assert.equal(cc.metrics({battery:{percent:0,status:'Discharging'}}).battery,'0%'); assert.match(cc.metrics({thermalAlerts:[{zone:'battery',tempC:45,limitC:44}]}).thermal,/battery 45 °C ≥ 44 °C/); }); +test('battery health distinguishes charging states and optional sensors', () => { + for (const status of [null, 'Unknown', 'Unexpected']) { + const m = cc.metrics({battery:{percent:100,status,timeToEmpty:3600}}); + assert.equal(m.batteryDetail,'Charging state unavailable'); + assert.equal(m.estimate,'Unavailable'); + } + assert.equal(cc.metrics({battery:{status:'Not charging'}}).batteryDetail,'Not charging'); + assert.equal(cc.metrics({battery:{status:'Full'}}).batteryDetail,'Fully charged'); + assert.equal(cc.metrics({battery:{status:'Charging',timeToFull:3600}}).estimate,'1 h 0 min to full'); + assert.equal(cc.metrics({battery:{status:'Discharging',timeToEmpty:600}}).estimate,'10 min remaining'); + const m = cc.metrics({battery:{cycleCount:0,fullMah:4500,designMah:5000,capacityHealthPercent:90, + source:'/sys/class/power_supply/max1720x_bat_7-36',health:'Unknown',tempC:0}}); + assert.equal(m.cycles,'0'); assert.equal(m.capacityHealth,'90%'); + assert.equal(m.fullCapacity,'4,500 mAh'); assert.equal(m.batteryTemp,'0 °C'); + assert.equal(m.batteryHealth,'Unavailable'); assert.match(m.batterySource,/max1720x/); + for (const key of ['cycles','fullCapacity','designCapacity','capacityHealth','batterySource','batteryHealth','batteryTemp']) + assert.equal(cc.metrics(null)[key],'Unavailable'); +}); +test('battery chip preserves unknown status and does not infer Full at 100%', () => { + const f=fixture(); + const start=pageSource.indexOf('function battery(b, power)'); + const end=pageSource.indexOf('// hidePopover', start); + const context={$:f.element,dur:String}; vm.createContext(context); + vm.runInContext(pageSource.slice(start,end),context); + context.battery({percent:100,status:'Unknown'},{watts:20}); + assert.equal(f.element('battState').textContent,'Charging state unavailable'); + context.battery({percent:50,status:'Not charging'},null); + assert.equal(f.element('battState').textContent,'Not charging'); + context.battery({percent:50,status:'Charging',watts:2,tempC:0},null); + assert.match(f.element('battState').textContent,/Charging/); + assert.match(f.element('battDetail').textContent,/battery 0°C/); + context.battery(null,null); + assert.equal(f.element('battState').textContent,'Battery unknown'); +}); +test('new health readings clear after a failed status read', () => { + const f=mounted(); + f.ui.status({...sample(),battery:{...sample().battery,cycleCount:42,capacityHealthPercent:92, + fullMah:4600,designMah:5000,source:'/sys/class/power_supply/max1720x_bat_7-36'}}); + assert.equal(f.element('metric-capacityHealth').textContent,'92%'); + f.ui.unavailable(Error('Read failed')); + for (const key of ['cycles','fullCapacity','designCapacity','capacityHealth','batterySource']) + assert.equal(f.element('metric-'+key).textContent,'Unavailable'); + f.ui.destroy(); +}); test('audio controller validates before any API call and rejects offline or expired readings', async () => { let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},calls=[]; const controller=cc.createController({api:async(...args)=>{calls.push(args);return {};},getContext:()=>context}); diff --git a/tests/smoke/frame_smoke.py b/tests/smoke/frame_smoke.py index 740e048..c8abce6 100644 --- a/tests/smoke/frame_smoke.py +++ b/tests/smoke/frame_smoke.py @@ -13,11 +13,12 @@ BUILD_ID in tests/smoke/results/