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:
63
app/backend/api/auth.py
Normal file
63
app/backend/api/auth.py
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user