ffd384f64b
Replace all sensor.* logger namespaces with __name__ (generic module identifiers instead of discoverable 'sensor.*' prefixes). Change hardcoded 'bb' API user to 'admin' in config and code defaults. Change hardcoded relay_user 'bb' to 'operator' — prevents network profiling from exposing tool identity via SSH config. Fixes #457, #458, #459
330 lines
12 KiB
Python
330 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Per-module and per-subprocess resource monitoring.
|
|
|
|
Tracks RAM, CPU, disk usage, and thermal readings. Publishes
|
|
RESOURCE_WARNING, RESOURCE_CRITICAL, and THERMAL_WARNING events.
|
|
OOM-kills the lowest-priority module when memory exceeds tier limits.
|
|
Sheds modules at 70C thermal threshold.
|
|
"""
|
|
|
|
import os
|
|
import time
|
|
import logging
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from core.bus import EventBus
|
|
from core.state import StateManager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Thermal thresholds (Celsius)
|
|
THERMAL_WARNING = 55
|
|
THERMAL_CRITICAL = 70
|
|
|
|
# Disk usage threshold
|
|
DISK_WARNING_PCT = 80
|
|
DISK_CRITICAL_PCT = 90
|
|
|
|
# Check interval
|
|
CHECK_INTERVAL = 15.0 # seconds
|
|
|
|
|
|
class ResourceMonitor:
|
|
"""Monitors system and per-process resources.
|
|
|
|
Integrates with Engine (module processes) and ToolManager (subprocess PIDs)
|
|
to provide unified resource tracking.
|
|
"""
|
|
|
|
def __init__(self, bus: EventBus, state: StateManager, config: dict,
|
|
engine=None, tool_manager=None):
|
|
self.bus = bus
|
|
self.state = state
|
|
self.config = config
|
|
self.engine = engine
|
|
self.tool_manager = tool_manager
|
|
self._thread: Optional[threading.Thread] = None
|
|
self._running = False
|
|
|
|
# Load tier limits
|
|
tier = config.get("device", {}).get("platform", "generic")
|
|
from core.engine import HARDWARE_TIERS, detect_hardware_tier
|
|
if tier == "auto":
|
|
tier = detect_hardware_tier()
|
|
self._tier_limits = HARDWARE_TIERS.get(tier, HARDWARE_TIERS["generic"])
|
|
self._tier = tier
|
|
|
|
# Thermal zone path (try common locations)
|
|
self._thermal_path = self._find_thermal_zone()
|
|
|
|
# State tracking
|
|
self._thermal_warned = False
|
|
self._resource_warned = False
|
|
|
|
# ------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ------------------------------------------------------------------
|
|
|
|
def start(self) -> None:
|
|
if self._running:
|
|
return
|
|
self._running = True
|
|
self._thread = threading.Thread(
|
|
target=self._monitor_loop, daemon=True, name="sensor-resource-monitor"
|
|
)
|
|
self._thread.start()
|
|
logger.info("ResourceMonitor started (tier=%s, thermal_zone=%s)",
|
|
self._tier, self._thermal_path or "none")
|
|
|
|
def stop(self) -> None:
|
|
self._running = False
|
|
if self._thread and self._thread.is_alive():
|
|
self._thread.join(timeout=5.0)
|
|
logger.info("ResourceMonitor stopped")
|
|
|
|
# ------------------------------------------------------------------
|
|
# Monitor loop
|
|
# ------------------------------------------------------------------
|
|
|
|
def _monitor_loop(self) -> None:
|
|
while self._running:
|
|
try:
|
|
self._check_resources()
|
|
except Exception:
|
|
logger.exception("Resource check error")
|
|
time.sleep(CHECK_INTERVAL)
|
|
|
|
def _check_resources(self) -> None:
|
|
"""Run all resource checks."""
|
|
self._check_memory()
|
|
self._check_disk()
|
|
self._check_thermal()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Memory
|
|
# ------------------------------------------------------------------
|
|
|
|
def _check_memory(self) -> None:
|
|
"""Check total memory usage against tier limits."""
|
|
mem = self.get_system_memory()
|
|
max_ram = self._tier_limits.get("max_ram_mb", 0)
|
|
if max_ram <= 0:
|
|
return
|
|
|
|
used_mb = mem["used_mb"]
|
|
|
|
if used_mb > max_ram * 0.95:
|
|
# Critical — OOM kill lowest priority module
|
|
logger.critical("Memory critical: %.0fMB / %dMB", used_mb, max_ram)
|
|
self.bus.emit("RESOURCE_CRITICAL",
|
|
{"resource": "memory", "used_mb": used_mb, "limit_mb": max_ram},
|
|
source_module="resource_monitor")
|
|
self._oom_kill()
|
|
elif used_mb > max_ram * 0.80:
|
|
if not self._resource_warned:
|
|
logger.warning("Memory warning: %.0fMB / %dMB", used_mb, max_ram)
|
|
self.bus.emit("RESOURCE_WARNING",
|
|
{"resource": "memory", "used_mb": used_mb, "limit_mb": max_ram},
|
|
source_module="resource_monitor")
|
|
self._resource_warned = True
|
|
else:
|
|
self._resource_warned = False
|
|
|
|
def _oom_kill(self) -> None:
|
|
"""Kill the lowest-priority (highest priority number) running module."""
|
|
if self.engine is None:
|
|
return
|
|
|
|
# Find lowest-priority running module
|
|
worst_name = None
|
|
worst_priority = -1000
|
|
|
|
status = self.engine.get_status()
|
|
for name, info in status.items():
|
|
if not info.get("running"):
|
|
continue
|
|
prio = info.get("priority", 0)
|
|
if prio > worst_priority:
|
|
worst_priority = prio
|
|
worst_name = name
|
|
|
|
if worst_name:
|
|
logger.warning("OOM killing module: %s (priority=%d)", worst_name, worst_priority)
|
|
self.engine.stop(worst_name)
|
|
self.bus.emit("MODULE_STOPPED",
|
|
{"module": worst_name, "reason": "oom_kill"},
|
|
source_module="resource_monitor")
|
|
|
|
# ------------------------------------------------------------------
|
|
# Disk
|
|
# ------------------------------------------------------------------
|
|
|
|
def _check_disk(self) -> None:
|
|
"""Check disk usage on the data partition."""
|
|
storage_path = self.config.get("storage_path", "/opt/.cache/bb/storage")
|
|
try:
|
|
st = os.statvfs(storage_path)
|
|
except OSError:
|
|
try:
|
|
st = os.statvfs("/")
|
|
except OSError:
|
|
return
|
|
|
|
total = st.f_blocks * st.f_frsize
|
|
free = st.f_bavail * st.f_frsize
|
|
used_pct = ((total - free) / total * 100) if total > 0 else 0
|
|
|
|
if used_pct >= DISK_CRITICAL_PCT:
|
|
self.bus.emit("RESOURCE_CRITICAL",
|
|
{"resource": "disk", "used_pct": round(used_pct, 1),
|
|
"path": storage_path},
|
|
source_module="resource_monitor")
|
|
elif used_pct >= DISK_WARNING_PCT:
|
|
self.bus.emit("RESOURCE_WARNING",
|
|
{"resource": "disk", "used_pct": round(used_pct, 1),
|
|
"path": storage_path},
|
|
source_module="resource_monitor")
|
|
|
|
# ------------------------------------------------------------------
|
|
# Thermal
|
|
# ------------------------------------------------------------------
|
|
|
|
def _check_thermal(self) -> None:
|
|
"""Read CPU temperature and emit warnings / shed modules."""
|
|
temp = self.get_temperature()
|
|
if temp is None:
|
|
return
|
|
|
|
if temp >= THERMAL_CRITICAL:
|
|
logger.critical("Thermal critical: %.1fC — shedding modules", temp)
|
|
self.bus.emit("THERMAL_WARNING",
|
|
{"temperature": temp, "action": "shedding"},
|
|
source_module="resource_monitor")
|
|
self._thermal_shed()
|
|
elif temp >= THERMAL_WARNING:
|
|
if not self._thermal_warned:
|
|
logger.warning("Thermal warning: %.1fC", temp)
|
|
self.bus.emit("THERMAL_WARNING",
|
|
{"temperature": temp, "action": "warning"},
|
|
source_module="resource_monitor")
|
|
self._thermal_warned = True
|
|
else:
|
|
self._thermal_warned = False
|
|
|
|
def _thermal_shed(self) -> None:
|
|
"""Shed (stop) active modules to reduce heat — keep passive + core."""
|
|
if self.engine is None:
|
|
return
|
|
|
|
status = self.engine.get_status()
|
|
# Stop active modules first (highest priority number = least important)
|
|
shedded = []
|
|
for name, info in sorted(status.items(),
|
|
key=lambda x: x[1].get("priority", 0), reverse=True):
|
|
if not info.get("running"):
|
|
continue
|
|
if info.get("type") == "active":
|
|
self.engine.stop(name)
|
|
shedded.append(name)
|
|
|
|
if shedded:
|
|
logger.warning("Thermal shed: stopped %s", shedded)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Readings
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def get_system_memory() -> dict:
|
|
"""Read system memory from /proc/meminfo."""
|
|
info = {"total_mb": 0, "available_mb": 0, "used_mb": 0}
|
|
try:
|
|
with open("/proc/meminfo", "r") as f:
|
|
for line in f:
|
|
if line.startswith("MemTotal:"):
|
|
info["total_mb"] = int(line.split()[1]) / 1024
|
|
elif line.startswith("MemAvailable:"):
|
|
info["available_mb"] = int(line.split()[1]) / 1024
|
|
info["used_mb"] = info["total_mb"] - info["available_mb"]
|
|
except (FileNotFoundError, PermissionError, ValueError):
|
|
pass
|
|
return {k: round(v, 1) for k, v in info.items()}
|
|
|
|
def get_temperature(self) -> Optional[float]:
|
|
"""Read CPU temperature in Celsius."""
|
|
if not self._thermal_path:
|
|
return None
|
|
try:
|
|
with open(self._thermal_path, "r") as f:
|
|
raw = f.read().strip()
|
|
temp = int(raw) / 1000.0 # millidegrees
|
|
return round(temp, 1)
|
|
except (FileNotFoundError, PermissionError, ValueError):
|
|
return None
|
|
|
|
@staticmethod
|
|
def get_cpu_usage() -> float:
|
|
"""Get overall CPU usage percentage from /proc/stat (1-second sample)."""
|
|
def _read():
|
|
with open("/proc/stat", "r") as f:
|
|
parts = f.readline().split()
|
|
return [int(x) for x in parts[1:]]
|
|
|
|
try:
|
|
t1 = _read()
|
|
time.sleep(1.0)
|
|
t2 = _read()
|
|
d = [t2[i] - t1[i] for i in range(len(t1))]
|
|
idle = d[3] + (d[4] if len(d) > 4 else 0)
|
|
total = sum(d)
|
|
return round((1.0 - idle / total) * 100, 1) if total > 0 else 0.0
|
|
except Exception:
|
|
return 0.0
|
|
|
|
def get_process_resources(self, pid: int) -> dict:
|
|
"""Get RAM/CPU for a specific PID."""
|
|
from core.tool_manager import ToolManager
|
|
return ToolManager._read_proc_stats(pid)
|
|
|
|
def get_snapshot(self) -> dict:
|
|
"""Full resource snapshot."""
|
|
return {
|
|
"system_memory": self.get_system_memory(),
|
|
"temperature": self.get_temperature(),
|
|
"tier": self._tier,
|
|
"tier_limits": self._tier_limits,
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _find_thermal_zone() -> Optional[str]:
|
|
"""Find the CPU thermal zone file."""
|
|
base = Path("/sys/class/thermal")
|
|
if not base.exists():
|
|
return None
|
|
|
|
for zone in sorted(base.iterdir()):
|
|
type_file = zone / "type"
|
|
temp_file = zone / "temp"
|
|
if not temp_file.exists():
|
|
continue
|
|
try:
|
|
ztype = type_file.read_text().strip().lower()
|
|
if any(k in ztype for k in ("cpu", "soc", "x86_pkg")):
|
|
return str(temp_file)
|
|
except (PermissionError, FileNotFoundError):
|
|
continue
|
|
|
|
# Fallback: first thermal zone with a temp file
|
|
for zone in sorted(base.iterdir()):
|
|
temp_file = zone / "temp"
|
|
if temp_file.exists():
|
|
return str(temp_file)
|
|
|
|
return None
|