Files
Hydra-OS/hydra/doctor.py
T

189 lines
8.5 KiB
Python

"""Bounded, read-only hardware observations; presence is not a functional test."""
import argparse
import itertools
import json
import os
from pathlib import Path
import platform
import re
import shutil
import subprocess
READ_LIMIT = 65536
DEVICE_LIMIT = 64
TOOLS = ('systemctl', 'nmcli', 'ip', 'iw', 'rfkill', 'wpctl', 'pactl',
'aplay', 'brightnessctl', 'wlr-randr', 'qemu-system-x86_64', 'btrfs',
'openssl', 'efibootmgr', 'lsblk', 'lspci')
DRIVERS = frozenset(('i915', 'xe', 'nouveau', 'nvidia', 'amdgpu', 'radeon',
'virtio_gpu', 'bochs', 'simpledrm', 'iwlwifi', 'ath9k',
'ath10k_pci', 'ath11k_pci', 'mt7921e', 'mt7922', 'rtw89_pci',
'r8169', 'e1000e', 'igc', 'virtio_net', 'snd_hda_intel'))
MODULES = ('kvm', 'kvm_intel', 'kvm_amd', 'i915', 'xe', 'nouveau', 'nvidia',
'amdgpu', 'iwlwifi', 'mt7921e', 'snd_hda_intel', 'snd_sof_pci_intel_tgl')
MANUAL_CHECKS = [
'Wi-Fi connection: connect to your access point and test connectivity.',
'Brightness keys: check both keys and the visible panel brightness.',
'144 Hz: select the panel mode and verify the actual refresh rate.',
'Audio: play sound through speakers/headphones and record microphone input.',
'Suspend/resume: test wake-up, graphics, network and audio after suspend.',
'USB: attach your devices and verify input/storage without erasing data.',
]
def _read(path, limit=READ_LIMIT):
try:
with path.open('rb') as stream:
return stream.read(limit).decode('ascii', errors='replace')
except OSError:
return ''
def _entries(path):
try:
# Do not materialize an unbounded directory or emit its identifying names.
with os.scandir(path) as entries:
return [Path(entry.path) for entry in itertools.islice(entries, DEVICE_LIMIT)]
except OSError:
return []
def _number(path, *, base=10):
text = _read(path, 128).strip()
try:
value = int(text, base)
return value if 0 <= value <= 2**64 - 1 else None
except ValueError:
return None
def _driver(path):
try:
name = (path / 'driver').resolve(strict=True).name
return name if name in DRIVERS else 'other'
except OSError:
return 'unknown'
def _services(find_tool, run):
systemctl = find_tool('systemctl')
result = {}
for unit, user in (('NetworkManager', False), ('greetd', False),
('hydra-network', False), ('hydra-dnsmasq', False),
('pipewire', True), ('wireplumber', True), ('pipewire-pulse', True)):
key = unit + (':user' if user else ':system')
if not systemctl:
result[key] = 'unavailable'
continue
command = [systemctl] + (['--user'] if user else []) + ['is-active', '--quiet', unit + '.service']
try:
status = run(command, timeout=1, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False).returncode
result[key] = {0: 'active', 3: 'inactive', 4: 'unavailable'}.get(status, 'unknown')
except subprocess.TimeoutExpired:
result[key] = 'timeout'
except OSError:
result[key] = 'unavailable'
return result
def doctor_report(*, proc_root=Path('/proc'), sys_root=Path('/sys'), dev_root=Path('/dev'),
find_tool=shutil.which, run=subprocess.run):
"""Return JSON-safe facts without identifiers or hardware-changing commands.
Each file read is bounded to 64 KiB, each device class to 64 entries and
the seven read-only service queries to one second each. Missing access
produces unknown/absent facts, never a claim of functional success.
"""
proc_root, sys_root, dev_root = Path(proc_root), Path(sys_root), Path(dev_root)
cpu = _read(proc_root / 'cpuinfo')
flags = set(re.findall(r'\b(?:vmx|svm)\b', cpu))
memory = {}
meminfo = _read(proc_root / 'meminfo')
for field, key in (('MemTotal', 'total_mib'), ('MemAvailable', 'available_mib')):
match = re.search(r'^' + field + r':\s+([0-9]{1,16})\s+kB$', meminfo, re.M)
memory[key] = int(match[1]) // 1024 if match else None
efi = sys_root / 'firmware/efi'
secure_boot = None
variable = efi / 'efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c'
try:
with variable.open('rb') as stream:
value = stream.read(6)
if len(value) == 5 and value[4] in (0, 1):
secure_boot = bool(value[4])
except OSError:
pass
storage = []
for path in _entries(sys_root / 'class/block'):
if (path / 'partition').exists() or re.match(r'^(loop|ram|zram)', path.name):
continue
sectors = _number(path / 'size')
storage.append({'size_bytes': sectors * 512 if sectors is not None else None,
'removable': {0: False, 1: True}.get(_number(path / 'removable'))})
graphics_drivers = set()
for path in _entries(sys_root / 'bus/pci/devices'):
pci_class = _number(path / 'class', base=16)
if pci_class is not None and pci_class >> 16 == 3:
graphics_drivers.add(_driver(path))
connectors = []
for path in _entries(sys_root / 'class/drm'):
if not (path / 'status').exists():
continue
status = _read(path / 'status', 64).strip()
modes = [line for line in _read(path / 'modes').splitlines()[:32]
if re.fullmatch(r'[0-9]{1,5}x[0-9]{1,5}', line)]
connectors.append({'status': status if status in ('connected', 'disconnected', 'unknown') else 'unknown',
'modes': modes})
network = _entries(sys_root / 'class/net')
inputs = [p for p in _entries(sys_root / 'class/input') if re.fullmatch(r'event[0-9]+', p.name)]
keyboards = 0
for path in inputs:
words = _read(path / 'device/capabilities/key', 256).split()
if words and all(re.fullmatch(r'[0-9a-fA-F]{1,16}', word) for word in words):
# x86_64 sysfs prints most-significant unsigned-long words first,
# without zero padding. KEY_A (30) and KEY_Z (44) are in the low word.
bits = int(words[-1], 16)
keyboards += bool(bits & (1 << 30) and bits & (1 << 44))
return {
'format': 1,
'scope': 'Read-only observations; physical functionality requires manual tests.',
'cpu': {'architecture': platform.machine(), 'logical_processors': len(re.findall(r'^processor\s*:', cpu, re.M)) or None,
'virtualization_flags': sorted(flags)},
'memory': memory,
'firmware': {'uefi': efi.is_dir(), 'secure_boot': secure_boot},
'kvm': {'device_present': (dev_root / 'kvm').exists(),
'accessible': os.access(dev_root / 'kvm', os.R_OK | os.W_OK),
'modules': [name for name in ('kvm', 'kvm_intel', 'kvm_amd') if (sys_root / 'module' / name).is_dir()]},
'storage': {'devices': storage},
'graphics': {'drivers': sorted(graphics_drivers), 'connectors': connectors,
'refresh_rate_tested': False},
'input': {'event_devices': len(inputs), 'keyboards': keyboards,
'backlight_devices': len(_entries(sys_root / 'class/backlight'))},
'network': {'devices': len(network), 'wireless_devices': sum((p / 'wireless').is_dir() for p in network),
'drivers': sorted({_driver(p / 'device') for p in network if (p / 'device').exists()})},
'audio': {'cards': sum(bool(re.fullmatch(r'card[0-9]+', p.name)) for p in _entries(sys_root / 'class/sound'))},
'modules': [name for name in MODULES if (sys_root / 'module' / name).is_dir()],
'tools': {name: bool(find_tool(name)) for name in TOOLS},
'services': _services(find_tool, run),
'manual_checks': list(MANUAL_CHECKS),
}
def doctor_main(argv=None):
parser = argparse.ArgumentParser(prog='hydra doctor', description=__doc__)
parser.add_argument('--json', action='store_true', help='Print structured observations')
options = parser.parse_args(argv)
report = doctor_report()
if options.json:
print(json.dumps(report, indent=2, sort_keys=True))
else:
print('Hydra laptop doctor — read-only observations')
print(report['scope'])
for key, value in report.items():
if key not in ('format', 'scope', 'manual_checks'):
print(f'{key}: {json.dumps(value, sort_keys=True)}')
print('\nManual checks:')
for check in report['manual_checks']:
print('- ' + check)
return 0