"""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