From fecb226e43724089f25fead8bcbbf6696e905e73 Mon Sep 17 00:00:00 2001 From: SAPHID Date: Sat, 24 Oct 2026 02:39:25 +0000 Subject: [PATCH 1/7] Docs: record round-2 Frame verification and remaining gaps --- docs/backup-and-recovery.md | 7 +++++++ docs/file-transfer.md | 12 +++++++++--- docs/recording.md | 9 +++++---- 3 files changed, 21 insertions(+), 7 deletions(-) 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/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 From 3120a3cacf52b8e8ee9a0ce8245ed817be141e97 Mon Sep 17 00:00:00 2001 From: SAPHID Date: Sat, 24 Oct 2026 02:40:06 +0000 Subject: [PATCH 2/7] Add read-only Frame battery health and capacity monitoring --- docs/battery-health.md | 53 +++++++++++++++++++++++++ docs/changelog.md | 9 +++++ docs/charge-limit.md | 3 ++ docs/testing.md | 8 ++++ tests/control_center.cjs | 44 ++++++++++++++++++++ tests/test_battery.py | 86 ++++++++++++++++++++++++++++++++++++++++ ui/control-center.js | 18 +++++---- ui/frame_status.py | 37 +++++++++++++---- ui/index.html | 11 +++-- 9 files changed, 250 insertions(+), 19 deletions(-) create mode 100644 docs/battery-health.md create mode 100644 docs/changelog.md create mode 100644 tests/test_battery.py diff --git a/docs/battery-health.md b/docs/battery-health.md new file mode 100644 index 0000000..0ab3dc2 --- /dev/null +++ b/docs/battery-health.md @@ -0,0 +1,53 @@ +# 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. This change has not been tested on a real Frame; +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..e91c23c --- /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. No real-headset validation was performed for this change. 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/testing.md b/docs/testing.md index 3fd2660..fec28ff 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -357,3 +357,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/test_battery.py b/tests/test_battery.py new file mode 100644 index 0000000..0398b85 --- /dev/null +++ b/tests/test_battery.py @@ -0,0 +1,86 @@ +"""Read-only sysfs battery parsing; fixture values, no real headset access.""" +import sandbox # noqa: F401 +from pathlib import Path +import sys +import tempfile +import unittest +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui')) +import frame_status as status + + +class Battery(unittest.TestCase): + def probe(self, fields): + # Real files exercise missing files, whitespace and integer conversion. + with tempfile.TemporaryDirectory() as tmp: + node = Path(tmp) / 'max1720x_bat_7-36' + node.mkdir() + for name, value in dict(type='Battery', **fields).items(): + (node / name).write_text(str(value) + '\n') + with mock.patch.object(status.glob, 'glob', return_value=[str(node)]): + result = status.battery() + if result: + result.pop('source') + return result + + def test_frame_units_and_optional_health(self): + b = self.probe(dict(capacity=73, status='Charging', voltage_now=3800000, + current_now=1000000, temp=325, health='Good', + charge_full=4600000, charge_full_design=5000000, + cycle_count=42, time_to_full_now=3600)) + self.assertEqual(b, dict(percent=73, status='Charging', watts=3.8, + tempC=32.5, health='Good', timeToFull=3600, + timeToEmpty=None, cycleCount=42, fullMah=4600, + designMah=5000, capacityHealthPercent=92)) + + def test_zero_negative_current_and_temperature(self): + b = self.probe(dict(capacity=0, cycle_count=0, temp=-50, + voltage_now=3800000, current_now=-1000000)) + self.assertEqual((b['percent'], b['cycleCount'], b['tempC'], b['watts']), + (0, 0, -5, -3.8)) + + def test_missing_optional_sensors_stay_null(self): + b = self.probe(dict(capacity=50, status='Discharging')) + self.assertEqual(b['percent'], 50) + self.assertTrue(all(value is None for key, value in b.items() + if key not in ('percent', 'status'))) + + def test_invalid_values_do_not_break_other_readings(self): + for invalid in ('abc', '', '-1', '101', '12.5'): + b = self.probe(dict(capacity=invalid, status='Charging', temp='bad', + charge_full=4500000, charge_full_design=0, + time_to_full_now=-1, time_to_empty_now=4294967295, + cycle_count=-1, voltage_now='bad', current_now=100)) + self.assertIsNone(b['percent']) + for key in ('watts', 'tempC', 'capacityHealthPercent', 'timeToFull', 'timeToEmpty', 'cycleCount'): + self.assertIsNone(b[key], key) + self.assertEqual(b['status'], 'Charging') + + def test_no_battery_and_unreadable_type(self): + with mock.patch.object(status.glob, 'glob', return_value=[]): + self.assertIsNone(status.battery()) + with mock.patch.object(status.glob, 'glob', return_value=['/missing']), \ + mock.patch('builtins.open', side_effect=PermissionError): + self.assertIsNone(status.battery()) + + def test_prefers_frame_and_excludes_peripheral_or_absent_batteries(self): + paths = ['/sys/class/power_supply/aaa', '/sys/class/power_supply/max1720x_bat_7-36'] + def read(path): + return {'type': 'Battery', 'capacity': '80'}.get(path.rsplit('/', 1)[-1]) + with mock.patch.object(status.glob, 'glob', return_value=paths), \ + mock.patch.object(status, 'read', side_effect=read): + self.assertEqual(status.battery()['source'], paths[1]) + self.assertIsNone(self.probe(dict(scope='Device', capacity=99))) + self.assertIsNone(self.probe(dict(present=0, capacity=99))) + + def test_permission_failure_affects_only_one_sensor(self): + actual_open = open + def guarded(path, *args, **kwargs): + if str(path).endswith('/temp'): + raise PermissionError('unreadable') + return actual_open(path, *args, **kwargs) + with mock.patch('builtins.open', side_effect=guarded): + b = self.probe(dict(capacity=45, temp=300)) + self.assertEqual(b['percent'], 45) + self.assertIsNone(b['tempC']) diff --git a/ui/control-center.js b/ui/control-center.js index 4187d17..71bf418 100644 --- a/ui/control-center.js +++ b/ui/control-center.js @@ -16,8 +16,8 @@ description:'Set the default headset output volume or mute it. Changes apply only when you choose Apply or Mute.', keywords:'sound speakers headphones loud quiet', special:'audio'}, {id:'health', category:'device', icon:'pulse', title:'Battery & thermal health', kind:'read', label:'Read-only metrics', - description:'Inspect charge, signed battery power, charger capability, battery temperature and health. Heat warnings use the kernel’s own hot and critical trip points.', - keywords:'battery charging charger charge time estimate remaining power watts usb health heat temperature thermal sensor', special:'health', links:[['#comfort','Configure battery & heat alerts']]}, + description:'Inspect charge, signed battery power, charger readings, battery temperature, health and optional capacity / cycle readings. Heat warnings use the kernel’s own hot and critical trip points.', + keywords:'battery charging charger charge time estimate remaining power watts usb health cycles capacity wear heat temperature thermal sensor', special:'health', links:[['#comfort','Configure battery & heat alerts']]}, {id:'storage', category:'device', icon:'files', title:'Storage & memory', kind:'read', label:'Read-only metrics', description:'Check free user and system storage plus available RAM. Manage installed apps in their existing libraries, or use SFTP for files.', keywords:'disk space root home free total ram memory capacity cleanup usage storage', special:'storage', links:[['#library','Installed games'],['#linuxApps','Linux apps'],['#power','SFTP & remote tools']]}, @@ -144,9 +144,9 @@ const capacity = b.percent; const idle = !!s.power && Number.isFinite(b.watts) && Math.abs(b.watts) < .5; const charging = b.status === 'Charging'; - const full = b.status === 'Full' || (!!s.power && capacity >= 100); + const full = b.status === 'Full'; const batteryState = !sample ? 'Connect to read charge and power' : !s.battery ? 'Battery sensor unavailable' : - full ? 'Fully charged' : charging ? 'Charging' : idle ? 'Plugged in · not charging' : s.power ? 'Plugged in · on battery' : 'On battery'; + full ? 'Fully charged' : charging ? 'Charging' : b.status === 'Not charging' || idle ? (s.power ? 'Plugged in · not charging' : 'Not charging') : b.status === 'Discharging' ? (s.power ? 'Plugged in · on battery' : 'On battery') : 'Charging state unavailable'; return { battery:number(capacity, '%'), batteryDetail:batteryState, storage:h ? gib(h.free) : 'Unavailable', storageDetail:h ? `${gib(h.total)} total · user storage` : 'User storage unavailable', @@ -155,8 +155,11 @@ rootFree:gib(s.disk?.root?.free), rootTotal:gib(s.disk?.root?.total), memoryAvailable:gib(m?.available), fps:number(p.compositorFps, '', 1), fpsDetail:'Compositor FPS · not game FPS', power:number(b.watts, ' W', 1), charger:s.power ? `${s.power.type || 'Connected'} · ${number(s.power.watts, ' W', 1)}` : sample ? 'No charger reported' : 'Unavailable', - batteryTemp:number(b.tempC, ' °C', 1), batteryHealth:b.health || 'Unavailable', - estimate:charging ? (b.timeToFull > 0 ? `${duration(b.timeToFull)} to full` : 'Unavailable') : !full && !idle && b.timeToEmpty > 0 && b.timeToEmpty < 86400 ? `${duration(b.timeToEmpty)} remaining` : 'Unavailable', + batteryTemp:number(b.tempC, ' °C', 1), batteryHealth:b.health && b.health !== 'Unknown' ? b.health : 'Unavailable', + batterySource:b.source || 'Unavailable', cycles:number(b.cycleCount), + fullCapacity:number(b.fullMah, ' mAh', 1), designCapacity:number(b.designMah, ' mAh', 1), + capacityHealth:number(b.capacityHealthPercent, '%', 1), + estimate:charging ? (b.timeToFull > 0 ? `${duration(b.timeToFull)} to full` : 'Unavailable') : b.status === 'Discharging' && !idle && b.timeToEmpty > 0 && b.timeToEmpty < 86400 ? `${duration(b.timeToEmpty)} remaining` : 'Unavailable', wifi:s.wifi?.ssid || 'Unavailable', signal:number(s.wifi?.signal, '%'), ip:s.ip || 'Unavailable', uptime:duration(s.uptime), os:s.os?.version || 'Unavailable', build:s.os?.build || 'Unavailable', temperature:number(s.temp, ' °C', 1), cpu:number(p.cpuPercent, '%', 1), gpu:number(p.gpuMHz, ' MHz', 1), @@ -214,7 +217,8 @@ const metric = (id, label) => `
${label}
Unavailable
`; const perf = `
${[['appFps','Application FPS'],['frame','Compositor period'],['render','Render GPU time'],['compositorCpu','Compositor CPU'],['cpu','System CPU'],['gpu','GPU clock'],['temperature','Hottest sensor']].map(([id,label])=>metric(id,label)).join('')}

GPU time is render time, not utilisation. The hottest sensor is not necessarily CPU temperature.

Trip data unavailable

`; const audio = `
Waiting for an audio reading

`; - const health = `
${[['battery','Charge'],['power','Battery power · signed'],['charger','Charger'],['estimate','Battery estimate'],['batteryTemp','Battery temperature'],['batteryHealth','Battery health']].map(([id,label])=>metric(id,label)).join('')}
`; + const batteryNote = 'Read-only. Unavailable means not reported, unreadable or invalid. Health is the driver’s status; full / design capacity is a fuel-gauge estimate, not a diagnosis. Charge limits are unsupported on Frame.'; + const health = `
${[['battery','Charge'],['batteryDetail','Charging state'],['power','Battery power · signed'],['charger','Charger'],['estimate','Battery estimate'],['batteryTemp','Battery temperature'],['batteryHealth','Battery health'],['cycles','Charge cycles'],['fullCapacity','Full charge capacity'],['designCapacity','Design capacity'],['capacityHealth','Full / design capacity'],['batterySource','Battery source']].map(([id,label])=>metric(id,label)).join('')}

${batteryNote}

`; const forcequit = `

    `; const storage = `
    ${[['storage','User storage free'],['rootFree','System storage free'],['rootTotal','System storage total'],['memoryAvailable','Memory available']].map(([id,label])=>metric(id,label)).join('')}
    `; get('ccCategories').innerHTML = categories.map(([id,label,symbol])=>``).join(''); diff --git a/ui/frame_status.py b/ui/frame_status.py index 998879a..c3d3037 100644 --- a/ui/frame_status.py +++ b/ui/frame_status.py @@ -45,19 +45,40 @@ def num(path, scale=1.0): def battery(): - for d in glob.glob("/sys/class/power_supply/*"): + # Prefer the documented Frame fuel gauge over controller/peripheral batteries. + supplies = sorted(glob.glob("/sys/class/power_supply/*"), + key=lambda d: (os.path.basename(d) != "max1720x_bat_7-36", d)) + for d in supplies: if read(d + "/type") != "Battery": continue - cap = read(d + "/capacity") + if read(d + "/scope") == "Device" or read(d + "/present") == "0": + continue + + def value(name, scale=1, minimum=None, maximum=None): + n = num(d + "/" + name, scale) + if n is None or not math.isfinite(n): + return None + if (minimum is not None and n < minimum) or (maximum is not None and n > maximum): + return None + return n + # max1720x reports microvolts/microamps; current is positive while charging. - volts, amps = num(d + "/voltage_now", 1e-6), num(d + "/current_now", 1e-6) - return {"percent": int(cap) if cap and cap.isdigit() else None, + volts, amps = value("voltage_now", 1e-6, 0), value("current_now", 1e-6) + full = value("charge_full", 1e-3, 0) + design = value("charge_full_design", 1e-3, 0) + return {"source": d, + "percent": value("capacity", minimum=0, maximum=100), "status": read(d + "/status"), "watts": round(volts * amps, 2) if volts is not None and amps is not None else None, - "timeToFull": num(d + "/time_to_full_now"), - "timeToEmpty": num(d + "/time_to_empty_now"), - "tempC": num(d + "/temp", 0.1), - "health": read(d + "/health")} + # Reject negative/unsigned sentinel estimates and implausible temperatures. + "timeToFull": value("time_to_full_now", minimum=1, maximum=604800), + "timeToEmpty": value("time_to_empty_now", minimum=1, maximum=604800), + "tempC": value("temp", 0.1, -273.15, 200), + "health": read(d + "/health"), + "cycleCount": value("cycle_count", minimum=0), + "fullMah": full if full else None, + "designMah": design if design else None, + "capacityHealthPercent": round(full / design * 100, 1) if full and design else None} return None diff --git a/ui/index.html b/ui/index.html index 0c1b17c..8fd6431 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1124,7 +1124,10 @@
    Battery power · signed
    Unavailable
    Charger
    Unavailable
    Battery temperature
    Unavailable
    Battery health
    Unavailable
    Battery estimate
    Unavailable
    -

    Positive battery watts mean charging; negative means discharge. Estimates are reported by the battery and may be unavailable.

    +
    Charge cycles
    Unavailable
    Full charge capacity
    Unavailable
    +
    Design capacity
    Unavailable
    Full / design capacity
    Unavailable
    +
    Battery source
    Unavailable
    +

    Positive battery watts mean charging; negative means discharge. Unavailable sensors are not reported, unreadable or invalid. Health is the driver’s status; full / design capacity is a fuel-gauge estimate, not a diagnosis. Charge limits are unsupported on Frame.

    @@ -2422,12 +2425,11 @@ $("castBtn").onclick = () => { // ---- battery ---- function battery(b, power) { const pct = b?.percent; - const full = b?.status === "Full" || (!!power && pct >= 100); + const full = b?.status === "Full"; const charging = !full && b?.status === "Charging"; // A weak charger can lose to the load: plugged in but still draining. // Topped up on a charger it reports Discharging at ~0 W: that's idle, not draining. const idle = !!power && b?.watts != null && Math.abs(b.watts) < 0.5; - const draining = !full && !charging && !idle && (!power || b?.status === "Discharging" || b?.watts < 0); const low = !charging && !full && pct != null && pct <= 15; const color = full || charging ? "#59bf40" : low ? "#d94126" : pct != null && pct <= 30 ? "#d9a23a" : "#dcdedf"; @@ -2437,7 +2439,8 @@ function battery(b, power) { else if (charging) { label = b.watts > 0 ? `Charging · ${b.watts.toFixed(1)} W` : "Charging"; cls = "charging"; if (b.timeToFull > 0) detail.push(`Full in ${dur(b.timeToFull)}`); - } else if (!draining) { label = "Plugged in, not charging"; cls = "draining"; } + } else if (b.status === "Not charging" || idle) { label = "Not charging"; cls = "draining"; } + else if (b.status !== "Discharging") { label = "Charging state unavailable"; cls = "draining"; } else { label = low ? "Battery low" : power ? "Plugged in, still draining" : "On battery"; cls = low ? "low" : "draining"; From 60261f093ff93c47afad8813e86e61f4e54d4e3a Mon Sep 17 00:00:00 2001 From: SAPHID Date: Sat, 24 Oct 2026 02:40:26 +0000 Subject: [PATCH 3/7] fix: stop owned smoke processes before removing titles --- CHANGELOG.md | 10 +++ docs/testing.md | 27 +++++++ tests/smoke/frame_smoke.py | 53 ++++++++----- tests/smoke/stop_processes.py | 88 +++++++++++++++++++++ tests/test_smoke_cleanup.py | 140 ++++++++++++++++++++++++++++++++++ 5 files changed, 301 insertions(+), 17 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 tests/smoke/stop_processes.py create mode 100644 tests/test_smoke_cleanup.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e18a933 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## Unreleased + +- 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; real Proton/headset validation remains outstanding. diff --git a/docs/testing.md b/docs/testing.md index 3fd2660..975544d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -164,6 +164,33 @@ 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, inspection/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. A real Proton launch still needs coordinated headset verification. +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. 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/