187 lines
8.3 KiB
Python
187 lines
8.3 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:
|
|
value = _read(path / 'device/capabilities/key', 256).strip().replace(' ', '')
|
|
if re.fullmatch(r'[0-9a-fA-F]{1,256}', value):
|
|
bits = int(value, 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
|