#!/usr/bin/env python3 """Hardware tier detection and resource monitoring via /proc and /sys.""" import os import re from dataclasses import dataclass, field from pathlib import Path from typing import Dict, List, Optional, Tuple TIER_OPI_ZERO3 = "opi_zero3" TIER_PI_ZERO = "pi_zero" TIER_GENERIC = "generic" @dataclass class HardwareInfo: tier: str model: str cpu_model: str cpu_cores: int total_ram_mb: int architecture: str @dataclass class ResourceUsage: ram_used_mb: float ram_total_mb: float ram_percent: float cpu_percent: float # System-wide, from /proc/stat delta disk_used_pct: float disk_free_mb: float temperature_c: Optional[float] @dataclass class ProcessResources: pid: int name: str rss_mb: float vms_mb: float cpu_ticks: int state: str threads: int def get_hardware_tier() -> str: info = detect_hardware() return info.tier def detect_hardware() -> HardwareInfo: model = _read_device_model() cpu_model, cpu_cores, arch = _read_cpuinfo() if "Orange Pi Zero3" in model or "Orange Pi Zero 3" in model: tier = TIER_OPI_ZERO3 elif "Raspberry Pi Zero 2" in model or "Raspberry Pi Zero2" in model: tier = TIER_PI_ZERO elif "Raspberry Pi" in model and ("Zero" in model or "zero" in model): tier = TIER_PI_ZERO elif "Allwinner" in cpu_model or "H618" in cpu_model: # Fallback detection via CPU for OPi Zero 3 tier = TIER_OPI_ZERO3 elif "BCM2835" in cpu_model or "BCM2710" in cpu_model: tier = TIER_PI_ZERO else: tier = TIER_GENERIC total_ram = _read_total_ram_mb() return HardwareInfo( tier=tier, model=model or "Unknown", cpu_model=cpu_model, cpu_cores=cpu_cores, total_ram_mb=total_ram, architecture=arch, ) def _read_device_model() -> str: dt_model = Path("/proc/device-tree/model") if dt_model.exists(): try: return dt_model.read_text().strip().rstrip("\x00") except IOError: pass return "" def _read_cpuinfo() -> Tuple[str, int, str]: cpu_model = "" cores = 0 arch = "unknown" try: with open("/proc/cpuinfo", "r") as f: content = f.read() for line in content.splitlines(): if line.startswith("model name") or line.startswith("Hardware"): cpu_model = line.split(":", 1)[1].strip() if line.startswith("processor"): cores += 1 # Get architecture with open("/proc/cpuinfo", "r") as f: first_lines = f.read(4096) if "aarch64" in first_lines.lower() or "ARMv8" in first_lines: arch = "aarch64" elif "armv7" in first_lines.lower(): arch = "armv7l" else: import platform arch = platform.machine() except IOError: pass return cpu_model, max(cores, 1), arch def _read_total_ram_mb() -> int: try: with open("/proc/meminfo", "r") as f: for line in f: if line.startswith("MemTotal:"): kb = int(line.split()[1]) return kb // 1024 except IOError: pass return 0 def get_memory_info() -> Dict[str, int]: info = {} try: with open("/proc/meminfo", "r") as f: for line in f: parts = line.split() key = parts[0].rstrip(":") val = int(parts[1]) # in kB info[key] = val except IOError: pass return info def get_resource_usage(disk_path: str = "/") -> ResourceUsage: mem = get_memory_info() total = mem.get("MemTotal", 0) available = mem.get("MemAvailable", mem.get("MemFree", 0)) used = total - available pct = (used / total * 100) if total > 0 else 0.0 stat = os.statvfs(disk_path) disk_total = stat.f_blocks * stat.f_frsize disk_free = stat.f_bavail * stat.f_frsize disk_used_pct = ((disk_total - disk_free) / disk_total * 100) if disk_total > 0 else 0.0 temp = get_cpu_temperature() return ResourceUsage( ram_used_mb=used / 1024, ram_total_mb=total / 1024, ram_percent=pct, cpu_percent=_get_cpu_percent(), disk_used_pct=disk_used_pct, disk_free_mb=disk_free / (1024 * 1024), temperature_c=temp, ) def get_cpu_temperature() -> Optional[float]: thermal_zones = Path("/sys/class/thermal") if thermal_zones.exists(): for zone in sorted(thermal_zones.iterdir()): temp_file = zone / "temp" type_file = zone / "type" if temp_file.exists(): try: temp_raw = int(temp_file.read_text().strip()) return temp_raw / 1000.0 except (IOError, ValueError): continue # Fallback for some SBCs hwmon = Path("/sys/class/hwmon") if hwmon.exists(): for hw in sorted(hwmon.iterdir()): temp_file = hw / "temp1_input" if temp_file.exists(): try: return int(temp_file.read_text().strip()) / 1000.0 except (IOError, ValueError): continue return None def _get_cpu_percent() -> float: try: with open("/proc/stat", "r") as f: line = f.readline() parts = line.split() # user, nice, system, idle, iowait, irq, softirq, steal total = sum(int(p) for p in parts[1:8]) idle = int(parts[4]) # Snapshot-based; for delta, caller should sample twice if total > 0: return ((total - idle) / total) * 100 except (IOError, IndexError, ValueError): pass return 0.0 def get_process_resources(pid: int) -> Optional[ProcessResources]: try: stat_path = f"/proc/{pid}/stat" status_path = f"/proc/{pid}/status" with open(stat_path, "r") as f: stat_line = f.read() # Parse /proc/PID/stat — comm field can contain spaces/parens match = re.match(r"(\d+) \((.+)\) (.+)", stat_line) if not match: return None name = match.group(2) fields = match.group(3).split() state = fields[0] utime = int(fields[11]) stime = int(fields[12]) threads = int(fields[17]) vsize = int(fields[20]) rss_pages = int(fields[21]) page_size = os.sysconf("SC_PAGE_SIZE") rss_mb = (rss_pages * page_size) / (1024 * 1024) vms_mb = vsize / (1024 * 1024) return ProcessResources( pid=pid, name=name, rss_mb=rss_mb, vms_mb=vms_mb, cpu_ticks=utime + stime, state=state, threads=threads, ) except (IOError, OSError, IndexError, ValueError): return None def get_disk_usage(path: str = "/") -> Tuple[float, float, float]: stat = os.statvfs(path) total_gb = (stat.f_blocks * stat.f_frsize) / (1024 ** 3) free_gb = (stat.f_bavail * stat.f_frsize) / (1024 ** 3) used_pct = ((stat.f_blocks - stat.f_bavail) / stat.f_blocks * 100) if stat.f_blocks > 0 else 0 return total_gb, free_gb, used_pct