diff --git a/CHANGELOG.md b/CHANGELOG.md index 080d179..9651b50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Add `scripts/frame-power-sample.sh`, a bounded read-only SSH sampler for + battery, CPU, named temperatures, fan and available power rails (issue #52). + JSON samples/summaries and CSV exports retain explicit unavailable states; + active smoke-test locks stop measurement. No power settings are changed. + - Show saved Android compatibility reports on store search cards and app details. Reports refresh in the background, so a stalled database or credential lookup cannot delay search. Warnings allow installation; build compatibility blockers diff --git a/README.md b/README.md index dc8e8f3..60e4fca 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ Live video of what the lenses show, or of the app panel in use, flat and still h **🔋 Battery and status**
Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running. +For repeatable read-only battery, power-rail, temperature and fan measurements, +use the [power and heat sampler](docs/power-and-heat.md). + diff --git a/docs/evidence/power-sampler-2026-10-24.json b/docs/evidence/power-sampler-2026-10-24.json new file mode 100644 index 0000000..819a7f8 --- /dev/null +++ b/docs/evidence/power-sampler-2026-10-24.json @@ -0,0 +1,149 @@ +{ + "date": "2026-10-24", + "osVersion": "0.5.6", + "osBuild": "20261015.6141207", + "commands": [ + "scripts/frame-power-sample.sh --count 3 --interval 1", + "scripts/frame-power-sample.sh --count 1 --csv" + ], + "jsonSampleCount": 3, + "elapsedSeconds": [ + 0.078011, + 1.088542, + 2.107291 + ], + "stopped": null, + "unavailableGroups": [ + [ + "proximity" + ], + [ + "proximity" + ], + [ + "proximity" + ] + ], + "selectedSummary": { + "battery.percent": { + "unit": "%", + "sources": [ + "/sys/class/power_supply/max1720x_bat_7-36" + ], + "available": 3, + "unavailable": 0, + "min": 99, + "max": 99, + "mean": 99.0, + "first": 99, + "last": 99 + }, + "battery.status": { + "unit": "state", + "sources": [ + "/sys/class/power_supply/max1720x_bat_7-36" + ], + "available": 3, + "unavailable": 0, + "states": [ + "Not charging" + ] + }, + "battery.tempC": { + "unit": "C", + "sources": [ + "/sys/class/power_supply/max1720x_bat_7-36" + ], + "available": 3, + "unavailable": 0, + "min": 26.400000000000002, + "max": 26.400000000000002, + "mean": 26.4, + "first": 26.400000000000002, + "last": 26.400000000000002 + }, + "battery.watts": { + "unit": "W", + "sources": [ + "/sys/class/power_supply/max1720x_bat_7-36" + ], + "available": 3, + "unavailable": 0, + "min": -0.12, + "max": -0.02, + "mean": -0.07, + "first": -0.02, + "last": -0.07 + }, + "cpu.percent": { + "unit": "%", + "sources": [ + "/proc/stat" + ], + "available": 2, + "unavailable": 1, + "min": 8.7, + "max": 11.0, + "mean": 9.85, + "first": 11.0, + "last": 8.7 + }, + "fan.pwmRaw": { + "unit": "raw", + "sources": [ + "/sys/class/hwmon/hwmon50/pwm1" + ], + "available": 3, + "unavailable": 0, + "min": 41, + "max": 41, + "mean": 41.0, + "first": 41, + "last": 41 + }, + "fan.slg4ax46073v.fan1_input": { + "unit": "RPM", + "sources": [ + "/sys/class/hwmon/hwmon50/fan1_input" + ], + "available": 3, + "unavailable": 0, + "min": 8624, + "max": 8638, + "mean": 8628.666667, + "first": 8638, + "last": 8624 + }, + "proximity.raw": { + "unit": "raw", + "sources": [ + "/sys/bus/iio/devices/iio:device2/in_proximity_raw" + ], + "available": 0, + "unavailable": 3, + "states": [] + }, + "rail.max34417_9_10.vph.power1": { + "unit": "W", + "sources": [ + "/sys/class/hwmon/hwmon53/power1_input" + ], + "available": 3, + "unavailable": 0, + "min": 4.656299, + "max": 4.804066, + "mean": 4.705555, + "first": 4.656299, + "last": 4.804066 + } + }, + "csvRows": 92, + "csvSummaryMetrics": 91, + "csvHasExplicitUnavailable": true, + "limits": [ + "Read-only short transport/parser check; no controlled baseline or worn/load/battery experiment.", + "Proximity unavailable; numeric thermistors are not proof of sensor accuracy.", + "No settings, services, device files or application state changed; no remote installation.", + "No smoke lock at preflight or sampler checks; advisory checks cannot exclude a concurrent lock appearing during a read." + ] +} diff --git a/docs/power-and-heat.md b/docs/power-and-heat.md new file mode 100644 index 0000000..a6fcbfd --- /dev/null +++ b/docs/power-and-heat.md @@ -0,0 +1,122 @@ +# Read-only power and heat measurements + +Issue [#52](https://github.com/saphid/frame-control/issues/52) starts with +measurement. `scripts/frame-power-sample.sh` is a bounded, on-demand sampler +over the existing `ssh frame` alias. It needs Python 3 on both computers and +an already paired, awake, reachable Frame. It uses strict SSH host-key checking +and batch mode; it never pairs, prompts for a password or changes SSH security. + +```sh +# Twelve samples, five seconds apart (about 55 seconds), JSON report: +scripts/frame-power-sample.sh > power.json + +# Five-minute observation: samples at 0, 5, ... 300 seconds: +scripts/frame-power-sample.sh --count 61 --interval 5 > baseline.json + +# Long-form CSV samples; JSON summary goes separately to stderr: +scripts/frame-power-sample.sh --count 12 --interval 5 --csv > power.csv 2> summary.json +``` + +Count is limited to 1–720; interval to finite values from 1–60 seconds. These +are target start-to-start intervals, not guarantees: read latency can delay a +sample. Wall-clock `time` and monotonic `elapsedSeconds` record actual timing. +The first sample has no CPU utilization: two valid `/proc/stat` observations +are required. CPU utilization then covers the interval between observations, +averaged across the machine, rather than the utilization of one process. + +## What it reads + +- Battery percentage, driver charging state, signed battery watts and battery + temperature, using the same parser as Frame Control's battery details. + Positive battery watts mean charging on the Frame fuel gauge; negative means + discharge. `Not charging` alone does not prove a charger is connected. +- CPU utilization from `/proc/stat`; GPU clock from the known Frame devfreq path. +- Named thermal zones in °C, with zone names retained. A thermal zone is not + automatically a CPU sensor; there is no inferred “CPU temperature” or safe + temperature threshold. +- hwmon power channels, labels and driver names in watts. `vph` is the measured + system rail, not power at the wall. CPU `apc*` and GPU `gfx` rails are separate + readings; do not sum all channels as if they were independent power consumers. +- Fan RPM and the Frame fan driver's raw `pwm1`. No generic percentage conversion + is assumed. The historical Frame driver reported a percent-like value of 41. +- Named IIO board temperatures, PM8550B input/charge current in amps, and raw + VCNL4000 proximity if the account can read it. Raw proximity has no inferred + worn/unworn threshold. + +Each metric includes its unit, source path and availability state. Missing, +unreadable or invalid values are JSON `null`, not zero. A missing sensor family +is also listed in each sample's `unavailableGroups`; CSV adds unavailable group +rows. Numeric zero remains a valid reading. The OS build is recorded per sample +when `/etc/os-release` is readable; hostnames, addresses, machine IDs, process +lists, logs, screenshots and application inventories are not collected. + +The JSON report contains raw samples and a summary per metric: available and +unavailable counts, numeric minimum/maximum/arithmetic mean/first/last, or the +observed charging states. Means exclude unavailable readings. They are sample +means, not time-weighted energy or battery-runtime estimates. Different sample +availability and intervals must be considered when comparing sessions. + +Numeric validity does not establish sensor accuracy. Historical readings +already showed an apparently absent/broken right-display thermistor near +−16 °C. This firmware also reports one charger-lite die sensor at −60 °C. +Those raw numbers remain visible for diagnosis; neither should be used to +infer cooling performance or safety. This tool does not diagnose heat, explain +a spike, predict battery health, or promise power savings. + +## Safety and shared-device use + +Only file reads under `/proc`, `/sys` and `/etc/os-release` run on the Frame. +The Python sources are sent on SSH stdin and not installed on the headset. +There is no sudo, OpenVR initialization, service call, settings write, process +control, journal query, wake, suspend or reboot. No daemon, telemetry upload or +automatic measurement is added. Redirects above write only to your computer. +The observer itself uses a little CPU and SSH traffic, so label baseline +conditions and avoid interpreting very small changes as savings. + +The sampler checks `/tmp/frame-test.lock` on the computer before SSH, and on +the Frame before any probe and before each sample. If a smoke session is active, +it exits 75. A lock appearing mid-run stops the next sample, retains earlier +samples, sets `stopped`, and exits 75. It never removes or acquires another +session's lock. This is an advisory check, not an atomic lease: a smoke session +can start during a file read. Coordinate sessions and defer measurement when +timing overlaps. It does not block someone else from starting a smoke test. + +SSH failures/timeouts return a nonzero exit and an explanation, without +presenting a zero or an old report as a successful measurement. Fully missing +sensors can still produce a successful report with explicit unavailable states. +On a connection failure, partial samples buffered on the headset can be lost. + +## Evidence and remaining work + +Read-only checks on **2026-10-24**, SteamOS **0.5.6**, build +**20261015.6141207**, confirmed battery, CPU ticks, thermal zones, rail power, +fan, GPU clock, board temperatures and charger current through the final +sampler. Proximity was unavailable. Both JSON and CSV paths were exercised; +see [the recorded results](evidence/power-sampler-2026-10-24.json). +No smoke lock was present at the preflight or sampler checks. No settings, +services, application state or device files were changed. This short run is +a parser/transport check, not a controlled baseline or a worn-headset test. + +The original planning document is in commit `fe132e3` (2026-09-29). It recorded +an off-head, charging idle baseline of about 4.71 W, CPU zones around 40–45 °C, +and about 8,350 fan RPM on build 20260925.6191901. Those conditions and firmware +differ from the current check; comparisons do not establish a regression. + +This change completes only the sampler portion of “Plan: leave it on, but +cheaply.” Controlled worn/home-space, streaming/game load and unplugged +baselines, and the cause of the 2026-09-28 95 °C spike, remain uninvestigated. +Quiet mode, compositor changes, CPU/fan/charging changes and remote-wake tests +are separate work. No controls are offered here. Follow the shared-device +procedure in [testing.md](testing.md#headset-smoke-test) for future experiments. + +Offline validation: + +```sh +python3 -m unittest discover -s tests -p test_power_sample.py -v +bash -n scripts/frame-power-sample.sh +python3 -m py_compile scripts/frame_power_sample.py +``` + +Fixtures cover units, signed and zero values, missing/malformed/permission-denied +readings, named/discovered channels, unknown charging state, CPU warmup, +summary counts, CSV quoting, strict SSH transport and smoke-lock interruption. diff --git a/docs/testing.md b/docs/testing.md index df6ad9b..bf5d21c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -12,6 +12,12 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste ## Unit tests +The read-only power/heat sampler has fixture and transport tests in +`tests/test_power_sample.py`. Run +`python3 -m unittest discover -s tests -p test_power_sample.py -v`. +See [power-and-heat.md](power-and-heat.md) for sampling, smoke-lock behavior and +real-device verification limits. + ```sh python3 -m unittest discover -s tests ``` diff --git a/scripts/frame-power-sample.sh b/scripts/frame-power-sample.sh new file mode 100755 index 0000000..7cd0a63 --- /dev/null +++ b/scripts/frame-power-sample.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Read-only, bounded power/heat sampler. See docs/power-and-heat.md. +set -euo pipefail +root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +exec python3 "$root/scripts/frame_power_sample.py" "$@" diff --git a/scripts/frame_power_sample.py b/scripts/frame_power_sample.py new file mode 100644 index 0000000..e66ae23 --- /dev/null +++ b/scripts/frame_power_sample.py @@ -0,0 +1,211 @@ +"""Bounded read-only Frame measurements; no device files are written. + +The host sends this and frame_status.py over one trusted SSH connection. Only +battery() and CPU tick helpers are reused: no OpenVR, services or analytics. +""" +import argparse +import csv +import glob +import io +import json +import math +from pathlib import Path +import subprocess +import sys +import time + +LOCK = Path('/tmp/frame-test.lock') +UNAVAILABLE = 'unavailable: not reported, unreadable or invalid' + + +def read(path): + try: + return Path(path).read_text().strip() + except (OSError, UnicodeError): + return None + + +def number(path, scale=1, minimum=None, maximum=None): + try: + value = int(read(path)) * scale + if not math.isfinite(value): + return None + if minimum is not None and value < minimum or maximum is not None and value > maximum: + return None + return round(value, 6) + except (TypeError, ValueError, OverflowError): + return None + + +def snapshot(status, before): + if LOCK.exists(): + raise RuntimeError('Smoke session active (/tmp/frame-test.lock); no sensors read.') + ticks = status.cpu_ticks() + readings = {} + + def add(key, value, unit, source): + readings[key] = {'value': value, 'unit': unit, 'source': source, + 'state': 'available' if value is not None else UNAVAILABLE} + + battery = status.battery() or {} + for field, unit in (('percent', '%'), ('status', 'state'), ('watts', 'W'), + ('tempC', 'C')): + value = battery.get(field) + if field == 'status' and value not in ('Charging', 'Discharging', 'Not charging', 'Full'): + value = None + add('battery.' + field, value, unit, battery.get('source')) + add('cpu.percent', status.cpu_percent(before, ticks), '%', '/proc/stat') + if before is None: + readings['cpu.percent']['state'] = 'unavailable: needs two valid CPU tick samples' + add('gpu.clock', number('/sys/class/devfreq/3d00000.gpu/cur_freq', 1e-6, 0), + 'MHz', '/sys/class/devfreq/3d00000.gpu/cur_freq') + + # Discover by driver name/label, never assume a hwmon index. Keep source + # paths and channel numbers to distinguish duplicate labels. + for node in sorted(glob.glob('/sys/class/hwmon/hwmon*')): + name = read(node + '/name') or Path(node).name + for path in sorted(glob.glob(node + '/power*_input')): + channel = Path(path).name[:-6] + label = read(node + '/' + channel + '_label') or channel + add(f'rail.{name}.{label}.{channel}', number(path, 1e-6, 0), 'W', path) + for path in sorted(glob.glob(node + '/fan*_input')): + add(f'fan.{name}.{Path(path).stem}', number(path, minimum=0), 'RPM', path) + # pwm1 on Frame's driver is percent; leave raw to avoid assuming the + # generic hwmon 0..255 scale on other drivers or later firmware. + if name == 'slg4ax46073v': + add('fan.pwmRaw', number(node + '/pwm1', minimum=0), 'raw', node + '/pwm1') + for node in sorted(glob.glob('/sys/class/thermal/thermal_zone*')): + name = read(node + '/type') or Path(node).name + add(f'thermal.{name}.{Path(node).name}', number(node + '/temp', 0.001, -273.15, 200), + 'C', node + '/temp') + for node in sorted(glob.glob('/sys/bus/iio/devices/iio:device*')): + for path in sorted(glob.glob(node + '/in_temp_*_input')): + add(f'board.{Path(node).name}.{Path(path).stem}', number(path, 0.001, -273.15, 200), + 'C', path) + for field in ('iin', 'ichg'): + path = node + '/in_current_pm8550b_' + field + '_fb_input' + # Known names, but no fixed IIO index; absent channels are covered + # by group availability below. + if Path(path).exists(): + add(f'charger.{Path(node).name}.{field}', number(path, 1e-6), 'A', path) + if read(node + '/name') == 'vcnl4000': + add('proximity.raw', number(node + '/in_proximity_raw', minimum=0), + 'raw', node + '/in_proximity_raw') + unavailable = [group for group in ('rail', 'fan', 'thermal', 'board', 'charger', 'proximity') + if not any(k.startswith(group + '.') and v['value'] is not None + for k, v in readings.items())] + os_info = dict(line.split('=', 1) for line in (read('/etc/os-release') or '').splitlines() + if '=' in line) + return {'time': time.time(), 'osBuild': os_info.get('BUILD_ID', '').strip('"') or None, + 'readings': readings, 'unavailableGroups': unavailable}, ticks + + +def summarize(samples): + result = {} + keys = sorted({key for sample in samples for key in sample['readings']}) + for key in keys: + rows = [s['readings'][key] for s in samples if key in s['readings']] + values = [r['value'] for r in rows if r['value'] is not None] + item = {'unit': rows[0]['unit'], 'sources': sorted({r['source'] for r in rows if r['source']}), + 'available': len(values), 'unavailable': len(samples) - len(values)} + if values and all(isinstance(v, (int, float)) for v in values): + item.update(min=min(values), max=max(values), mean=round(sum(values) / len(values), 6), + first=values[0], last=values[-1]) + else: + item['states'] = sorted(set(values)) + result[key] = item + return result + + +def collect(status, count, interval): + samples, before = [], None + started = time.monotonic() + stopped = None + for index in range(count): + if index: + time.sleep(max(0, started + index * interval - time.monotonic())) + try: + sample, before = snapshot(status, before) + except RuntimeError as exc: + stopped = str(exc) + break + sample['elapsedSeconds'] = round(time.monotonic() - started, 6) + samples.append(sample) + return {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval, + 'stopped': stopped, 'samples': samples, 'summary': summarize(samples)} + + +def csv_samples(report): + output = io.StringIO(newline='') + writer = csv.writer(output) + writer.writerow(('time', 'elapsedSeconds', 'metric', 'value', 'unit', 'source', 'state')) + 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)) + return output.getvalue() + + +def parser(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('--count', type=int, default=12, help='samples, 1..720 (default 12)') + ap.add_argument('--interval', type=float, default=5, help='seconds, 1..60 (default 5)') + ap.add_argument('--csv', action='store_true', help='CSV samples on stdout; JSON summary on stderr') + ap.add_argument('--probe', action='store_true', help=argparse.SUPPRESS) + return ap + + +def remote_payload(): + root = Path(__file__).resolve().parents[1] + source = (root / 'ui/frame_status.py').read_text() + # Executing as a module prevents frame_status.main() from probing unrelated + # status. Nothing is installed, cached or logged on the headset. + return ('import types, sys\n' + 'status = types.ModuleType("frame_status")\n' + f'exec(compile({source!r}, "frame_status.py", "exec"), status.__dict__)\n' + 'sys.modules["frame_status"] = status\n' + Path(__file__).read_text()) + + +def main(argv=None): + ap = parser() + args = ap.parse_args(argv) + if not 1 <= args.count <= 720 or not math.isfinite(args.interval) or not 1 <= args.interval <= 60: + ap.error('count must be 1..720 and interval must be finite, 1..60 seconds') + # Also honor a coordinator lock on the computer before opening SSH. + if LOCK.exists(): + print('Smoke session active (/tmp/frame-test.lock); no SSH or sensors accessed.', file=sys.stderr) + return 75 + if args.probe: + import frame_status + report = collect(frame_status, args.count, args.interval) + else: + command = ['ssh', '-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', + '-o', 'ConnectTimeout=5', '-o', 'ServerAliveInterval=5', + '-o', 'ServerAliveCountMax=2', 'frame', + 'python3', '-', '--probe', '--count', str(args.count), '--interval', str(args.interval)] + try: + run = subprocess.run(command, input=remote_payload(), text=True, capture_output=True, + timeout=args.count * args.interval + 30) + if run.returncode: + print('Read-only sample failed: ' + (run.stderr.strip() or f'SSH exit {run.returncode}'), + file=sys.stderr) + return run.returncode + report = json.loads(run.stdout) + except (OSError, subprocess.TimeoutExpired, ValueError) as exc: + print(f'Read-only sample unavailable: {exc}', file=sys.stderr) + return 1 + if args.csv: + sys.stdout.write(csv_samples(report)) + print(json.dumps({k: v for k, v in report.items() if k != 'samples'}, allow_nan=False), file=sys.stderr) + else: + print(json.dumps(report, allow_nan=False)) + return 75 if report['stopped'] else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/test_power_sample.py b/tests/test_power_sample.py new file mode 100644 index 0000000..8e01264 --- /dev/null +++ b/tests/test_power_sample.py @@ -0,0 +1,209 @@ +"""Power sampler fixtures and SSH transport contract; no real device contact.""" +import sandbox # noqa: F401 +import contextlib +import csv +import importlib.util +import io +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('power_sample', ROOT / 'scripts/frame_power_sample.py') +power = importlib.util.module_from_spec(spec) +spec.loader.exec_module(power) +sys.path.insert(0, str(ROOT / 'ui')) +import frame_status as status + + +class Sampler(unittest.TestCase): + def patch(self, obj, name, **kwargs): + p = mock.patch.object(obj, name, **kwargs) + result = p.start() + self.addCleanup(p.stop) + return result + + def fixture(self, files): + # Windows cannot create a directory named iio:device8. Keep the + # production sysfs spelling in returned paths while using a portable + # name inside this temporary fixture tree. + def local_path(path): + return self.root / str(path).replace('\\', '/').lstrip('/').replace(':', '__COLON__') + + for path, value in files.items(): + node = local_path(path) + node.parent.mkdir(parents=True, exist_ok=True) + node.write_text(str(value)) + actual_glob = power.glob.glob + def paths(pattern): + return ['/' + Path(p).relative_to(self.root).as_posix().replace('__COLON__', ':') + for p in actual_glob(str(local_path(pattern)))] + actual_read = power.read + self.patch(power.glob, 'glob', side_effect=paths) + self.patch(power, 'read', side_effect=lambda p: actual_read(str(local_path(str(p))))) + self.patch(status, 'read', side_effect=lambda p: actual_read(str(local_path(str(p))))) + return local_path + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + p = mock.patch.object(power, 'LOCK', self.root / 'lock') + p.start() + self.addCleanup(p.stop) + + def test_real_file_units_zero_signed_battery_and_named_sensors(self): + h = '/sys/class/hwmon/hwmon88' + b = '/sys/class/power_supply/max1720x_bat_7-36' + self.fixture({h + '/name': 'max34417_9_10', h + '/power1_label': 'vph', + h + '/power1_input': '4710000', + '/sys/class/hwmon/hwmon3/name': 'slg4ax46073v', + '/sys/class/hwmon/hwmon3/fan1_input': '0', + '/sys/class/hwmon/hwmon3/pwm1': '41', + '/sys/class/thermal/thermal_zone9/type': 'cpu3_top_thermal', + '/sys/class/thermal/thermal_zone9/temp': '45321', + b + '/type': 'Battery', b + '/capacity': '0', b + '/status': 'Discharging', + b + '/voltage_now': '3800000', b + '/current_now': '-1000000', + '/proc/stat': 'cpu 10 0 0 90 0 0 0 0'}) + sample, ticks = power.snapshot(status, (80, 80)) + r = sample['readings'] + self.assertEqual(r['rail.max34417_9_10.vph.power1']['value'], 4.71) + self.assertEqual(r['battery.percent']['value'], 0) + self.assertEqual(r['battery.watts']['value'], -3.8) + self.assertEqual(r['battery.status']['value'], 'Discharging') + self.assertEqual(r['fan.slg4ax46073v.fan1_input']['value'], 0) + self.assertEqual(r['fan.pwmRaw']['unit'], 'raw') + self.assertEqual(r['thermal.cpu3_top_thermal.thermal_zone9']['value'], 45.321) + self.assertEqual(r['cpu.percent']['value'], 50) + self.assertEqual(ticks, (100, 90)) + self.assertEqual(sample['unavailableGroups'], ['board', 'charger', 'proximity']) + + def test_missing_and_invalid_values_remain_unavailable(self): + self.fixture({'/sys/class/thermal/thermal_zone0/temp': '999999999', + '/sys/class/hwmon/hwmon2/name': 'fan', + '/sys/class/hwmon/hwmon2/fan1_input': '-1'}) + sample, ticks = power.snapshot(status, None) + self.assertIsNone(ticks) + self.assertTrue(all(r['value'] is None for r in sample['readings'].values())) + self.assertEqual(len(sample['unavailableGroups']), 6) + self.assertIn('two valid CPU', sample['readings']['cpu.percent']['state']) + + def test_permission_error_is_unavailable(self): + with mock.patch.object(Path, 'read_text', side_effect=PermissionError): + self.assertIsNone(power.number('/unreadable')) + for value in ('NaN', 'Infinity', '1.5', '', '9' * 5000): + with mock.patch.object(power, 'read', return_value=value): + self.assertIsNone(power.number('/invalid')) + + def test_iio_units_and_permission_denied_proximity(self): + node = '/sys/bus/iio/devices/iio:device8' + local_path = self.fixture({node + '/name': 'vcnl4000', + node + '/in_temp_pcba_at_wifi_input': '37500', + node + '/in_current_pm8550b_iin_fb_input': '241671', + '/etc/os-release': 'BUILD_ID=20261015.6141207\n'}) + # exists() is used only for optional known charger channels and lock. + exists = Path.exists + with mock.patch.object(Path, 'exists', lambda p: exists(p) if p == power.LOCK + else exists(local_path(str(p)))): + sample, _ = power.snapshot(status, None) + self.assertEqual(sample['osBuild'], '20261015.6141207') + self.assertEqual(sample['readings']['board.iio:device8.in_temp_pcba_at_wifi_input']['value'], 37.5) + self.assertEqual(sample['readings']['charger.iio:device8.iin']['value'], 0.241671) + self.assertIsNone(sample['readings']['proximity.raw']['value']) + self.assertIn('proximity', sample['unavailableGroups']) + + def test_unknown_charging_state_is_not_claimed(self): + self.fixture({}) + with mock.patch.object(status, 'battery', return_value={'status': 'Unknown', 'percent': 50}): + r = power.snapshot(status, None)[0]['readings'] + self.assertIsNone(r['battery.status']['value']) + self.assertEqual(r['battery.percent']['value'], 50) + + def test_smoke_lock_prevents_any_sensor_reads(self): + power.LOCK.mkdir() + with mock.patch.object(status, 'cpu_ticks') as ticks: + with self.assertRaisesRegex(RuntimeError, 'Smoke session'): + power.snapshot(status, None) + ticks.assert_not_called() + + def test_smoke_lock_appearing_between_samples_stops_and_preserves_partial_report(self): + self.fixture({'/proc/stat': 'cpu 10 0 0 90 0 0 0 0'}) + with mock.patch.object(power.time, 'sleep', side_effect=lambda _: power.LOCK.mkdir()): + report = power.collect(status, 3, 1) + self.assertEqual(len(report['samples']), 1) + self.assertIn('Smoke session', report['stopped']) + self.assertEqual(report['requestedSamples'], 3) + + def test_summaries_ignore_missing_values_and_keep_state_changes(self): + def row(value, unit='W'): + return {'value': value, 'unit': unit, 'source': '/sensor', 'state': 'available'} + samples = [{'readings': {'rail': row(0), 'state': row('Charging', 'state')}}, + {'readings': {'rail': row(None), 'state': row('Full', 'state')}}, + {'readings': {'rail': row(6)}}] + summary = power.summarize(samples) + self.assertEqual(summary['rail'], dict(unit='W', sources=['/sensor'], available=2, + unavailable=1, min=0, max=6, mean=3, first=0, last=6)) + self.assertEqual(summary['state']['states'], ['Charging', 'Full']) + self.assertEqual(summary['state']['unavailable'], 1) + + def test_csv_quoting_and_unavailable_groups(self): + report = {'samples': [{'time': 1, 'elapsedSeconds': 0, + 'readings': {'fan,test': {'value': -3.8, 'unit': 'W', + 'source': '=formula', 'state': 'available'}}, + 'unavailableGroups': ['thermal']}]} + rows = list(csv.reader(io.StringIO(power.csv_samples(report)))) + self.assertEqual(rows[1][2:6], ['fan,test', '-3.8', 'W', "'=formula"]) + self.assertEqual(rows[2][-1], power.UNAVAILABLE) + + def test_bounded_arguments_rejected_before_ssh(self): + for args in (['--count', '0'], ['--count', '721'], ['--interval', 'nan'], + ['--interval', 'inf'], ['--interval', '0.5'], ['--interval', '61']): + with mock.patch.object(power.subprocess, 'run') as run, contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit): + power.main(args) + run.assert_not_called() + + def test_local_lock_prevents_ssh(self): + power.LOCK.mkdir() + with mock.patch.object(power.subprocess, 'run') as run, contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(power.main([]), 75) + run.assert_not_called() + + def test_ssh_payload_runs_as_module_and_outputs_report(self): + report = dict(samples=[], summary={}, stopped=None) + def ssh(command, **kwargs): + self.assertIn('BatchMode=yes', command) + self.assertIn('StrictHostKeyChecking=yes', command) + self.assertIn('frame', command) + self.assertEqual(command[-4:], ['--count', '2', '--interval', '1.0']) + # Execute the exact transmitted source, with main suppressed. Its + # frame_status module must be importable without running status(). + namespace = {'__name__': 'payload_test'} + with mock.patch.dict(sys.modules): + exec(compile(kwargs['input'], '', 'exec'), namespace) + remote = sys.modules['frame_status'] + self.assertTrue(callable(remote.battery)) + with mock.patch.object(remote, 'status', side_effect=AssertionError('full status forbidden')), \ + mock.patch.object(remote, 'battery', return_value=None), \ + mock.patch.object(remote, 'cpu_ticks', return_value=(100, 90)), \ + mock.patch.object(namespace['glob'], 'glob', return_value=[]), \ + mock.patch.object(namespace['Path'], 'exists', return_value=False): + report.update(namespace['collect'](remote, 1, 1)) + return subprocess.CompletedProcess(command, 0, json.dumps(report), '') + output = io.StringIO() + with mock.patch.object(power.subprocess, 'run', side_effect=ssh), contextlib.redirect_stdout(output): + self.assertEqual(power.main(['--count', '2', '--interval', '1']), 0) + self.assertEqual(len(json.loads(output.getvalue())['samples']), 1) + + def test_ssh_failures_are_not_measurements(self): + for result in (subprocess.CompletedProcess([], 255, '', 'offline'), + subprocess.CompletedProcess([], 0, 'bad json', '')): + output = io.StringIO() + with mock.patch.object(power.subprocess, 'run', return_value=result), \ + contextlib.redirect_stdout(output), contextlib.redirect_stderr(io.StringIO()): + self.assertNotEqual(power.main([]), 0) + self.assertEqual(output.getvalue(), '')