Initial commit - Phase 3/4
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
824
app/backend/services/system_service.py
Normal file
824
app/backend/services/system_service.py
Normal file
@@ -0,0 +1,824 @@
|
||||
"""System Service Layer.
|
||||
|
||||
This service provides system operations (shutdown, restart, info) that can be
|
||||
consumed by multiple interfaces (REST API, BLE GATT, etc.).
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import platform
|
||||
import psutil
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict, Any
|
||||
from pathlib import Path
|
||||
|
||||
from .pm3_service import PM3ServiceError, PM3ServiceResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class CPUCoreInfo:
|
||||
"""Per-core CPU information."""
|
||||
core_id: int
|
||||
percent: float
|
||||
online: bool = True # Whether this core is currently online
|
||||
|
||||
|
||||
@dataclass
|
||||
class PiModelInfo:
|
||||
"""Raspberry Pi model information."""
|
||||
model: str # Full model string (e.g., "Raspberry Pi Zero 2 W Rev 1.0")
|
||||
model_short: str # Short name (e.g., "Pi Zero 2 W")
|
||||
total_cores: int # Total physical cores
|
||||
default_active_cores: int # Recommended default active cores
|
||||
min_cores: int # Minimum cores (always 1)
|
||||
max_cores: int # Maximum configurable cores
|
||||
|
||||
|
||||
@dataclass
|
||||
class CPUCoresConfig:
|
||||
"""CPU cores configuration."""
|
||||
total_cores: int
|
||||
online_cores: int
|
||||
configurable_cores: list # List of core IDs that can be toggled (usually all except 0)
|
||||
pi_model: Optional[PiModelInfo] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemInfo:
|
||||
"""System information data."""
|
||||
hostname: str
|
||||
platform: str
|
||||
platform_version: str
|
||||
cpu_count: int
|
||||
cpu_percent: float
|
||||
cpu_per_core: list # List of CPUCoreInfo
|
||||
memory_total: int
|
||||
memory_used: int
|
||||
memory_percent: float
|
||||
disk_total: int
|
||||
disk_used: int
|
||||
disk_percent: float
|
||||
uptime: float
|
||||
temperature: Optional[float] = None
|
||||
load_average: Optional[tuple] = None # 1, 5, 15 min load averages
|
||||
|
||||
|
||||
class SystemService:
|
||||
"""System operations service.
|
||||
|
||||
Provides reusable business logic for:
|
||||
- System information queries
|
||||
- Shutdown/restart operations
|
||||
- Service log retrieval
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize system service."""
|
||||
pass
|
||||
|
||||
async def get_info(self) -> PM3ServiceResult:
|
||||
"""Get system information.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with system info (CPU, memory, disk, etc.)
|
||||
"""
|
||||
try:
|
||||
# Get CPU usage (blocking call, run in executor)
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Get per-core CPU percentages (requires percpu=True)
|
||||
# First call to initialize, then wait briefly for measurement
|
||||
await loop.run_in_executor(None, lambda: psutil.cpu_percent(percpu=True))
|
||||
await asyncio.sleep(0.1) # Brief pause for accurate measurement
|
||||
cpu_per_core_raw = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: psutil.cpu_percent(percpu=True)
|
||||
)
|
||||
|
||||
# Check which cores are online
|
||||
total_cores = psutil.cpu_count(logical=True) or 1
|
||||
core_online_status = {}
|
||||
for i in range(total_cores):
|
||||
online_file = Path(f"/sys/devices/system/cpu/cpu{i}/online")
|
||||
if i == 0:
|
||||
# Core 0 is always online
|
||||
core_online_status[i] = True
|
||||
elif online_file.exists():
|
||||
core_online_status[i] = online_file.read_text().strip() == "1"
|
||||
else:
|
||||
# File doesn't exist, core is always on
|
||||
core_online_status[i] = True
|
||||
|
||||
# Build per-core info with online status
|
||||
cpu_per_core = [
|
||||
CPUCoreInfo(
|
||||
core_id=i,
|
||||
percent=pct if core_online_status.get(i, True) else 0.0,
|
||||
online=core_online_status.get(i, True)
|
||||
)
|
||||
for i, pct in enumerate(cpu_per_core_raw)
|
||||
]
|
||||
|
||||
# Overall CPU percentage
|
||||
cpu_percent = sum(cpu_per_core_raw) / len(cpu_per_core_raw) if cpu_per_core_raw else 0.0
|
||||
|
||||
# Get load average (Unix only)
|
||||
try:
|
||||
load_average = os.getloadavg()
|
||||
except (OSError, AttributeError):
|
||||
load_average = None
|
||||
|
||||
# Get memory info
|
||||
memory = psutil.virtual_memory()
|
||||
|
||||
# Get disk info for root partition
|
||||
disk = psutil.disk_usage('/')
|
||||
|
||||
# Get system uptime
|
||||
boot_time = psutil.boot_time()
|
||||
uptime = psutil.time.time() - boot_time
|
||||
|
||||
# Try to get CPU temperature (Raspberry Pi specific)
|
||||
temperature = await self._get_cpu_temperature()
|
||||
|
||||
system_info = SystemInfo(
|
||||
hostname=platform.node(),
|
||||
platform=platform.system(),
|
||||
platform_version=platform.release(),
|
||||
cpu_count=psutil.cpu_count(),
|
||||
cpu_percent=cpu_percent,
|
||||
cpu_per_core=cpu_per_core,
|
||||
memory_total=memory.total,
|
||||
memory_used=memory.used,
|
||||
memory_percent=memory.percent,
|
||||
disk_total=disk.total,
|
||||
disk_used=disk.used,
|
||||
disk_percent=disk.percent,
|
||||
uptime=uptime,
|
||||
temperature=temperature,
|
||||
load_average=load_average
|
||||
)
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"hostname": system_info.hostname,
|
||||
"platform": system_info.platform,
|
||||
"platform_version": system_info.platform_version,
|
||||
"cpu": {
|
||||
"count": system_info.cpu_count,
|
||||
"percent": system_info.cpu_percent,
|
||||
"temperature": system_info.temperature,
|
||||
"per_core": [
|
||||
{"core_id": c.core_id, "percent": c.percent, "online": c.online}
|
||||
for c in system_info.cpu_per_core
|
||||
],
|
||||
"load_average": list(system_info.load_average) if system_info.load_average else None
|
||||
},
|
||||
"memory": {
|
||||
"total": system_info.memory_total,
|
||||
"used": system_info.memory_used,
|
||||
"percent": system_info.memory_percent
|
||||
},
|
||||
"disk": {
|
||||
"total": system_info.disk_total,
|
||||
"used": system_info.disk_used,
|
||||
"percent": system_info.disk_percent
|
||||
},
|
||||
"uptime": system_info.uptime
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="system_info_error",
|
||||
message="Failed to get system information",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def _get_cpu_temperature(self) -> Optional[float]:
|
||||
"""Get CPU temperature (Raspberry Pi specific).
|
||||
|
||||
Returns:
|
||||
Temperature in Celsius or None if unavailable
|
||||
"""
|
||||
try:
|
||||
temp_file = Path("/sys/class/thermal/thermal_zone0/temp")
|
||||
if temp_file.exists():
|
||||
temp_str = temp_file.read_text().strip()
|
||||
# Temperature is in millidegrees
|
||||
return float(temp_str) / 1000.0
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
async def shutdown(self, delay: int = 0) -> PM3ServiceResult:
|
||||
"""Initiate system shutdown.
|
||||
|
||||
Args:
|
||||
delay: Delay in seconds before shutdown (default: 0)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success/failure
|
||||
"""
|
||||
try:
|
||||
if delay > 0:
|
||||
# Schedule shutdown
|
||||
await self._run_command(f"sudo shutdown -h +{delay // 60}")
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": f"Shutdown scheduled in {delay} seconds",
|
||||
"delay": delay
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Immediate shutdown
|
||||
await self._run_command("sudo shutdown -h now")
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={"message": "Shutdown initiated"}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="shutdown_error",
|
||||
message="Failed to initiate shutdown",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def restart(self, delay: int = 0) -> PM3ServiceResult:
|
||||
"""Initiate system restart.
|
||||
|
||||
Args:
|
||||
delay: Delay in seconds before restart (default: 0)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success/failure
|
||||
"""
|
||||
try:
|
||||
if delay > 0:
|
||||
# Schedule restart
|
||||
await self._run_command(f"sudo shutdown -r +{delay // 60}")
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": f"Restart scheduled in {delay} seconds",
|
||||
"delay": delay
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Immediate restart
|
||||
await self._run_command("sudo shutdown -r now")
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={"message": "Restart initiated"}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="restart_error",
|
||||
message="Failed to initiate restart",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def cancel_shutdown(self) -> PM3ServiceResult:
|
||||
"""Cancel a scheduled shutdown or restart.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success/failure
|
||||
"""
|
||||
try:
|
||||
await self._run_command("sudo shutdown -c")
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={"message": "Shutdown/restart cancelled"}
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="cancel_shutdown_error",
|
||||
message="Failed to cancel shutdown",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def get_logs(
|
||||
self,
|
||||
service: str = "dangerous-pi",
|
||||
lines: int = 100
|
||||
) -> PM3ServiceResult:
|
||||
"""Get service logs using journalctl.
|
||||
|
||||
Args:
|
||||
service: Service name to get logs for
|
||||
lines: Number of lines to retrieve (default: 100)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with log content
|
||||
"""
|
||||
try:
|
||||
output = await self._run_command(
|
||||
f"sudo journalctl -u {service} -n {lines} --no-pager"
|
||||
)
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"service": service,
|
||||
"lines": lines,
|
||||
"logs": output
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="logs_error",
|
||||
message=f"Failed to get logs for service '{service}'",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def get_service_status(self, service: str) -> PM3ServiceResult:
|
||||
"""Get systemd service status.
|
||||
|
||||
Args:
|
||||
service: Service name
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with service status
|
||||
"""
|
||||
try:
|
||||
output = await self._run_command(
|
||||
f"sudo systemctl status {service}",
|
||||
check=False # Don't raise on non-zero exit
|
||||
)
|
||||
|
||||
# Parse status
|
||||
is_active = "active (running)" in output.lower()
|
||||
is_enabled = await self._is_service_enabled(service)
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"service": service,
|
||||
"active": is_active,
|
||||
"enabled": is_enabled,
|
||||
"status": output
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="service_status_error",
|
||||
message=f"Failed to get status for service '{service}'",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def _is_service_enabled(self, service: str) -> bool:
|
||||
"""Check if a systemd service is enabled.
|
||||
|
||||
Args:
|
||||
service: Service name
|
||||
|
||||
Returns:
|
||||
True if service is enabled
|
||||
"""
|
||||
try:
|
||||
output = await self._run_command(
|
||||
f"sudo systemctl is-enabled {service}",
|
||||
check=False
|
||||
)
|
||||
return output.strip() == "enabled"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _run_command(
|
||||
self,
|
||||
command: str,
|
||||
check: bool = True
|
||||
) -> str:
|
||||
"""Run a shell command asynchronously.
|
||||
|
||||
Args:
|
||||
command: Command to run
|
||||
check: Raise exception on non-zero exit code
|
||||
|
||||
Returns:
|
||||
Command output
|
||||
|
||||
Raises:
|
||||
RuntimeError: If command fails and check=True
|
||||
"""
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if check and process.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"Command failed with exit code {process.returncode}: "
|
||||
f"{stderr.decode()}"
|
||||
)
|
||||
|
||||
return stdout.decode()
|
||||
|
||||
# Pi model defaults: maps model substring to (short_name, default_active_cores, physical_cores)
|
||||
# default_active_cores: recommended cores for thermal/power management
|
||||
# physical_cores: actual hardware cores regardless of boot config
|
||||
PI_MODEL_DEFAULTS = {
|
||||
"Pi Zero 2": ("Pi Zero 2 W", 2, 4), # 4 cores, default to 2 for thermal
|
||||
"Pi Zero W": ("Pi Zero W", 1, 1), # Single core
|
||||
"Pi Zero": ("Pi Zero", 1, 1), # Single core
|
||||
"Pi 4": ("Pi 4", 4, 4),
|
||||
"Pi 3": ("Pi 3", 4, 4),
|
||||
"Pi 2": ("Pi 2", 4, 4),
|
||||
"Pi 5": ("Pi 5", 4, 4),
|
||||
}
|
||||
|
||||
def _get_physical_cores(self) -> int:
|
||||
"""Get the actual physical core count (not limited by maxcpus).
|
||||
|
||||
Reads from /sys/devices/system/cpu/possible to get the full range
|
||||
of possible CPUs regardless of boot-time restrictions.
|
||||
|
||||
Returns:
|
||||
Physical core count
|
||||
"""
|
||||
try:
|
||||
# /sys/devices/system/cpu/possible contains the full range like "0-3" for 4 cores
|
||||
possible_file = Path("/sys/devices/system/cpu/possible")
|
||||
if possible_file.exists():
|
||||
content = possible_file.read_text().strip()
|
||||
# Parse "0-3" format to get count of 4
|
||||
if '-' in content:
|
||||
start, end = content.split('-')
|
||||
return int(end) - int(start) + 1
|
||||
elif content.isdigit():
|
||||
return int(content) + 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to psutil (may be limited by maxcpus)
|
||||
return psutil.cpu_count(logical=True) or 1
|
||||
|
||||
async def get_pi_model(self) -> PM3ServiceResult:
|
||||
"""Detect Raspberry Pi model.
|
||||
|
||||
Reads from /proc/device-tree/model or /proc/cpuinfo.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with PiModelInfo data
|
||||
"""
|
||||
try:
|
||||
model_str = "Unknown"
|
||||
model_short = "Unknown"
|
||||
physical_cores = self._get_physical_cores()
|
||||
total_cores = physical_cores # Use physical cores as total
|
||||
default_cores = total_cores
|
||||
|
||||
# Try device tree first (most reliable)
|
||||
model_file = Path("/proc/device-tree/model")
|
||||
if model_file.exists():
|
||||
model_str = model_file.read_text().strip().rstrip('\x00')
|
||||
else:
|
||||
# Fallback to cpuinfo
|
||||
cpuinfo = Path("/proc/cpuinfo")
|
||||
if cpuinfo.exists():
|
||||
for line in cpuinfo.read_text().split('\n'):
|
||||
if line.startswith("Model"):
|
||||
model_str = line.split(':')[1].strip()
|
||||
break
|
||||
|
||||
# Determine short name and default/physical cores based on model
|
||||
for pattern, (short_name, def_cores, phys_cores) in self.PI_MODEL_DEFAULTS.items():
|
||||
if pattern in model_str:
|
||||
model_short = short_name
|
||||
# Use the known physical cores from model defaults if detected
|
||||
total_cores = max(physical_cores, phys_cores)
|
||||
default_cores = min(def_cores, total_cores)
|
||||
break
|
||||
else:
|
||||
# Not a known Pi model, use detected physical cores
|
||||
if "Raspberry" in model_str:
|
||||
model_short = "Raspberry Pi"
|
||||
else:
|
||||
model_short = model_str[:20] if len(model_str) > 20 else model_str
|
||||
|
||||
pi_model = PiModelInfo(
|
||||
model=model_str,
|
||||
model_short=model_short,
|
||||
total_cores=total_cores,
|
||||
default_active_cores=default_cores,
|
||||
min_cores=1,
|
||||
max_cores=total_cores
|
||||
)
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"model": pi_model.model,
|
||||
"model_short": pi_model.model_short,
|
||||
"total_cores": pi_model.total_cores,
|
||||
"default_active_cores": pi_model.default_active_cores,
|
||||
"min_cores": pi_model.min_cores,
|
||||
"max_cores": pi_model.max_cores
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="pi_model_error",
|
||||
message="Failed to detect Pi model",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def get_cpu_cores_config(self) -> PM3ServiceResult:
|
||||
"""Get CPU cores configuration.
|
||||
|
||||
Returns info about total cores, online cores, configured cores (from cmdline.txt),
|
||||
and Pi model with defaults.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with CPUCoresConfig data
|
||||
"""
|
||||
try:
|
||||
# Get physical core count (not limited by maxcpus)
|
||||
physical_cores = self._get_physical_cores()
|
||||
|
||||
# Get currently online cores (runtime state)
|
||||
# On Pi, we can use nproc or count from /proc/cpuinfo
|
||||
try:
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
["nproc"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
online_cores = int(result.stdout.strip()) if result.returncode == 0 else physical_cores
|
||||
except Exception:
|
||||
online_cores = physical_cores
|
||||
|
||||
# Get configured cores from cmdline.txt (boot-time setting)
|
||||
configured_cores = self._get_configured_maxcpus()
|
||||
|
||||
# Use physical cores as total (what the hardware actually has)
|
||||
total_cores = physical_cores
|
||||
|
||||
# Configurable cores are all cores except 0 (which is always on)
|
||||
configurable_cores = list(range(1, total_cores))
|
||||
|
||||
# Get Pi model info
|
||||
model_result = await self.get_pi_model()
|
||||
pi_model_data = model_result.data if model_result.success else None
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"total_cores": total_cores,
|
||||
"online_cores": online_cores,
|
||||
"configured_cores": configured_cores, # From cmdline.txt
|
||||
"configurable_cores": configurable_cores,
|
||||
"pi_model": pi_model_data
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="cpu_cores_error",
|
||||
message="Failed to get CPU cores config",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
# Possible locations for cmdline.txt (varies by Pi OS version)
|
||||
CMDLINE_PATHS = [
|
||||
Path("/boot/firmware/cmdline.txt"), # Newer Pi OS (Bookworm+)
|
||||
Path("/boot/cmdline.txt"), # Older Pi OS
|
||||
]
|
||||
|
||||
def _find_cmdline_path(self) -> Optional[Path]:
|
||||
"""Find the cmdline.txt file location.
|
||||
|
||||
Returns:
|
||||
Path to cmdline.txt or None if not found
|
||||
"""
|
||||
for path in self.CMDLINE_PATHS:
|
||||
if path.exists():
|
||||
return path
|
||||
return None
|
||||
|
||||
def _get_configured_maxcpus(self) -> Optional[int]:
|
||||
"""Read the maxcpus value from cmdline.txt.
|
||||
|
||||
Returns:
|
||||
Configured maxcpus value, or None if not set
|
||||
"""
|
||||
cmdline_path = self._find_cmdline_path()
|
||||
if not cmdline_path:
|
||||
return None
|
||||
|
||||
try:
|
||||
content = cmdline_path.read_text().strip()
|
||||
# Parse cmdline parameters
|
||||
for param in content.split():
|
||||
if param.startswith("maxcpus="):
|
||||
return int(param.split("=")[1])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
async def set_cpu_cores(
|
||||
self,
|
||||
num_cores: int,
|
||||
persist: bool = True,
|
||||
reboot: bool = True
|
||||
) -> PM3ServiceResult:
|
||||
"""Set the number of active CPU cores via kernel parameter.
|
||||
|
||||
On Pi Zero 2 W (and other ARM systems), CPU hotplug via sysfs doesn't work.
|
||||
Instead, we modify the maxcpus=N kernel parameter in /boot/cmdline.txt.
|
||||
This requires a reboot to take effect.
|
||||
|
||||
Args:
|
||||
num_cores: Number of cores to use (1 to total_cores)
|
||||
persist: Must be True (always persists to cmdline.txt)
|
||||
reboot: If True, reboot the system immediately
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success/failure
|
||||
"""
|
||||
try:
|
||||
# Use physical cores (not limited by current maxcpus setting)
|
||||
total_cores = self._get_physical_cores()
|
||||
|
||||
# Validate input
|
||||
if num_cores < 1:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="invalid_cores",
|
||||
message="Must have at least 1 core active",
|
||||
details=f"Requested: {num_cores}"
|
||||
)
|
||||
)
|
||||
|
||||
if num_cores > total_cores:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="invalid_cores",
|
||||
message=f"Cannot exceed {total_cores} cores",
|
||||
details=f"Requested: {num_cores}, Available: {total_cores}"
|
||||
)
|
||||
)
|
||||
|
||||
# Find cmdline.txt
|
||||
cmdline_path = self._find_cmdline_path()
|
||||
if not cmdline_path:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="cmdline_not_found",
|
||||
message="Could not find /boot/cmdline.txt or /boot/firmware/cmdline.txt",
|
||||
details="This feature requires a Raspberry Pi with standard boot configuration"
|
||||
)
|
||||
)
|
||||
|
||||
# Update cmdline.txt with maxcpus parameter
|
||||
update_result = await self._update_cmdline_maxcpus(cmdline_path, num_cores)
|
||||
if not update_result.success:
|
||||
return update_result
|
||||
|
||||
# Reboot if requested
|
||||
if reboot:
|
||||
await self._run_command("sudo shutdown -r +0", check=False)
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": f"CPU cores set to {num_cores}. Rebooting...",
|
||||
"requested": num_cores,
|
||||
"rebooting": True
|
||||
}
|
||||
)
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": f"CPU cores set to {num_cores}. Reboot required to apply.",
|
||||
"requested": num_cores,
|
||||
"rebooting": False,
|
||||
"reboot_required": True
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="set_cores_error",
|
||||
message="Failed to set CPU cores",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def _update_cmdline_maxcpus(
|
||||
self,
|
||||
cmdline_path: Path,
|
||||
num_cores: int
|
||||
) -> PM3ServiceResult:
|
||||
"""Update the maxcpus parameter in cmdline.txt.
|
||||
|
||||
Args:
|
||||
cmdline_path: Path to cmdline.txt
|
||||
num_cores: Number of cores to set
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success/failure
|
||||
"""
|
||||
try:
|
||||
# Read current cmdline
|
||||
content = cmdline_path.read_text().strip()
|
||||
|
||||
# Parse and update parameters
|
||||
params = content.split()
|
||||
new_params = []
|
||||
maxcpus_found = False
|
||||
|
||||
for param in params:
|
||||
if param.startswith("maxcpus="):
|
||||
# Replace existing maxcpus
|
||||
new_params.append(f"maxcpus={num_cores}")
|
||||
maxcpus_found = True
|
||||
else:
|
||||
new_params.append(param)
|
||||
|
||||
# Add maxcpus if not present
|
||||
if not maxcpus_found:
|
||||
new_params.append(f"maxcpus={num_cores}")
|
||||
|
||||
new_content = " ".join(new_params)
|
||||
|
||||
# Backup original
|
||||
await self._run_command(
|
||||
f"sudo cp {cmdline_path} {cmdline_path}.bak",
|
||||
check=True
|
||||
)
|
||||
|
||||
# Write new cmdline.txt
|
||||
# Use a temp file and move to avoid corruption
|
||||
await self._run_command(
|
||||
f"echo '{new_content}' | sudo tee {cmdline_path}.new > /dev/null",
|
||||
check=True
|
||||
)
|
||||
await self._run_command(
|
||||
f"sudo mv {cmdline_path}.new {cmdline_path}",
|
||||
check=True
|
||||
)
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={"message": f"Updated {cmdline_path} with maxcpus={num_cores}"}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="cmdline_update_error",
|
||||
message="Failed to update cmdline.txt",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
def get_configured_cpu_cores(self) -> Optional[int]:
|
||||
"""Get the configured CPU cores from cmdline.txt.
|
||||
|
||||
Returns:
|
||||
Configured maxcpus value, or None if not explicitly set
|
||||
"""
|
||||
return self._get_configured_maxcpus()
|
||||
Reference in New Issue
Block a user