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:
michael
2026-01-06 13:45:29 -08:00
parent 1da6730735
commit 4f35df1781
323 changed files with 98287 additions and 1195 deletions

63
app/backend/api/auth.py Normal file
View File

@@ -0,0 +1,63 @@
"""Authentication module for Dangerous Pi API."""
import secrets
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from .. import config
security = HTTPBasic()
def verify_credentials(credentials: HTTPBasicCredentials = Depends(security)) -> str:
"""Verify HTTP Basic Auth credentials.
Args:
credentials: HTTP Basic credentials from request
Returns:
Username if authentication successful
Raises:
HTTPException: If authentication fails
"""
if not config.AUTH_ENABLED:
return "anonymous"
if not config.AUTH_PASSWORD:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AUTH_ENABLED is true but AUTH_PASSWORD is not set",
)
# Use constant-time comparison to prevent timing attacks
is_correct_username = secrets.compare_digest(
credentials.username.encode("utf-8"),
config.AUTH_USERNAME.encode("utf-8")
)
is_correct_password = secrets.compare_digest(
credentials.password.encode("utf-8"),
config.AUTH_PASSWORD.encode("utf-8")
)
if not (is_correct_username and is_correct_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username
def get_optional_auth(credentials: HTTPBasicCredentials = Depends(security)) -> str | None:
"""Optional authentication - returns username or None.
Use this for endpoints where auth is optional based on config.
"""
if not config.AUTH_ENABLED:
return None
try:
return verify_credentials(credentials)
except HTTPException:
return None

View File

@@ -1,6 +1,7 @@
"""Health check endpoints."""
from fastapi import APIRouter
from pydantic import BaseModel
import aiosqlite
router = APIRouter()
@@ -21,6 +22,28 @@ async def health_check():
@router.get("/ready")
async def readiness_check():
"""Readiness check endpoint."""
# TODO: Check if PM3 is connected, database is accessible, etc.
return {"ready": True}
"""Readiness check endpoint.
Checks:
- Database connectivity
"""
from .. import config
checks = {"database": False}
reasons = []
# Check database connectivity
try:
async with aiosqlite.connect(config.DATABASE_PATH) as db:
await db.execute("SELECT 1")
checks["database"] = True
except Exception as e:
reasons.append(f"Database: {str(e)}")
all_ready = all(checks.values())
return {
"ready": all_ready,
"checks": checks,
"reasons": reasons if not all_ready else None
}

View File

@@ -1,20 +1,29 @@
"""Proxmark3 API endpoints."""
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional
import asyncio
"""Proxmark3 API endpoints.
from ..workers.pm3_worker import PM3Worker, PM3Command
from ..managers.session_manager import SessionManager
Refactored to use PM3Service for all business logic.
Endpoints are now thin adapters that convert HTTP requests/responses.
Multi-device support:
- GET /devices - List all devices
- GET /devices/available - List available devices
- POST /devices/{device_id}/identify - Blink device LEDs
- GET /devices/{device_id}/status - Get device-specific status
- GET /status?device_id=xxx - Get status (all devices or specific)
- POST /command - Execute command (with optional device_id)
"""
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from ..services.container import container
router = APIRouter()
pm3_worker = PM3Worker()
session_manager = SessionManager()
class CommandRequest(BaseModel):
command: str
session_id: Optional[str] = None
device_id: Optional[str] = None # Multi-device support
class CommandResponse(BaseModel):
@@ -30,79 +39,426 @@ class StatusResponse(BaseModel):
session_active: bool
@router.get("/status", response_model=StatusResponse)
async def get_status():
"""Get Proxmark3 status."""
is_connected = await pm3_worker.is_connected()
version = None
class FirmwareInfo(BaseModel):
"""Firmware information for a device."""
bootrom_version: Optional[str] = None
os_version: Optional[str] = None
compatible: bool = False
if is_connected:
# Try to get version info
result = await pm3_worker.execute_command("hw version")
if result.success:
version = result.output.split("\n")[0] if result.output else None
class DeviceInfo(BaseModel):
"""Device information response."""
device_id: str
device_path: str
friendly_name: Optional[str] = None
serial_number: Optional[str] = None
status: str
connected: bool
in_use: bool
firmware_info: FirmwareInfo
usb_vid: Optional[str] = None
usb_pid: Optional[str] = None
last_seen: str
class DeviceListResponse(BaseModel):
"""Response for device list endpoints."""
devices: List[DeviceInfo]
class DeviceStatusResponse(BaseModel):
"""Response for single device status."""
device_id: str
device_path: str
friendly_name: Optional[str] = None
serial_number: Optional[str] = None
connected: bool
in_use: bool
status: str
firmware_info: FirmwareInfo
last_seen: str
class IdentifyRequest(BaseModel):
"""Request to identify a device (blink LEDs)."""
duration_ms: int = Field(default=2000, ge=500, le=10000, description="LED blink duration in milliseconds")
class FlashRequest(BaseModel):
"""Request to flash firmware to a device."""
confirm: bool = Field(..., description="User confirmation that they want to flash")
class FlashResponse(BaseModel):
"""Response from flash operation."""
success: bool
message: str
device_id: str
def _service_error_to_http_status(error_code: str) -> int:
"""Map service error codes to HTTP status codes.
Args:
error_code: Service error code
Returns:
HTTP status code
"""
codes = {
# Session errors
"session_locked": 423,
"session_not_found": 404,
# Connection errors
"pm3_not_connected": 503,
"connection_error": 503,
"disconnection_error": 500,
# Command execution errors
"command_failed": 500,
"execution_error": 500,
"status_error": 500,
# Multi-device errors
"device_not_found": 404,
"device_manager_not_available": 503,
"list_devices_error": 500,
"get_available_devices_error": 500,
"identify_device_error": 500,
# Firmware flash errors
"device_busy": 409,
"flash_failed": 500,
"flash_error": 500,
}
return codes.get(error_code, 500)
@router.get("/status")
async def get_status(device_id: Optional[str] = Query(None, description="Optional device ID for multi-device support")):
"""Get Proxmark3 status.
Multi-device support:
- If device_id is None and device_manager exists: returns all devices
- If device_id is provided: returns specific device status
- If device_id is None and no device_manager: returns legacy single device status
Args:
device_id: Optional device ID for multi-device mode
Returns:
StatusResponse (legacy single device) or dict with devices list (multi-device)
"""
result = await container.pm3_service.get_status(device_id=device_id)
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
# Multi-device mode: return all devices
if "devices" in result.data:
return {"devices": result.data["devices"]}
# Single device mode (specific device or legacy)
return StatusResponse(
connected=is_connected,
device=pm3_worker.device_path,
version=version,
session_active=session_manager.has_active_session()
connected=result.data["connected"],
device=result.data["device_path"],
version=result.data.get("version"),
session_active=result.data["session_active"]
)
@router.post("/connect")
async def connect():
"""Connect to Proxmark3 device."""
try:
await pm3_worker.connect()
return {"success": True, "message": "Connected to Proxmark3"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
"""Connect to Proxmark3 device.
Uses PM3Service for business logic.
"""
result = await container.pm3_service.connect()
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return {"success": True, "message": result.data["message"]}
@router.post("/disconnect")
async def disconnect():
"""Disconnect from Proxmark3 device."""
try:
await pm3_worker.disconnect()
return {"success": True, "message": "Disconnected from Proxmark3"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
"""Disconnect from Proxmark3 device.
Uses PM3Service for business logic.
"""
result = await container.pm3_service.disconnect()
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return {"success": True, "message": result.data["message"]}
@router.post("/command", response_model=CommandResponse)
async def execute_command(request: CommandRequest):
"""Execute a Proxmark3 command."""
try:
# Check if another session is active
if not session_manager.can_execute(request.session_id):
"""Execute a Proxmark3 command.
Uses PM3Service for all business logic including:
- Session validation
- Command execution
- Session activity updates
- Multi-device support (via device_id in request)
Multi-device support:
- If device_id is provided: command executes on specified device
- If device_id is None: uses legacy single-device mode
Args:
request: CommandRequest with command, optional session_id, and optional device_id
"""
result = await container.pm3_service.execute_command(
command=request.command,
session_id=request.session_id,
device_id=request.device_id
)
if result.success:
return CommandResponse(
success=True,
output=result.data["output"],
error=None
)
else:
# For session_locked, return 423 via HTTPException
# For other errors, return in response body
if result.error.code == "session_locked":
raise HTTPException(
status_code=423,
detail="Another session is active. Please take over or wait."
detail=result.error.message
)
# Execute command
result = await pm3_worker.execute_command(request.command)
# Include details in error message for better diagnostics
error_msg = result.error.message
if result.error.details:
error_msg = f"{error_msg}: {result.error.details}"
# Update session activity
if request.session_id:
session_manager.update_activity(request.session_id)
return CommandResponse(
success=result.success,
output=result.output,
error=result.error
)
except Exception as e:
return CommandResponse(
success=False,
output="",
error=str(e)
error=error_msg
)
@router.get("/commands/history")
async def get_command_history(limit: int = 50):
"""Get recent command history."""
# TODO: Implement database query
return {"history": []}
"""Get recent command history from database."""
import aiosqlite
from .. import config
try:
async with aiosqlite.connect(config.DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT command, response, success, executed_at FROM command_history ORDER BY executed_at DESC LIMIT ?",
(limit,)
)
rows = await cursor.fetchall()
return {"history": [dict(row) for row in rows]}
except Exception as e:
return {"history": [], "error": str(e)}
# ============================================================================
# Multi-Device Endpoints
# ============================================================================
@router.get("/devices", response_model=DeviceListResponse)
async def list_devices():
"""List all discovered PM3 devices.
Returns all devices regardless of their status (connected, in use, etc.).
Uses PM3DeviceManager via PM3Service.
Returns:
DeviceListResponse: List of all devices with their status and firmware info
"""
result = await container.pm3_service.list_devices()
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
# Convert device dictionaries to DeviceInfo models
devices = []
for device_dict in result.data["devices"]:
devices.append(DeviceInfo(
device_id=device_dict["device_id"],
device_path=device_dict["device_path"],
friendly_name=device_dict.get("friendly_name"),
serial_number=device_dict.get("serial_number"),
status=device_dict["status"],
connected=device_dict["status"] in ["CONNECTED", "IN_USE"],
in_use=device_dict["status"] == "IN_USE",
firmware_info=FirmwareInfo(
bootrom_version=device_dict["firmware_info"].get("bootrom_version"),
os_version=device_dict["firmware_info"].get("os_version"),
compatible=device_dict["firmware_info"].get("compatible", False)
),
usb_vid=device_dict.get("usb_vid"),
usb_pid=device_dict.get("usb_pid"),
last_seen=device_dict["last_seen"]
))
return DeviceListResponse(devices=devices)
@router.get("/devices/available", response_model=DeviceListResponse)
async def list_available_devices():
"""List available PM3 devices (not currently in use).
Returns only devices that are connected but do not have active sessions.
These devices can be selected for new operations.
Returns:
DeviceListResponse: List of available devices
"""
result = await container.pm3_service.get_available_devices()
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
# Convert device dictionaries to DeviceInfo models
devices = []
for device_dict in result.data["devices"]:
devices.append(DeviceInfo(
device_id=device_dict["device_id"],
device_path=device_dict["device_path"],
friendly_name=device_dict.get("friendly_name"),
serial_number=device_dict.get("serial_number"),
status=device_dict["status"],
connected=device_dict["status"] in ["CONNECTED", "IN_USE"],
in_use=device_dict["status"] == "IN_USE",
firmware_info=FirmwareInfo(
bootrom_version=device_dict["firmware_info"].get("bootrom_version"),
os_version=device_dict["firmware_info"].get("os_version"),
compatible=device_dict["firmware_info"].get("compatible", False)
),
usb_vid=device_dict.get("usb_vid"),
usb_pid=device_dict.get("usb_pid"),
last_seen=device_dict["last_seen"]
))
return DeviceListResponse(devices=devices)
@router.post("/devices/{device_id}/identify")
async def identify_device(device_id: str, request: IdentifyRequest = IdentifyRequest()):
"""Identify a PM3 device by blinking its LEDs.
Useful for physically identifying which device corresponds to a device_id
when multiple devices are connected.
Args:
device_id: Device ID to identify
request: Request with optional duration_ms (default: 2000ms)
Returns:
Success message
"""
result = await container.pm3_service.identify_device(
device_id=device_id,
duration_ms=request.duration_ms
)
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return {"success": True, "message": result.data["message"]}
@router.get("/devices/{device_id}/status", response_model=DeviceStatusResponse)
async def get_device_status(device_id: str):
"""Get status for a specific PM3 device.
Args:
device_id: Device ID to query
Returns:
DeviceStatusResponse: Device status and firmware info
"""
result = await container.pm3_service.get_status(device_id=device_id)
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
data = result.data
return DeviceStatusResponse(
device_id=data["device_id"],
device_path=data["device_path"],
friendly_name=data.get("friendly_name"),
serial_number=data.get("serial_number"),
connected=data["connected"],
in_use=data["in_use"],
status=data["status"],
firmware_info=FirmwareInfo(
bootrom_version=data["firmware_info"]["bootrom_version"],
os_version=data["firmware_info"]["os_version"],
compatible=data["firmware_info"]["compatible"]
),
last_seen=data["last_seen"]
)
@router.post("/devices/{device_id}/flash", response_model=FlashResponse)
async def flash_device(device_id: str, request: FlashRequest):
"""Flash firmware to a PM3 device.
Flashes both bootrom and fullimage from bundled firmware files.
Progress is reported via WebSocket notifications (pm3_flash_progress event).
Args:
device_id: Device ID to flash
request: FlashRequest with confirmation
Returns:
FlashResponse with success status and message
Raises:
HTTPException 400: If confirmation not provided
HTTPException 404: If device not found
HTTPException 409: If device is already being flashed
HTTPException 500: If flash operation fails
"""
if not request.confirm:
raise HTTPException(
status_code=400,
detail="Confirmation required. Set confirm=true to proceed with flash."
)
result = await container.pm3_service.flash_firmware(device_id=device_id)
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message if not result.error.details else f"{result.error.message}: {result.error.details}"
)
return FlashResponse(
success=True,
message=result.data["message"],
device_id=device_id
)

View File

@@ -1,39 +1,62 @@
"""System API endpoints."""
"""System API endpoints.
Refactored to use services for business logic.
Session management uses PM3Service, system operations use SystemService.
"""
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from typing import Optional, Dict
from ..managers.session_manager import SessionManager
from ..services.container import container
from ..managers.ups_manager import get_ups_manager
from ..managers.ble_manager import get_ble_manager
router = APIRouter()
session_manager = SessionManager()
class CreateSessionRequest(BaseModel):
force_takeover: bool = False
device_id: Optional[str] = None # Device to create session for
class CreateSessionResponse(BaseModel):
success: bool
session_id: Optional[str] = None
device_id: Optional[str] = None
error: Optional[str] = None
class SessionInfo(BaseModel):
session_id: str
device_id: Optional[str]
client_ip: str
created_at: float
last_activity: float
time_remaining: float
class CPUCoreInfo(BaseModel):
"""Per-core CPU information."""
core_id: int
percent: float
online: bool = True # Whether this core is currently enabled
class CPUInfo(BaseModel):
"""CPU information including per-core data."""
count: int
percent: float
temperature: Optional[float] = None
per_core: list[CPUCoreInfo] = []
load_average: Optional[list[float]] = None
class SystemInfo(BaseModel):
"""System information response."""
hostname: str
uptime: float
cpu_temp: Optional[float]
cpu: Optional[CPUInfo] = None
memory_used: float
memory_total: float
disk_used: float
@@ -42,38 +65,70 @@ class SystemInfo(BaseModel):
@router.post("/session/create", response_model=CreateSessionResponse)
async def create_session(request: Request, body: CreateSessionRequest):
"""Create a new session for PM3 access."""
"""Create a new session for PM3 access.
Uses PM3Service for session management.
Optionally specify device_id for multi-device support.
"""
# Get client IP from request
client_ip = request.client.host if request.client else "unknown"
user_agent = request.headers.get("user-agent")
success, session_id, error = await session_manager.create_session(
result = await container.pm3_service.create_session(
client_ip=client_ip,
user_agent=user_agent,
force_takeover=body.force_takeover
force_takeover=body.force_takeover,
device_id=body.device_id
)
return CreateSessionResponse(
success=success,
session_id=session_id,
error=error
)
if result.success:
return CreateSessionResponse(
success=True,
session_id=result.data["session_id"],
device_id=result.data.get("device_id"),
error=None
)
else:
return CreateSessionResponse(
success=False,
session_id=None,
device_id=None,
error=result.error.message
)
@router.post("/session/{session_id}/release")
async def release_session(session_id: str):
"""Release an active session."""
success = await session_manager.release_session(session_id)
async def release_session(session_id: str, device_id: Optional[str] = None):
"""Release an active session.
if not success:
raise HTTPException(status_code=404, detail="Session not found")
Uses PM3Service for session management.
return {"success": True, "message": "Session released"}
Args:
session_id: Session ID to release
device_id: Optional device ID for faster lookup
"""
result = await container.pm3_service.release_session(session_id, device_id)
if not result.success:
raise HTTPException(
status_code=404,
detail=result.error.message
)
return {"success": True, "message": result.data["message"]}
@router.get("/session/active", response_model=Optional[SessionInfo])
async def get_active_session():
"""Get information about the active session."""
session = session_manager.get_active_session()
async def get_active_session(device_id: Optional[str] = None):
"""Get information about the active session for a device.
Uses PM3Service (via SessionManager) for session info.
Args:
device_id: Optional device ID. If None, returns any active session.
"""
# Access session manager through container for consistency
session = container.session_manager.get_active_session(device_id)
if not session:
return None
@@ -85,6 +140,7 @@ async def get_active_session():
return SessionInfo(
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,
@@ -92,36 +148,63 @@ async def get_active_session():
)
@router.get("/sessions/all")
async def get_all_sessions():
"""Get all active sessions across all devices.
Returns a list of all active sessions with their device IDs.
"""
result = container.pm3_service.get_all_sessions()
return result.data
@router.get("/info", response_model=SystemInfo)
async def get_system_info():
"""Get system information."""
import platform
import psutil
from pathlib import Path
"""Get system information.
# Get CPU temperature (Raspberry Pi specific)
cpu_temp = None
try:
temp_file = Path("/sys/class/thermal/thermal_zone0/temp")
if temp_file.exists():
cpu_temp = int(temp_file.read_text()) / 1000.0
except Exception:
pass
Refactored to use SystemService for business logic.
"""
from ..services.container import container
# Get memory info
memory = psutil.virtual_memory()
result = await container.system_service.get_info()
# Get disk info
disk = psutil.disk_usage('/')
if not result.success:
raise HTTPException(
status_code=500,
detail=result.error.message
)
cpu_data = result.data["cpu"]
memory_data = result.data["memory"]
disk_data = result.data["disk"]
# Build per-core CPU info
per_core = [
CPUCoreInfo(
core_id=c["core_id"],
percent=c["percent"],
online=c.get("online", True)
)
for c in cpu_data.get("per_core", [])
]
cpu_info = CPUInfo(
count=cpu_data.get("count", 0),
percent=cpu_data.get("percent", 0.0),
temperature=cpu_data.get("temperature"),
per_core=per_core,
load_average=cpu_data.get("load_average")
)
return SystemInfo(
hostname=platform.node(),
uptime=psutil.boot_time(),
cpu_temp=cpu_temp,
memory_used=memory.used,
memory_total=memory.total,
disk_used=disk.used,
disk_total=disk.total
hostname=result.data["hostname"],
uptime=result.data["uptime"],
cpu_temp=cpu_data.get("temperature"),
cpu=cpu_info,
memory_used=memory_data["used"],
memory_total=memory_data["total"],
disk_used=disk_data["used"],
disk_total=disk_data["total"]
)
@@ -129,11 +212,15 @@ async def get_system_info():
async def get_config():
"""Get system configuration (non-sensitive values)."""
from .. import config
from ..services.container import container
# Get WiFi mode from manager, default to AP mode
wifi_mode = container.wifi_manager._current_mode.value if container.wifi_manager else "ap"
return {
"pm3_device": config.PM3_DEVICE,
"session_timeout": config.SESSION_TIMEOUT,
"wifi_mode": "auto", # TODO: Get from wifi manager
"wifi_mode": wifi_mode,
"ble_enabled": config.BLE_ENABLED,
"auth_enabled": config.AUTH_ENABLED,
"https_enabled": config.HTTPS_ENABLED
@@ -141,17 +228,47 @@ async def get_config():
@router.post("/restart")
async def restart_system():
"""Restart the backend application."""
# TODO: Implement graceful restart
return {"success": True, "message": "Restart initiated"}
async def restart_system(delay: int = 0):
"""Restart the system.
Refactored to use SystemService for business logic.
Args:
delay: Delay in seconds before restart (default: 0)
"""
from ..services.container import container
result = await container.system_service.restart(delay=delay)
if not result.success:
raise HTTPException(
status_code=500,
detail=result.error.message
)
return {"success": True, "message": result.data["message"]}
@router.post("/shutdown")
async def shutdown_system():
"""Initiate system shutdown."""
# TODO: Implement safe shutdown sequence
return {"success": True, "message": "Shutdown initiated"}
async def shutdown_system(delay: int = 0):
"""Initiate system shutdown.
Refactored to use SystemService for business logic.
Args:
delay: Delay in seconds before shutdown (default: 0)
"""
from ..services.container import container
result = await container.system_service.shutdown(delay=delay)
if not result.success:
raise HTTPException(
status_code=500,
detail=result.error.message
)
return {"success": True, "message": result.data["message"]}
class UPSStatusResponse(BaseModel):
@@ -174,6 +291,21 @@ class UPSThresholdsRequest(BaseModel):
warning_threshold: Optional[float] = None
class PowerRestrictionsResponse(BaseModel):
"""Power restrictions response model."""
restricted: bool
reason: Optional[str] = None
power_source: str
ups_available: bool
battery_percentage: Optional[float] = None
allow_firmware_flash: bool
allow_bootloader_flash: bool
allow_intensive_operations: bool
message: Optional[str] = None
warning: Optional[str] = None
shutdown_imminent: Optional[bool] = None
@router.get("/ups/status", response_model=UPSStatusResponse)
async def get_ups_status():
"""Get current UPS battery status."""
@@ -227,6 +359,140 @@ async def trigger_ups_shutdown(delay: int = 30):
}
@router.get("/power/restrictions", response_model=PowerRestrictionsResponse)
async def get_power_restrictions():
"""Get current power restrictions based on UPS/battery state.
Returns power policy information including:
- Whether operations are restricted
- Current power source (AC, battery, or assumed AC if no UPS)
- Battery level (if UPS present)
- Which operations are allowed (firmware flash, bootloader flash, etc.)
- User-friendly messages and warnings
This endpoint is critical for determining whether power-intensive
operations (like firmware flashing) should be allowed.
If UPS hardware is not detected, assumes stable AC power and allows
all operations (user responsibility to ensure power stability).
"""
ups_manager = get_ups_manager()
restrictions = ups_manager.get_power_restrictions()
return PowerRestrictionsResponse(**restrictions)
class PiModelResponse(BaseModel):
"""Pi model information response."""
model: str
model_short: str
total_cores: int
default_active_cores: int
min_cores: int
max_cores: int
class CPUCoresConfigResponse(BaseModel):
"""CPU cores configuration response."""
total_cores: int
online_cores: int
configured_cores: Optional[int] = None # From cmdline.txt maxcpus parameter
configurable_cores: list[int]
pi_model: Optional[PiModelResponse] = None
class SetCPUCoresRequest(BaseModel):
"""Request to set CPU cores."""
num_cores: int
persist: bool = True # Save to config for boot persistence
reboot: bool = True # Reboot after saving
@router.get("/cpu/cores", response_model=CPUCoresConfigResponse)
async def get_cpu_cores():
"""Get CPU cores configuration.
Returns information about:
- Total physical cores
- Currently online cores
- Which cores can be toggled (core 0 is always on)
- Pi model information with recommended defaults
"""
result = await container.system_service.get_cpu_cores_config()
if not result.success:
raise HTTPException(
status_code=500,
detail=result.error.message
)
# Convert pi_model dict to response model if present
pi_model_data = result.data.get("pi_model")
pi_model = None
if pi_model_data:
pi_model = PiModelResponse(**pi_model_data)
return CPUCoresConfigResponse(
total_cores=result.data["total_cores"],
online_cores=result.data["online_cores"],
configured_cores=result.data.get("configured_cores"),
configurable_cores=result.data["configurable_cores"],
pi_model=pi_model
)
@router.post("/cpu/cores")
async def set_cpu_cores(request: SetCPUCoresRequest):
"""Set the number of active CPU cores.
Core 0 is always active. This endpoint enables/disables cores 1 through N.
For Pi Zero 2 W, the recommended default is 2 cores (out of 4) for
better thermal management and power efficiency.
Args:
num_cores: Number of cores to keep active (1 to max_cores)
persist: Save setting to config file (default: True)
reboot: Reboot system after saving (default: True)
"""
result = await container.system_service.set_cpu_cores(
num_cores=request.num_cores,
persist=request.persist,
reboot=request.reboot
)
if not result.success:
raise HTTPException(
status_code=400 if result.error.code == "invalid_cores" else 500,
detail=result.error.message
)
return {
"success": True,
"message": result.data["message"],
"requested": result.data.get("requested"),
"actual": result.data.get("actual"),
"rebooting": result.data.get("rebooting", False)
}
@router.get("/pi/model", response_model=PiModelResponse)
async def get_pi_model():
"""Get Raspberry Pi model information.
Returns the detected Pi model with recommended CPU core settings.
"""
result = await container.system_service.get_pi_model()
if not result.success:
raise HTTPException(
status_code=500,
detail=result.error.message
)
return PiModelResponse(**result.data)
class BLEStatusResponse(BaseModel):
"""BLE status response model."""
enabled: bool
@@ -307,3 +573,317 @@ async def send_ble_notification(request: SendNotificationRequest):
"success": True,
"message": "Notification sent"
}
# -----------------------------------------------------------------------------
# SSL/HTTPS API
# -----------------------------------------------------------------------------
class SSLCertificateInfo(BaseModel):
"""SSL certificate information."""
enabled: bool
certificate_exists: bool
certificate_path: Optional[str] = None
key_path: Optional[str] = None
subject: Optional[str] = None
issuer: Optional[str] = None
not_before: Optional[str] = None
not_after: Optional[str] = None
san: Optional[list[str]] = None # Subject Alternative Names
fingerprint_sha256: Optional[str] = None
class SSLRegenerateRequest(BaseModel):
"""Request to regenerate SSL certificate."""
reload_nginx: bool = True
@router.get("/ssl/info", response_model=SSLCertificateInfo)
async def get_ssl_info():
"""Get SSL certificate information.
Returns details about the current SSL configuration including:
- Whether HTTPS is enabled
- Certificate existence and paths
- Certificate subject, issuer, validity dates
- Subject Alternative Names (SANs)
- SHA256 fingerprint
"""
import os
import subprocess
from .. import config
cert_path = "/opt/dangerous-pi/ssl/dangerous-pi.crt"
key_path = "/opt/dangerous-pi/ssl/dangerous-pi.key"
# Check if HTTPS is enabled and certificate exists
https_enabled = config.HTTPS_ENABLED
cert_exists = os.path.exists(cert_path)
key_exists = os.path.exists(key_path)
if not cert_exists:
return SSLCertificateInfo(
enabled=https_enabled,
certificate_exists=False,
certificate_path=cert_path,
key_path=key_path
)
# Parse certificate details using openssl
cert_info = {
"subject": None,
"issuer": None,
"not_before": None,
"not_after": None,
"san": [],
"fingerprint": None
}
try:
# Get subject
result = subprocess.run(
["openssl", "x509", "-in", cert_path, "-noout", "-subject"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
cert_info["subject"] = result.stdout.strip().replace("subject=", "")
# Get issuer
result = subprocess.run(
["openssl", "x509", "-in", cert_path, "-noout", "-issuer"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
cert_info["issuer"] = result.stdout.strip().replace("issuer=", "")
# Get validity dates
result = subprocess.run(
["openssl", "x509", "-in", cert_path, "-noout", "-dates"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split("\n"):
if line.startswith("notBefore="):
cert_info["not_before"] = line.replace("notBefore=", "")
elif line.startswith("notAfter="):
cert_info["not_after"] = line.replace("notAfter=", "")
# Get SANs
result = subprocess.run(
["openssl", "x509", "-in", cert_path, "-noout", "-ext", "subjectAltName"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0 and "subjectAltName" in result.stdout:
# Parse SANs from output like "DNS:localhost, IP:192.168.4.1"
san_line = result.stdout.strip()
for line in san_line.split("\n"):
if "DNS:" in line or "IP:" in line:
# Split by comma and clean up
sans = [s.strip() for s in line.split(",")]
cert_info["san"] = [s for s in sans if s.startswith(("DNS:", "IP:"))]
break
# Get SHA256 fingerprint
result = subprocess.run(
["openssl", "x509", "-in", cert_path, "-noout", "-fingerprint", "-sha256"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
# Format: "sha256 Fingerprint=XX:XX:XX..."
fingerprint = result.stdout.strip()
if "=" in fingerprint:
cert_info["fingerprint"] = fingerprint.split("=", 1)[1]
except subprocess.TimeoutExpired:
pass # Return what we have
except Exception:
pass # Return what we have
return SSLCertificateInfo(
enabled=https_enabled,
certificate_exists=cert_exists and key_exists,
certificate_path=cert_path,
key_path=key_path,
subject=cert_info["subject"],
issuer=cert_info["issuer"],
not_before=cert_info["not_before"],
not_after=cert_info["not_after"],
san=cert_info["san"] if cert_info["san"] else None,
fingerprint_sha256=cert_info["fingerprint"]
)
@router.post("/ssl/regenerate")
async def regenerate_ssl_certificate(request: SSLRegenerateRequest):
"""Regenerate the self-signed SSL certificate.
This will:
1. Generate a new EC P-256 key and self-signed certificate
2. Optionally reload nginx to pick up the new certificate
The new certificate will be valid for 10 years and include SANs for:
- 192.168.4.1 (AP gateway IP)
- dangerous-pi.local (mDNS hostname)
- localhost
Args:
reload_nginx: Whether to reload nginx after regeneration (default: True)
Returns:
Success status and certificate info
"""
import subprocess
import os
script_path = "/opt/dangerous-pi/scripts/generate-ssl-cert.sh"
# Check if script exists
if not os.path.exists(script_path):
raise HTTPException(
status_code=500,
detail="SSL certificate generation script not found"
)
try:
# Run certificate generation with --force flag
result = subprocess.run(
[script_path, "--force"],
capture_output=True,
text=True,
timeout=30
)
if result.returncode != 0:
raise HTTPException(
status_code=500,
detail=f"Certificate generation failed: {result.stderr}"
)
# Reload nginx if requested
nginx_reloaded = False
if request.reload_nginx:
try:
nginx_result = subprocess.run(
["systemctl", "reload", "nginx"],
capture_output=True,
text=True,
timeout=10
)
nginx_reloaded = nginx_result.returncode == 0
except Exception:
pass # Non-fatal, certificate was still generated
return {
"success": True,
"message": "SSL certificate regenerated successfully",
"nginx_reloaded": nginx_reloaded,
"output": result.stdout
}
except subprocess.TimeoutExpired:
raise HTTPException(
status_code=500,
detail="Certificate generation timed out"
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Certificate generation error: {str(e)}"
)
# -----------------------------------------------------------------------------
# Header Widgets API
# -----------------------------------------------------------------------------
class WidgetResponse(BaseModel):
"""Header widget response model."""
id: str
source: str
severity: str
message: str
dismissible: bool
icon: Optional[str] = None
action_label: Optional[str] = None
action_url: Optional[str] = None
created_at: str
expires_at: Optional[str] = None
@router.get("/widgets", response_model=list[WidgetResponse])
async def get_header_widgets():
"""Get all active header widgets.
Returns widgets from:
- System managers (UPS, PM3, Updates)
- Enabled plugins
Widgets are sorted by severity (error > warning > info > success).
"""
from ..managers.plugin_manager import get_plugin_manager
plugin_manager = get_plugin_manager()
widgets = plugin_manager.get_active_widgets()
# Sort by severity (error first, then warning, info, success)
severity_order = {"error": 0, "warning": 1, "info": 2, "success": 3}
widgets.sort(key=lambda w: severity_order.get(w.severity.value, 99))
return [
WidgetResponse(
id=w.id,
source=w.source,
severity=w.severity.value,
message=w.message,
dismissible=w.dismissible,
icon=w.icon,
action_label=w.action_label,
action_url=w.action_url,
created_at=w.created_at or "",
expires_at=w.expires_at
)
for w in widgets
]
@router.post("/widgets/{widget_id}/dismiss")
async def dismiss_widget(widget_id: str):
"""Dismiss a header widget.
Dismissed widgets will not reappear until the server restarts
or the widget is explicitly re-registered.
Args:
widget_id: Full widget ID (e.g., "ups.hardware_missing", "plugin.hello_world.status")
Returns:
Success status
"""
from ..managers.plugin_manager import get_plugin_manager
plugin_manager = get_plugin_manager()
success = plugin_manager.dismiss_widget(widget_id)
if not success:
raise HTTPException(
status_code=404,
detail=f"Widget '{widget_id}' not found or not dismissible"
)
return {"success": True, "widget_id": widget_id}
@router.post("/widgets/clear-dismissed")
async def clear_dismissed_widgets():
"""Clear all dismissed widgets, allowing them to reappear.
This is useful if a user wants to see previously dismissed
notifications again.
"""
from ..managers.plugin_manager import get_plugin_manager
plugin_manager = get_plugin_manager()
plugin_manager.clear_dismissed()
return {"success": True, "message": "Dismissed widgets cleared"}

View File

@@ -1,9 +1,13 @@
"""Update management API endpoints."""
"""Update management API endpoints.
Refactored to use UpdateService for all business logic.
Endpoints are now thin adapters that convert HTTP requests/responses.
"""
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from ..managers.update_manager import get_update_manager, UpdateStatus
from ..services.container import container
from ..managers.ble_manager import get_ble_manager, NotificationType
@@ -37,149 +41,165 @@ class ReleaseNotesRequest(BaseModel):
version: Optional[str] = None
def _service_error_to_http_status(error_code: str) -> int:
"""Map service error codes to HTTP status codes.
Args:
error_code: Service error code
Returns:
HTTP status code
"""
codes = {
"update_check_error": 500,
"no_update_available": 400,
"download_failed": 500,
"update_download_error": 500,
"no_update_downloaded": 400,
"installation_failed": 500,
"update_install_error": 500,
"progress_error": 500,
"release_notes_error": 500,
"check_download_error": 500,
"full_update_error": 500,
}
return codes.get(error_code, 500)
@router.get("/check", response_model=UpdateCheckResponse)
async def check_for_updates():
"""Check for available updates.
Returns:
UpdateCheckResponse with update status and info
Uses UpdateService for business logic.
"""
try:
manager = get_update_manager()
ble_manager = get_ble_manager()
result = await manager.check_for_updates()
result = await container.update_service.check_for_updates()
# Send BLE notification if update is available
if result.get("update_available"):
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
# Send BLE notification if update is available
if result.data.get("update_available"):
try:
ble_manager = get_ble_manager()
await ble_manager.send_notification(
NotificationType.UPDATE_AVAILABLE,
f"Update available: v{result['latest_version']}",
{"version": result["latest_version"]}
f"Update available: v{result.data['latest_version']}",
{"version": result.data["latest_version"]}
)
except Exception:
# BLE notification failure shouldn't affect the response
pass
return UpdateCheckResponse(**result)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return UpdateCheckResponse(**result.data)
@router.get("/progress", response_model=UpdateProgressResponse)
async def get_update_progress():
"""Get current update progress.
Returns:
UpdateProgressResponse with current status
Uses UpdateService for business logic.
"""
try:
manager = get_update_manager()
progress = await manager.get_progress()
result = await container.update_service.get_progress()
return UpdateProgressResponse(
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
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return UpdateProgressResponse(**result.data)
@router.post("/download")
async def download_update():
"""Download the available update.
Returns:
Success message
Uses UpdateService for business logic.
"""
try:
manager = get_update_manager()
success = await manager.download_update()
result = await container.update_service.download_update()
if success:
return {"message": "Update downloaded successfully"}
else:
raise HTTPException(status_code=500, detail="Download failed")
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return {"message": result.data["message"]}
@router.post("/install")
async def install_update():
"""Install the downloaded update.
Returns:
Success message
Uses UpdateService for business logic.
"""
result = await container.update_service.install_update()
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
# Send BLE notification
try:
manager = get_update_manager()
ble_manager = get_ble_manager()
success = await manager.install_update()
await ble_manager.send_notification(
NotificationType.UPDATE_COMPLETE,
"Update installed successfully",
{"restart_required": True}
)
except Exception:
# BLE notification failure shouldn't affect the response
pass
if success:
# Send BLE notification
await ble_manager.send_notification(
NotificationType.UPDATE_COMPLETE,
"Update installed successfully",
{"restart_required": True}
)
return {
"message": "Update installed successfully. Please restart the service.",
"restart_required": True
}
else:
raise HTTPException(status_code=500, detail="Installation failed")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return {
"message": result.data["message"],
"restart_required": result.data.get("requires_restart", True)
}
@router.post("/release-notes", response_model=dict)
async def get_release_notes(request: ReleaseNotesRequest):
"""Get release notes for a specific version.
Uses UpdateService for business logic.
Args:
request: Version to get notes for (latest if not specified)
Returns:
Release notes as markdown
"""
try:
manager = get_update_manager()
notes = await manager.get_release_notes(request.version)
result = await container.update_service.get_release_notes(request.version)
return {
"version": request.version or "latest",
"notes": notes
}
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return {
"version": result.data["version"],
"notes": result.data["release_notes"]
}
@router.get("/current-version")
async def get_current_version():
"""Get current system version.
Returns:
Current version info
Uses UpdateService for business logic.
"""
try:
manager = get_update_manager()
progress = await manager.get_progress()
result = await container.update_service.get_progress()
return {
"version": progress.current_version,
"last_check": progress.last_check
}
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return {
"version": result.data["current_version"],
"last_check": result.data["last_check"]
}

View File

@@ -1,15 +1,13 @@
"""WiFi management API endpoints."""
"""WiFi management API endpoints.
Refactored to use WiFiService for all business logic.
Endpoints are now thin adapters that convert HTTP requests/responses.
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import List, Optional
from ..managers.wifi_manager import (
wifi_manager,
WiFiMode,
WiFiStatus,
WiFiNetwork,
WiFiInterface,
)
from ..services.container import container
router = APIRouter()
@@ -37,7 +35,7 @@ class WiFiNetworkResponse(BaseModel):
class SetModeRequest(BaseModel):
"""Request to set WiFi mode."""
mode: WiFiMode
mode: str # "ap", "client", "dual", "auto", "off"
class ConnectRequest(BaseModel):
@@ -48,213 +46,217 @@ class ConnectRequest(BaseModel):
hidden: bool = False
def _service_error_to_http_status(error_code: str) -> int:
"""Map service error codes to HTTP status codes.
Args:
error_code: Service error code
Returns:
HTTP status code
"""
codes = {
"wifi_status_error": 500,
"wifi_scan_error": 500,
"connection_failed": 503,
"wifi_connect_error": 500,
"disconnect_failed": 500,
"wifi_disconnect_error": 500,
"invalid_mode": 400,
"mode_not_supported": 400,
"mode_change_failed": 500,
"wifi_mode_error": 500,
"saved_networks_error": 500,
"network_not_found": 404,
"forget_network_error": 500,
}
return codes.get(error_code, 500)
@router.get("/status", response_model=WiFiStatusResponse)
async def get_wifi_status():
"""Get current WiFi status and available interfaces.
Returns WiFi mode, interface information, and connection status.
Uses WiFiService for business logic.
"""
try:
status: WiFiStatus = await wifi_manager.get_status()
result = await container.wifi_service.get_status()
return WiFiStatusResponse(
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,
}
for iface in status.interfaces
],
current_ssid=status.current_ssid,
current_ip=status.current_ip,
ap_ssid=status.ap_ssid,
ap_ip=status.ap_ip,
supports_dual=status.supports_dual,
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get WiFi status: {e}")
data = result.data
return WiFiStatusResponse(
mode=data["mode"],
interfaces=data["interfaces"],
current_ssid=data["client"]["ssid"] if data.get("client") else None,
current_ip=data["client"]["ip"] if data.get("client") else None,
ap_ssid=data["access_point"]["ssid"],
ap_ip=data["access_point"]["ip"],
supports_dual=data["supports_dual"]
)
@router.get("/scan", response_model=List[WiFiNetworkResponse])
async def scan_networks(interface: Optional[str] = None):
"""Scan for available WiFi networks.
Uses WiFiService for business logic.
Args:
interface: Optional interface to scan with
Returns:
List of available networks
"""
try:
networks = await wifi_manager.scan_networks(interface)
result = await container.wifi_service.scan_networks(interface)
return [
WiFiNetworkResponse(
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
]
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to scan networks: {e}")
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return [
WiFiNetworkResponse(**net)
for net in result.data["networks"]
]
@router.post("/mode")
async def set_wifi_mode(request: SetModeRequest):
"""Set WiFi operation mode.
Uses WiFiService for business logic.
Args:
request: Mode to set (ap, client, dual, auto, off)
Returns:
Success status
"""
try:
success = await wifi_manager.set_mode(request.mode)
result = await container.wifi_service.set_mode(request.mode)
if not success:
raise HTTPException(status_code=500, detail="Failed to set WiFi mode")
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return {
"success": True,
"message": f"WiFi mode set to {request.mode.value}",
"mode": request.mode.value,
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to set WiFi mode: {e}")
return {
"success": True,
"message": result.data["message"],
"mode": result.data["mode"]
}
@router.post("/connect")
async def connect_to_network(request: ConnectRequest):
"""Connect to a WiFi network.
Uses WiFiService for business logic.
Args:
request: Connection details (SSID, password, interface, hidden)
Returns:
Success status
"""
try:
success = await wifi_manager.connect_to_network(
ssid=request.ssid,
password=request.password,
interface=request.interface,
hidden=request.hidden,
result = await container.wifi_service.connect(
ssid=request.ssid,
password=request.password,
interface=request.interface,
hidden=request.hidden
)
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
if not success:
raise HTTPException(status_code=500, detail="Failed to connect to network")
return {
"success": True,
"message": f"Connected to {request.ssid}",
"ssid": request.ssid,
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to connect to network: {e}")
return {
"success": True,
"message": result.data["message"],
"ssid": result.data["ssid"],
"ip": result.data.get("ip")
}
@router.get("/interfaces")
async def get_interfaces():
"""Get available WiFi interfaces.
Returns:
List of WiFi interfaces with their status
Uses WiFiService for business logic.
"""
try:
interfaces = await wifi_manager.detect_interfaces()
result = await container.wifi_service.get_status()
return {
"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,
}
for iface in interfaces
],
"supports_dual": len(interfaces) >= 2,
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get interfaces: {e}")
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return {
"interfaces": result.data["interfaces"],
"supports_dual": result.data["supports_dual"]
}
@router.post("/disconnect")
async def disconnect_from_network(interface: Optional[str] = None):
"""Disconnect from current network.
Uses WiFiService for business logic.
Args:
interface: Interface to disconnect (optional)
Returns:
Success status
"""
try:
success = await wifi_manager.disconnect_from_network(interface)
result = await container.wifi_service.disconnect(interface)
if not success:
raise HTTPException(status_code=500, detail="Failed to disconnect")
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return {
"success": True,
"message": "Disconnected from network",
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to disconnect: {e}")
return {
"success": True,
"message": result.data["message"]
}
@router.get("/saved")
async def get_saved_networks():
"""Get list of saved networks.
Returns:
List of saved networks
Uses WiFiService for business logic.
"""
try:
networks = await wifi_manager.get_saved_networks()
return {"networks": networks}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get saved networks: {e}")
result = await container.wifi_service.get_saved_networks()
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return {"networks": result.data["networks"]}
@router.delete("/saved/{ssid}")
async def forget_network(ssid: str):
"""Forget a saved network.
Uses WiFiService for business logic.
Args:
ssid: SSID of network to forget
Returns:
Success status
"""
try:
success = await wifi_manager.forget_network(ssid)
result = await container.wifi_service.forget_network(ssid)
if not success:
raise HTTPException(status_code=404, detail=f"Network {ssid} not found")
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return {
"success": True,
"message": f"Forgot network {ssid}",
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to forget network: {e}")
return {
"success": True,
"message": result.data["message"]
}
@router.post("/static-ip")
@@ -267,18 +269,18 @@ async def set_static_ip(
):
"""Set static IP for interface.
NOTE: This is a direct manager operation (not yet in WiFiService).
TODO: Move to WiFiService in future iteration.
Args:
interface: Interface name
ip_address: Static IP address
netmask: Network mask (default: 255.255.255.0)
gateway: Gateway IP (optional)
dns: DNS servers (optional)
Returns:
Success status
"""
try:
success = await wifi_manager.set_static_ip(
success = await container.wifi_manager.set_static_ip(
interface, ip_address, netmask, gateway, dns
)
@@ -297,14 +299,14 @@ async def set_static_ip(
async def enable_dhcp(interface: str):
"""Enable DHCP for interface.
NOTE: This is a direct manager operation (not yet in WiFiService).
TODO: Move to WiFiService in future iteration.
Args:
interface: Interface name
Returns:
Success status
"""
try:
success = await wifi_manager.enable_dhcp(interface)
success = await container.wifi_manager.enable_dhcp(interface)
if not success:
raise HTTPException(status_code=500, detail="Failed to enable DHCP")