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:
35
app/backend/services/__init__.py
Normal file
35
app/backend/services/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Service layer for Dangerous Pi.
|
||||
|
||||
The service layer provides reusable business logic that can be consumed by
|
||||
multiple interfaces (REST API, BLE GATT, plugins, etc.).
|
||||
|
||||
Services handle:
|
||||
- Business logic and validation
|
||||
- Session management
|
||||
- Error handling and formatting
|
||||
- Cross-cutting concerns
|
||||
|
||||
This pattern prevents code duplication across interfaces.
|
||||
"""
|
||||
|
||||
from .pm3_service import PM3Service, PM3ServiceResult, PM3ServiceError
|
||||
from .system_service import SystemService
|
||||
from .wifi_service import WiFiService
|
||||
from .update_service import UpdateService
|
||||
from .container import ServiceContainer, container
|
||||
|
||||
__all__ = [
|
||||
# PM3 Service
|
||||
"PM3Service",
|
||||
"PM3ServiceResult",
|
||||
"PM3ServiceError",
|
||||
|
||||
# Other Services
|
||||
"SystemService",
|
||||
"WiFiService",
|
||||
"UpdateService",
|
||||
|
||||
# Service Container
|
||||
"ServiceContainer",
|
||||
"container",
|
||||
]
|
||||
192
app/backend/services/container.py
Normal file
192
app/backend/services/container.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Service Container for Dependency Injection.
|
||||
|
||||
This module provides a singleton container that manages service instances
|
||||
and their dependencies, ensuring consistent state across all interfaces
|
||||
(REST API, BLE GATT, plugins, etc.).
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from ..workers.pm3_worker import PM3Worker, SubprocessPM3Worker
|
||||
from ..managers.session_manager import SessionManager
|
||||
from ..managers.wifi_manager import WiFiManager
|
||||
from ..managers.update_manager import get_update_manager
|
||||
from ..managers.pm3_device_manager import PM3DeviceManager
|
||||
|
||||
from .pm3_service import PM3Service
|
||||
from .system_service import SystemService
|
||||
from .wifi_service import WiFiService
|
||||
from .update_service import UpdateService
|
||||
|
||||
|
||||
class ServiceContainer:
|
||||
"""Dependency injection container for services.
|
||||
|
||||
This container:
|
||||
- Creates and manages singleton instances of services
|
||||
- Injects shared dependencies (workers, managers)
|
||||
- Ensures consistent state across all interfaces
|
||||
- Provides centralized access to services
|
||||
|
||||
Usage:
|
||||
# In REST API
|
||||
from app.backend.services.container import container
|
||||
result = await container.pm3_service.execute_command(...)
|
||||
|
||||
# In BLE GATT
|
||||
from app.backend.services.container import container
|
||||
result = await container.pm3_service.execute_command(...)
|
||||
|
||||
# Both use the same service instance!
|
||||
"""
|
||||
|
||||
_instance: Optional['ServiceContainer'] = None
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize service container with shared dependencies."""
|
||||
if ServiceContainer._instance is not None:
|
||||
raise RuntimeError(
|
||||
"ServiceContainer is a singleton. Use container.get_instance() "
|
||||
"or import the global 'container' instance."
|
||||
)
|
||||
|
||||
# Create shared worker/manager instances
|
||||
# These are the low-level components that services will use
|
||||
# Use PM3Worker with SWIG bindings for better performance
|
||||
# Falls back to SubprocessPM3Worker if SWIG import fails
|
||||
try:
|
||||
self._pm3_worker = PM3Worker() # Try SWIG bindings first
|
||||
except Exception:
|
||||
self._pm3_worker = SubprocessPM3Worker() # Fallback to subprocess
|
||||
self._pm3_device_manager = PM3DeviceManager() # NEW: Multi-device manager
|
||||
self._session_manager = SessionManager()
|
||||
self._wifi_manager = WiFiManager()
|
||||
self._update_manager = get_update_manager()
|
||||
|
||||
# Create service instances with injected dependencies
|
||||
self._pm3_service = PM3Service(
|
||||
pm3_worker=self._pm3_worker,
|
||||
session_manager=self._session_manager,
|
||||
device_manager=self._pm3_device_manager # NEW: Multi-device support
|
||||
)
|
||||
|
||||
self._system_service = SystemService()
|
||||
|
||||
self._wifi_service = WiFiService(
|
||||
wifi_manager=self._wifi_manager
|
||||
)
|
||||
|
||||
self._update_service = UpdateService(
|
||||
update_manager=self._update_manager
|
||||
)
|
||||
|
||||
# Note: WorkflowService will be added in Phase 5
|
||||
# self._workflow_service = WorkflowService(
|
||||
# pm3_service=self._pm3_service
|
||||
# )
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> 'ServiceContainer':
|
||||
"""Get the singleton service container instance.
|
||||
|
||||
Returns:
|
||||
ServiceContainer instance
|
||||
"""
|
||||
if cls._instance is None:
|
||||
cls._instance = ServiceContainer()
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def reset_instance(cls):
|
||||
"""Reset the singleton instance.
|
||||
|
||||
This is primarily used for testing purposes.
|
||||
"""
|
||||
cls._instance = None
|
||||
|
||||
# Service properties (read-only access)
|
||||
|
||||
@property
|
||||
def pm3_service(self) -> PM3Service:
|
||||
"""Get PM3 service instance.
|
||||
|
||||
Returns:
|
||||
Shared PM3Service instance
|
||||
"""
|
||||
return self._pm3_service
|
||||
|
||||
@property
|
||||
def system_service(self) -> SystemService:
|
||||
"""Get system service instance.
|
||||
|
||||
Returns:
|
||||
Shared SystemService instance
|
||||
"""
|
||||
return self._system_service
|
||||
|
||||
@property
|
||||
def wifi_service(self) -> WiFiService:
|
||||
"""Get WiFi service instance.
|
||||
|
||||
Returns:
|
||||
Shared WiFiService instance
|
||||
"""
|
||||
return self._wifi_service
|
||||
|
||||
@property
|
||||
def update_service(self) -> UpdateService:
|
||||
"""Get update service instance.
|
||||
|
||||
Returns:
|
||||
Shared UpdateService instance
|
||||
"""
|
||||
return self._update_service
|
||||
|
||||
# Manager/Worker access (for cases where direct access is needed)
|
||||
|
||||
@property
|
||||
def pm3_worker(self) -> PM3Worker:
|
||||
"""Get PM3 worker instance.
|
||||
|
||||
Returns:
|
||||
Shared PM3Worker instance
|
||||
"""
|
||||
return self._pm3_worker
|
||||
|
||||
@property
|
||||
def session_manager(self) -> SessionManager:
|
||||
"""Get session manager instance.
|
||||
|
||||
Returns:
|
||||
Shared SessionManager instance
|
||||
"""
|
||||
return self._session_manager
|
||||
|
||||
@property
|
||||
def wifi_manager(self) -> WiFiManager:
|
||||
"""Get WiFi manager instance.
|
||||
|
||||
Returns:
|
||||
Shared WiFiManager instance
|
||||
"""
|
||||
return self._wifi_manager
|
||||
|
||||
@property
|
||||
def pm3_device_manager(self) -> PM3DeviceManager:
|
||||
"""Get PM3 device manager instance.
|
||||
|
||||
Returns:
|
||||
Shared PM3DeviceManager instance for multi-device support
|
||||
"""
|
||||
return self._pm3_device_manager
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
# Import this throughout the codebase for consistent service access
|
||||
container = ServiceContainer.get_instance()
|
||||
|
||||
|
||||
# Convenience exports for common services
|
||||
__all__ = [
|
||||
'ServiceContainer',
|
||||
'container',
|
||||
]
|
||||
202
app/backend/services/hardware_service.py
Normal file
202
app/backend/services/hardware_service.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""Hardware service for plugin hardware access.
|
||||
|
||||
Provides controlled access to hardware interfaces (I2C, SPI, GPIO, Serial)
|
||||
with permission checking and logging.
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HardwareService:
|
||||
"""Service for controlled hardware access.
|
||||
|
||||
Provides safe wrappers for hardware interfaces that:
|
||||
- Log all access attempts
|
||||
- Handle import errors gracefully (for non-Pi systems)
|
||||
- Return None if hardware is unavailable
|
||||
|
||||
Permission checking is done by PluginBase before calling these methods.
|
||||
"""
|
||||
|
||||
# Track which plugins have accessed which hardware
|
||||
_access_log: dict[str, list[str]] = {}
|
||||
|
||||
@classmethod
|
||||
def _log_access(cls, plugin_id: str, hardware_type: str) -> None:
|
||||
"""Log hardware access for auditing.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin requesting access
|
||||
hardware_type: Type of hardware (i2c, gpio, spi, serial)
|
||||
"""
|
||||
if plugin_id not in cls._access_log:
|
||||
cls._access_log[plugin_id] = []
|
||||
if hardware_type not in cls._access_log[plugin_id]:
|
||||
cls._access_log[plugin_id].append(hardware_type)
|
||||
logger.info(f"Plugin '{plugin_id}' accessed {hardware_type}")
|
||||
|
||||
@classmethod
|
||||
def get_access_log(cls) -> dict[str, list[str]]:
|
||||
"""Get the hardware access log.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping plugin IDs to list of accessed hardware types
|
||||
"""
|
||||
return cls._access_log.copy()
|
||||
|
||||
@staticmethod
|
||||
def get_i2c_bus(plugin_id: str, bus: int = 1) -> Optional[Any]:
|
||||
"""Get I2C bus for a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin requesting access (for logging)
|
||||
bus: I2C bus number (default: 1 for Raspberry Pi)
|
||||
|
||||
Returns:
|
||||
SMBus instance or None if unavailable
|
||||
"""
|
||||
HardwareService._log_access(plugin_id, "i2c")
|
||||
|
||||
try:
|
||||
from smbus2 import SMBus
|
||||
return SMBus(bus)
|
||||
except ImportError:
|
||||
logger.warning("smbus2 not available - I2C access disabled")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open I2C bus {bus}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_gpio(plugin_id: str) -> Optional[Any]:
|
||||
"""Get GPIO access for a plugin.
|
||||
|
||||
Returns the RPi.GPIO module if available.
|
||||
Plugin is responsible for setup/cleanup.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin requesting access (for logging)
|
||||
|
||||
Returns:
|
||||
GPIO module or None if unavailable
|
||||
"""
|
||||
HardwareService._log_access(plugin_id, "gpio")
|
||||
|
||||
try:
|
||||
import RPi.GPIO as GPIO
|
||||
return GPIO
|
||||
except ImportError:
|
||||
# Try gpiozero as fallback
|
||||
try:
|
||||
import gpiozero
|
||||
logger.info("Using gpiozero instead of RPi.GPIO")
|
||||
return gpiozero
|
||||
except ImportError:
|
||||
logger.warning("No GPIO library available (RPi.GPIO or gpiozero)")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to access GPIO: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_spi(plugin_id: str, bus: int = 0, device: int = 0) -> Optional[Any]:
|
||||
"""Get SPI device for a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin requesting access (for logging)
|
||||
bus: SPI bus number (default: 0)
|
||||
device: SPI device/chip select (default: 0)
|
||||
|
||||
Returns:
|
||||
SpiDev instance or None if unavailable
|
||||
"""
|
||||
HardwareService._log_access(plugin_id, "spi")
|
||||
|
||||
try:
|
||||
import spidev
|
||||
spi = spidev.SpiDev()
|
||||
spi.open(bus, device)
|
||||
return spi
|
||||
except ImportError:
|
||||
logger.warning("spidev not available - SPI access disabled")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open SPI bus {bus} device {device}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_serial(
|
||||
plugin_id: str,
|
||||
port: str,
|
||||
baudrate: int = 9600,
|
||||
timeout: float = 1.0
|
||||
) -> Optional[Any]:
|
||||
"""Get serial port for a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin requesting access (for logging)
|
||||
port: Serial port path (e.g., '/dev/ttyUSB0')
|
||||
baudrate: Baud rate (default: 9600)
|
||||
timeout: Read timeout in seconds (default: 1.0)
|
||||
|
||||
Returns:
|
||||
Serial instance or None if unavailable
|
||||
"""
|
||||
HardwareService._log_access(plugin_id, "serial")
|
||||
|
||||
try:
|
||||
import serial
|
||||
return serial.Serial(port, baudrate, timeout=timeout)
|
||||
except ImportError:
|
||||
logger.warning("pyserial not available - serial access disabled")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open serial port {port}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def is_hardware_available(hardware_type: str) -> bool:
|
||||
"""Check if a hardware type is available on this system.
|
||||
|
||||
Args:
|
||||
hardware_type: One of 'i2c', 'gpio', 'spi', 'serial'
|
||||
|
||||
Returns:
|
||||
True if the hardware library is importable
|
||||
"""
|
||||
try:
|
||||
if hardware_type == "i2c":
|
||||
from smbus2 import SMBus
|
||||
return True
|
||||
elif hardware_type == "gpio":
|
||||
try:
|
||||
import RPi.GPIO
|
||||
return True
|
||||
except ImportError:
|
||||
import gpiozero
|
||||
return True
|
||||
elif hardware_type == "spi":
|
||||
import spidev
|
||||
return True
|
||||
elif hardware_type == "serial":
|
||||
import serial
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_available_hardware() -> list[str]:
|
||||
"""Get list of available hardware types.
|
||||
|
||||
Returns:
|
||||
List of available hardware type names
|
||||
"""
|
||||
available = []
|
||||
for hw_type in ["i2c", "gpio", "spi", "serial"]:
|
||||
if HardwareService.is_hardware_available(hw_type):
|
||||
available.append(hw_type)
|
||||
return available
|
||||
660
app/backend/services/pm3_service.py
Normal file
660
app/backend/services/pm3_service.py
Normal file
@@ -0,0 +1,660 @@
|
||||
"""PM3 Service Layer.
|
||||
|
||||
This service encapsulates all PM3-related business logic and is used by
|
||||
both REST API and BLE GATT handlers to avoid code duplication.
|
||||
"""
|
||||
from typing import Optional, Dict, Any, List
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..workers.pm3_worker import PM3Worker, PM3CommandResult
|
||||
from ..managers.session_manager import SessionManager
|
||||
from ..managers.pm3_device_manager import PM3DeviceManager, PM3Device, DeviceStatus
|
||||
|
||||
|
||||
@dataclass
|
||||
class PM3ServiceError:
|
||||
"""Error response from PM3 service."""
|
||||
code: str # "session_locked", "pm3_not_connected", "command_failed"
|
||||
message: str
|
||||
details: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PM3ServiceResult:
|
||||
"""Result from PM3 service operations."""
|
||||
success: bool
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
error: Optional[PM3ServiceError] = None
|
||||
|
||||
|
||||
class PM3Service:
|
||||
"""PM3 service layer for command execution and status queries.
|
||||
|
||||
This service can be used by multiple interfaces:
|
||||
- REST API (FastAPI endpoints)
|
||||
- BLE GATT (Bluetooth handlers)
|
||||
- Plugin system (future)
|
||||
|
||||
It encapsulates:
|
||||
- Session validation
|
||||
- PM3 command execution
|
||||
- Session management
|
||||
- Response formatting
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pm3_worker: Optional[PM3Worker] = None,
|
||||
session_manager: Optional[SessionManager] = None,
|
||||
device_manager: Optional[PM3DeviceManager] = None
|
||||
):
|
||||
"""Initialize PM3 service.
|
||||
|
||||
Args:
|
||||
pm3_worker: PM3 worker instance (legacy, kept for backward compatibility)
|
||||
session_manager: Session manager instance (creates new if not provided)
|
||||
device_manager: PM3 device manager for multi-device support (optional)
|
||||
"""
|
||||
self.pm3_worker = pm3_worker or PM3Worker() # Legacy single-device support
|
||||
self.session_manager = session_manager or SessionManager()
|
||||
self.device_manager = device_manager # Multi-device support (optional)
|
||||
|
||||
async def execute_command(
|
||||
self,
|
||||
command: str,
|
||||
session_id: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
device_id: Optional[str] = None
|
||||
) -> PM3ServiceResult:
|
||||
"""Execute a PM3 command with session validation.
|
||||
|
||||
This method handles:
|
||||
1. Session validation
|
||||
2. Command execution
|
||||
3. Session activity updates
|
||||
4. Error handling
|
||||
|
||||
Args:
|
||||
command: PM3 command to execute
|
||||
session_id: Optional session ID for multi-user support
|
||||
timeout: Optional command timeout in seconds
|
||||
device_id: Optional device ID for multi-device support (uses legacy worker if not provided)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with success status and data/error
|
||||
"""
|
||||
# Determine which worker to use
|
||||
worker = None
|
||||
device_path = None
|
||||
|
||||
if device_id and self.device_manager:
|
||||
# Multi-device mode: get device from device manager
|
||||
device = await self.device_manager.get_device(device_id)
|
||||
if not device:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="device_not_found",
|
||||
message=f"Device {device_id} not found",
|
||||
details="Device may have been disconnected"
|
||||
)
|
||||
)
|
||||
|
||||
if not device.worker:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="device_no_worker",
|
||||
message=f"Device {device_id} has no worker instance",
|
||||
details="Device may not be fully initialized"
|
||||
)
|
||||
)
|
||||
|
||||
worker = device.worker
|
||||
device_path = device.device_path
|
||||
else:
|
||||
# Legacy single-device mode
|
||||
worker = self.pm3_worker
|
||||
device_path = self.pm3_worker.device_path
|
||||
|
||||
# 1. Validate session (per-device)
|
||||
if not self.session_manager.can_execute(session_id, device_id):
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="session_locked",
|
||||
message=f"Another session is active for device {device_id or 'default'}",
|
||||
details="Please take over the session or wait for it to expire"
|
||||
)
|
||||
)
|
||||
|
||||
# 2. Check PM3 connection
|
||||
if not await worker.is_connected():
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="pm3_not_connected",
|
||||
message="Proxmark3 not connected",
|
||||
details=f"Device path: {device_path}"
|
||||
)
|
||||
)
|
||||
|
||||
# 3. Execute command
|
||||
try:
|
||||
result = await worker.execute_command(command)
|
||||
|
||||
# 4. Update session activity
|
||||
if session_id:
|
||||
self.session_manager.update_activity(session_id, device_id)
|
||||
|
||||
# 5. Return result
|
||||
if result.success:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"output": result.output,
|
||||
"command": command,
|
||||
"session_id": session_id,
|
||||
"device_id": device_id
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Include both error message and output for better diagnostics
|
||||
# PM3 CLI often outputs error details to stdout
|
||||
error_details = result.error or ""
|
||||
if result.output:
|
||||
error_details = f"{error_details}\nOutput: {result.output}" if error_details else result.output
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="command_failed",
|
||||
message="PM3 command failed",
|
||||
details=error_details
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="execution_error",
|
||||
message="Command execution error",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def get_status(self, device_id: Optional[str] = None) -> PM3ServiceResult:
|
||||
"""Get PM3 device status.
|
||||
|
||||
Args:
|
||||
device_id: Optional device ID. If None and device_manager exists, returns all devices.
|
||||
If None and no device_manager, returns legacy single device status.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with connection status, version, etc.
|
||||
"""
|
||||
try:
|
||||
# Multi-device mode: return all devices or specific device
|
||||
if device_id is None and self.device_manager:
|
||||
# Return status for all devices
|
||||
devices = await self.device_manager.get_all_devices()
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"devices": [
|
||||
{
|
||||
"device_id": d.device_id,
|
||||
"device_path": d.device_path,
|
||||
"friendly_name": d.friendly_name or d.device_path.split('/')[-1],
|
||||
"serial_number": d.serial_number,
|
||||
"connected": d.status == DeviceStatus.CONNECTED,
|
||||
"in_use": d.status == DeviceStatus.IN_USE,
|
||||
"status": d.status.value,
|
||||
"firmware_info": {
|
||||
"bootrom_version": d.firmware_info.bootrom_version,
|
||||
"os_version": d.firmware_info.os_version,
|
||||
"compatible": d.firmware_info.compatible,
|
||||
},
|
||||
"last_seen": d.last_seen.isoformat(),
|
||||
}
|
||||
for d in devices
|
||||
]
|
||||
}
|
||||
)
|
||||
elif device_id and self.device_manager:
|
||||
# Return status for specific device
|
||||
device = await self.device_manager.get_device(device_id)
|
||||
if not device:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="device_not_found",
|
||||
message=f"Device {device_id} not found"
|
||||
)
|
||||
)
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"device_id": device.device_id,
|
||||
"device_path": device.device_path,
|
||||
"friendly_name": device.friendly_name or device.device_path.split('/')[-1],
|
||||
"serial_number": device.serial_number,
|
||||
"connected": device.status == DeviceStatus.CONNECTED,
|
||||
"in_use": device.status == DeviceStatus.IN_USE,
|
||||
"status": device.status.value,
|
||||
"firmware_info": {
|
||||
"bootrom_version": device.firmware_info.bootrom_version,
|
||||
"os_version": device.firmware_info.os_version,
|
||||
"compatible": device.firmware_info.compatible,
|
||||
},
|
||||
"last_seen": device.last_seen.isoformat(),
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Legacy single-device mode
|
||||
is_connected = await self.pm3_worker.is_connected()
|
||||
version = None
|
||||
|
||||
if is_connected:
|
||||
# Try to get version info
|
||||
result = await self.pm3_worker.execute_command("hw version")
|
||||
if result.success:
|
||||
version = result.output.split("\n")[0] if result.output else None
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"connected": is_connected,
|
||||
"device_path": self.pm3_worker.device_path,
|
||||
"version": version,
|
||||
"session_active": self.session_manager.has_active_session()
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="status_error",
|
||||
message="Failed to get PM3 status",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def connect(self) -> PM3ServiceResult:
|
||||
"""Connect to PM3 device.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success/failure
|
||||
"""
|
||||
try:
|
||||
await self.pm3_worker.connect()
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={"message": "Connected to Proxmark3"}
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="connection_error",
|
||||
message="Failed to connect to PM3",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def disconnect(self) -> PM3ServiceResult:
|
||||
"""Disconnect from PM3 device.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success/failure
|
||||
"""
|
||||
try:
|
||||
await self.pm3_worker.disconnect()
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={"message": "Disconnected from Proxmark3"}
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="disconnection_error",
|
||||
message="Failed to disconnect from PM3",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
# Session management methods (for both REST and BLE)
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
client_ip: str = "unknown",
|
||||
user_agent: Optional[str] = None,
|
||||
force_takeover: bool = False,
|
||||
device_id: Optional[str] = None
|
||||
) -> PM3ServiceResult:
|
||||
"""Create a new session for a device.
|
||||
|
||||
Args:
|
||||
client_ip: Client IP address
|
||||
user_agent: Client user agent string
|
||||
force_takeover: Whether to forcefully take over existing session
|
||||
device_id: Device ID to create session for (None for legacy mode)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with session_id
|
||||
"""
|
||||
success, session_id, error_msg = await self.session_manager.create_session(
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
force_takeover=force_takeover,
|
||||
device_id=device_id
|
||||
)
|
||||
|
||||
if success and session_id:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={"session_id": session_id, "device_id": device_id}
|
||||
)
|
||||
else:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="session_exists",
|
||||
message=error_msg or "Session already exists",
|
||||
details="Set force_takeover=true to take over"
|
||||
)
|
||||
)
|
||||
|
||||
async def release_session(
|
||||
self,
|
||||
session_id: str,
|
||||
device_id: Optional[str] = None
|
||||
) -> PM3ServiceResult:
|
||||
"""Release a session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to release
|
||||
device_id: Device ID (if known)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success
|
||||
"""
|
||||
released = await self.session_manager.release_session(session_id, device_id)
|
||||
if released:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={"message": "Session released"}
|
||||
)
|
||||
else:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="session_not_found",
|
||||
message="Session not found or already released"
|
||||
)
|
||||
)
|
||||
|
||||
def get_session_info(self, device_id: Optional[str] = None) -> PM3ServiceResult:
|
||||
"""Get session information for a device.
|
||||
|
||||
Args:
|
||||
device_id: Device ID to get session for (None for any active session)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with session details
|
||||
"""
|
||||
session = self.session_manager.get_active_session(device_id)
|
||||
|
||||
if session:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"session_id": session.session_id,
|
||||
"device_id": session.device_id,
|
||||
"client_ip": session.client_ip,
|
||||
"created_at": session.created_at,
|
||||
"last_activity": session.last_activity,
|
||||
}
|
||||
)
|
||||
else:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="session_not_found",
|
||||
message=f"No active session for device {device_id or 'default'}"
|
||||
)
|
||||
)
|
||||
|
||||
def get_all_sessions(self) -> PM3ServiceResult:
|
||||
"""Get all active sessions.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with all session details
|
||||
"""
|
||||
sessions = self.session_manager.get_all_active_sessions()
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"sessions": [
|
||||
{
|
||||
"session_id": s.session_id,
|
||||
"device_id": s.device_id,
|
||||
"client_ip": s.client_ip,
|
||||
"created_at": s.created_at,
|
||||
"last_activity": s.last_activity,
|
||||
}
|
||||
for s in sessions.values()
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
# Multi-device management methods
|
||||
|
||||
async def list_devices(self) -> PM3ServiceResult:
|
||||
"""List all discovered PM3 devices.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with list of all devices
|
||||
"""
|
||||
if not self.device_manager:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="device_manager_not_available",
|
||||
message="Device manager not initialized",
|
||||
details="Multi-device support is not enabled"
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
devices = await self.device_manager.get_all_devices()
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"devices": [device.to_dict() for device in devices]
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="list_devices_error",
|
||||
message="Failed to list devices",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def get_available_devices(self) -> PM3ServiceResult:
|
||||
"""Get devices without active sessions (available for use).
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with list of available devices
|
||||
"""
|
||||
if not self.device_manager:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="device_manager_not_available",
|
||||
message="Device manager not initialized",
|
||||
details="Multi-device support is not enabled"
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
all_devices = await self.device_manager.get_all_devices()
|
||||
|
||||
# Filter for devices that are CONNECTED (not IN_USE or other states)
|
||||
available_devices = [
|
||||
device for device in all_devices
|
||||
if device.status == DeviceStatus.CONNECTED
|
||||
]
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"devices": [device.to_dict() for device in available_devices]
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="get_available_devices_error",
|
||||
message="Failed to get available devices",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def identify_device(
|
||||
self,
|
||||
device_id: str,
|
||||
duration_ms: int = 2000
|
||||
) -> PM3ServiceResult:
|
||||
"""Blink LEDs on a device for physical identification.
|
||||
|
||||
Args:
|
||||
device_id: Device ID to identify
|
||||
duration_ms: Duration to blink LEDs in milliseconds (default: 2000)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success
|
||||
"""
|
||||
if not self.device_manager:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="device_manager_not_available",
|
||||
message="Device manager not initialized",
|
||||
details="Multi-device support is not enabled"
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
# Check if device exists
|
||||
device = await self.device_manager.get_device(device_id)
|
||||
if not device:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="device_not_found",
|
||||
message=f"Device {device_id} not found"
|
||||
)
|
||||
)
|
||||
|
||||
# Trigger LED identification
|
||||
await self.device_manager.identify_device(device_id, duration_ms)
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": f"Device {device_id} identified",
|
||||
"device_path": device.device_path,
|
||||
"duration_ms": duration_ms
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="identify_device_error",
|
||||
message="Failed to identify device",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def flash_firmware(self, device_id: str) -> PM3ServiceResult:
|
||||
"""Flash firmware to a PM3 device.
|
||||
|
||||
Flashes both bootrom and fullimage from bundled firmware files.
|
||||
Progress is reported via WebSocket notifications.
|
||||
|
||||
Args:
|
||||
device_id: Device ID to flash
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success/failure with message
|
||||
"""
|
||||
if not self.device_manager:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="device_manager_not_available",
|
||||
message="Device manager not initialized",
|
||||
details="Multi-device support is not enabled"
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
# Check if device exists
|
||||
device = await self.device_manager.get_device(device_id)
|
||||
if not device:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="device_not_found",
|
||||
message=f"Device {device_id} not found"
|
||||
)
|
||||
)
|
||||
|
||||
# Check if device is already flashing
|
||||
if device.status == DeviceStatus.FLASHING:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="device_busy",
|
||||
message="Device is already being flashed",
|
||||
details="Please wait for current flash to complete"
|
||||
)
|
||||
)
|
||||
|
||||
# Flash firmware
|
||||
result = await self.device_manager.flash_firmware(device_id)
|
||||
|
||||
if result["success"]:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": result["message"],
|
||||
"device_id": device_id
|
||||
}
|
||||
)
|
||||
else:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="flash_failed",
|
||||
message="Firmware flash failed",
|
||||
details=result.get("error", "Unknown error")
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="flash_error",
|
||||
message="Failed to flash firmware",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
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()
|
||||
312
app/backend/services/update_service.py
Normal file
312
app/backend/services/update_service.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""Update Service Layer.
|
||||
|
||||
This service provides software update operations that can be consumed by
|
||||
multiple interfaces (REST API, BLE GATT, etc.).
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from ..managers.update_manager import UpdateManager, UpdateProgress, UpdateStatus
|
||||
from .pm3_service import PM3ServiceError, PM3ServiceResult
|
||||
|
||||
|
||||
class UpdateService:
|
||||
"""Update service layer for software update management.
|
||||
|
||||
This service can be used by multiple interfaces:
|
||||
- REST API (FastAPI endpoints)
|
||||
- BLE GATT (Bluetooth handlers)
|
||||
- Plugin system (future)
|
||||
|
||||
It encapsulates:
|
||||
- Update checking
|
||||
- Update downloading
|
||||
- Update installation
|
||||
- Progress monitoring
|
||||
"""
|
||||
|
||||
def __init__(self, update_manager: Optional[UpdateManager] = None):
|
||||
"""Initialize update service.
|
||||
|
||||
Args:
|
||||
update_manager: Update manager instance (creates new if not provided)
|
||||
"""
|
||||
from ..managers.update_manager import get_update_manager
|
||||
self.update_manager = update_manager or get_update_manager()
|
||||
|
||||
async def check_for_updates(self) -> PM3ServiceResult:
|
||||
"""Check for available updates.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with update availability and information
|
||||
"""
|
||||
try:
|
||||
result = await self.update_manager.check_for_updates()
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data=result
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="update_check_error",
|
||||
message="Failed to check for updates",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def download_update(self) -> PM3ServiceResult:
|
||||
"""Download the latest available update.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating download success/failure
|
||||
"""
|
||||
try:
|
||||
success = await self.update_manager.download_update()
|
||||
|
||||
if success:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": "Update downloaded successfully",
|
||||
"ready_to_install": True
|
||||
}
|
||||
)
|
||||
else:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="download_failed",
|
||||
message="Update download failed"
|
||||
)
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
# No update available
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="no_update_available",
|
||||
message=str(e)
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="update_download_error",
|
||||
message="Error downloading update",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def install_update(self) -> PM3ServiceResult:
|
||||
"""Install the downloaded update.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating installation success/failure
|
||||
"""
|
||||
try:
|
||||
success = await self.update_manager.install_update()
|
||||
|
||||
if success:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": "Update installed successfully",
|
||||
"requires_restart": True
|
||||
}
|
||||
)
|
||||
else:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="installation_failed",
|
||||
message="Update installation failed"
|
||||
)
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
# No update downloaded
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="no_update_downloaded",
|
||||
message=str(e)
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="update_install_error",
|
||||
message="Error installing update",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def get_progress(self) -> PM3ServiceResult:
|
||||
"""Get current update progress.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with progress information
|
||||
"""
|
||||
try:
|
||||
progress = await self.update_manager.get_progress()
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"status": progress.status.value,
|
||||
"current_version": progress.current_version,
|
||||
"available_version": progress.available_version,
|
||||
"download_progress": progress.download_progress,
|
||||
"error_message": progress.error_message,
|
||||
"last_check": progress.last_check
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="progress_error",
|
||||
message="Failed to get update progress",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def get_release_notes(
|
||||
self,
|
||||
version: Optional[str] = None
|
||||
) -> PM3ServiceResult:
|
||||
"""Get release notes for a specific version or latest.
|
||||
|
||||
Args:
|
||||
version: Version to get notes for (latest if None)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with release notes
|
||||
"""
|
||||
try:
|
||||
notes = await self.update_manager.get_release_notes(version)
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"version": version or "latest",
|
||||
"release_notes": notes
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="release_notes_error",
|
||||
message="Failed to get release notes",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def check_and_download_update(self) -> PM3ServiceResult:
|
||||
"""Combined operation: check for updates and download if available.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with check and download status
|
||||
"""
|
||||
try:
|
||||
# First check for updates
|
||||
check_result = await self.check_for_updates()
|
||||
|
||||
if not check_result.success:
|
||||
return check_result
|
||||
|
||||
# If update available, download it
|
||||
if check_result.data.get("update_available"):
|
||||
download_result = await self.download_update()
|
||||
|
||||
if download_result.success:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": "Update checked and downloaded",
|
||||
"update_info": check_result.data,
|
||||
"ready_to_install": True
|
||||
}
|
||||
)
|
||||
else:
|
||||
return download_result
|
||||
else:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": "System is up to date",
|
||||
"update_available": False,
|
||||
"current_version": check_result.data.get("current_version")
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="check_download_error",
|
||||
message="Error checking and downloading update",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def full_update(self) -> PM3ServiceResult:
|
||||
"""Full update workflow: check, download, and install.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with complete update status
|
||||
"""
|
||||
try:
|
||||
# Check for updates
|
||||
check_result = await self.check_for_updates()
|
||||
if not check_result.success:
|
||||
return check_result
|
||||
|
||||
if not check_result.data.get("update_available"):
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": "System is already up to date",
|
||||
"update_performed": False
|
||||
}
|
||||
)
|
||||
|
||||
# Download update
|
||||
download_result = await self.download_update()
|
||||
if not download_result.success:
|
||||
return download_result
|
||||
|
||||
# Install update
|
||||
install_result = await self.install_update()
|
||||
if not install_result.success:
|
||||
return install_result
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": "Update completed successfully",
|
||||
"update_performed": True,
|
||||
"previous_version": check_result.data.get("current_version"),
|
||||
"new_version": check_result.data.get("latest_version"),
|
||||
"requires_restart": True
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="full_update_error",
|
||||
message="Error performing full update",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
351
app/backend/services/wifi_service.py
Normal file
351
app/backend/services/wifi_service.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""WiFi Service Layer.
|
||||
|
||||
This service provides WiFi management operations that can be consumed by
|
||||
multiple interfaces (REST API, BLE GATT, etc.).
|
||||
"""
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from ..managers.wifi_manager import WiFiManager, WiFiMode, WiFiStatus, WiFiNetwork
|
||||
from .pm3_service import PM3ServiceError, PM3ServiceResult
|
||||
|
||||
|
||||
class WiFiService:
|
||||
"""WiFi service layer for network management.
|
||||
|
||||
This service can be used by multiple interfaces:
|
||||
- REST API (FastAPI endpoints)
|
||||
- BLE GATT (Bluetooth handlers)
|
||||
- Plugin system (future)
|
||||
|
||||
It encapsulates:
|
||||
- Network scanning
|
||||
- Connection management
|
||||
- WiFi mode switching
|
||||
- Status queries
|
||||
"""
|
||||
|
||||
def __init__(self, wifi_manager: Optional[WiFiManager] = None):
|
||||
"""Initialize WiFi service.
|
||||
|
||||
Args:
|
||||
wifi_manager: WiFi manager instance (creates new if not provided)
|
||||
"""
|
||||
self.wifi_manager = wifi_manager or WiFiManager()
|
||||
|
||||
async def get_status(self) -> PM3ServiceResult:
|
||||
"""Get current WiFi status.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with WiFi status (mode, interfaces, connections)
|
||||
"""
|
||||
try:
|
||||
status = await self.wifi_manager.get_status()
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"mode": status.mode.value,
|
||||
"interfaces": [
|
||||
{
|
||||
"name": iface.name,
|
||||
"mac": iface.mac,
|
||||
"is_usb": iface.is_usb,
|
||||
"is_up": iface.is_up,
|
||||
"connected": iface.connected,
|
||||
"ssid": iface.ssid,
|
||||
"ip_address": iface.ip_address,
|
||||
"mode": iface.mode # "AP" or "managed"
|
||||
}
|
||||
for iface in status.interfaces
|
||||
],
|
||||
"client": {
|
||||
"ssid": status.current_ssid,
|
||||
"ip": status.current_ip
|
||||
} if status.current_ssid else None,
|
||||
"access_point": {
|
||||
"ssid": status.ap_ssid,
|
||||
"ip": status.ap_ip
|
||||
},
|
||||
"supports_dual": status.supports_dual
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="wifi_status_error",
|
||||
message="Failed to get WiFi status",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def scan_networks(
|
||||
self,
|
||||
interface: Optional[str] = None
|
||||
) -> PM3ServiceResult:
|
||||
"""Scan for available WiFi networks.
|
||||
|
||||
Args:
|
||||
interface: Interface to scan with (default: auto-select)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with list of available networks
|
||||
"""
|
||||
try:
|
||||
networks = await self.wifi_manager.scan_networks(interface)
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"networks": [
|
||||
{
|
||||
"ssid": net.ssid,
|
||||
"bssid": net.bssid,
|
||||
"signal_strength": net.signal_strength,
|
||||
"frequency": net.frequency,
|
||||
"encrypted": net.encrypted,
|
||||
"in_use": net.in_use
|
||||
}
|
||||
for net in networks
|
||||
],
|
||||
"count": len(networks)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="wifi_scan_error",
|
||||
message="Failed to scan for networks",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def connect(
|
||||
self,
|
||||
ssid: str,
|
||||
password: Optional[str] = None,
|
||||
interface: Optional[str] = None,
|
||||
hidden: bool = False
|
||||
) -> PM3ServiceResult:
|
||||
"""Connect to a WiFi network.
|
||||
|
||||
Args:
|
||||
ssid: Network SSID
|
||||
password: Network password (if encrypted)
|
||||
interface: Interface to use (default: auto-select)
|
||||
hidden: Whether network is hidden
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating connection success/failure
|
||||
"""
|
||||
try:
|
||||
success = await self.wifi_manager.connect_to_network(
|
||||
ssid=ssid,
|
||||
password=password,
|
||||
interface=interface,
|
||||
hidden=hidden
|
||||
)
|
||||
|
||||
if success:
|
||||
# Get updated status to include new connection info
|
||||
status = await self.wifi_manager.get_status()
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": f"Successfully connected to {ssid}",
|
||||
"ssid": ssid,
|
||||
"ip": status.current_ip
|
||||
}
|
||||
)
|
||||
else:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="connection_failed",
|
||||
message=f"Failed to connect to {ssid}",
|
||||
details="Connection attempt failed or timed out"
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="wifi_connect_error",
|
||||
message=f"Error connecting to {ssid}",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def disconnect(
|
||||
self,
|
||||
interface: Optional[str] = None
|
||||
) -> PM3ServiceResult:
|
||||
"""Disconnect from current network.
|
||||
|
||||
Args:
|
||||
interface: Interface to disconnect (default: first connected)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success/failure
|
||||
"""
|
||||
try:
|
||||
success = await self.wifi_manager.disconnect_from_network(interface)
|
||||
|
||||
if success:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={"message": "Disconnected from network"}
|
||||
)
|
||||
else:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="disconnect_failed",
|
||||
message="Failed to disconnect",
|
||||
details="No active connection found or disconnect failed"
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="wifi_disconnect_error",
|
||||
message="Error disconnecting from network",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def set_mode(self, mode: str) -> PM3ServiceResult:
|
||||
"""Set WiFi operation mode.
|
||||
|
||||
Args:
|
||||
mode: WiFi mode ("ap", "client", "dual", "auto", "off")
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success/failure
|
||||
"""
|
||||
try:
|
||||
# Validate and convert mode string to enum
|
||||
try:
|
||||
wifi_mode = WiFiMode(mode)
|
||||
except ValueError:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="invalid_mode",
|
||||
message=f"Invalid WiFi mode: {mode}",
|
||||
details=f"Valid modes: ap, client, dual, auto, off"
|
||||
)
|
||||
)
|
||||
|
||||
success = await self.wifi_manager.set_mode(wifi_mode)
|
||||
|
||||
if success:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": f"WiFi mode set to {mode}",
|
||||
"mode": mode
|
||||
}
|
||||
)
|
||||
else:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="mode_change_failed",
|
||||
message=f"Failed to set WiFi mode to {mode}",
|
||||
details="Mode change operation failed"
|
||||
)
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
# Handle dual mode without USB adapter
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="mode_not_supported",
|
||||
message=str(e),
|
||||
details="Dual mode requires USB WiFi adapter"
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="wifi_mode_error",
|
||||
message="Error setting WiFi mode",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def get_saved_networks(self) -> PM3ServiceResult:
|
||||
"""Get list of saved networks.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with list of saved networks
|
||||
"""
|
||||
try:
|
||||
networks = await self.wifi_manager.get_saved_networks()
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"networks": networks,
|
||||
"count": len(networks)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="saved_networks_error",
|
||||
message="Failed to get saved networks",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def forget_network(self, ssid: str) -> PM3ServiceResult:
|
||||
"""Forget a saved network.
|
||||
|
||||
Args:
|
||||
ssid: SSID of network to forget
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success/failure
|
||||
"""
|
||||
try:
|
||||
success = await self.wifi_manager.forget_network(ssid)
|
||||
|
||||
if success:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": f"Network '{ssid}' forgotten",
|
||||
"ssid": ssid
|
||||
}
|
||||
)
|
||||
else:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="network_not_found",
|
||||
message=f"Network '{ssid}' not found in saved networks"
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="forget_network_error",
|
||||
message=f"Error forgetting network '{ssid}'",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user