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:
1
stageDangerousPi/03-dangerous-pi/files/app/__init__.py
Normal file
1
stageDangerousPi/03-dangerous-pi/files/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Dangerous Pi application."""
|
||||
@@ -0,0 +1 @@
|
||||
"""API routers."""
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Health check endpoints."""
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
version: str
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
return HealthResponse(
|
||||
status="healthy",
|
||||
version="0.1.0"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ready")
|
||||
async def readiness_check():
|
||||
"""Readiness check endpoint."""
|
||||
# TODO: Check if PM3 is connected, database is accessible, etc.
|
||||
return {"ready": True}
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Plugin management API endpoints."""
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..managers.plugin_manager import get_plugin_manager
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class PluginMetadataResponse(BaseModel):
|
||||
"""Plugin metadata response model."""
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
description: str
|
||||
author: str
|
||||
homepage: Optional[str] = None
|
||||
dependencies: List[str] = []
|
||||
permissions: List[str] = []
|
||||
|
||||
|
||||
class PluginInfoResponse(BaseModel):
|
||||
"""Plugin info response model."""
|
||||
metadata: PluginMetadataResponse
|
||||
status: str
|
||||
enabled: bool
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
class PluginActionRequest(BaseModel):
|
||||
"""Request model for plugin actions."""
|
||||
plugin_id: str
|
||||
|
||||
|
||||
@router.get("/discover")
|
||||
async def discover_plugins():
|
||||
"""Discover all available plugins.
|
||||
|
||||
Returns:
|
||||
List of discovered plugin IDs
|
||||
"""
|
||||
try:
|
||||
manager = get_plugin_manager()
|
||||
discovered = await manager.discover_plugins()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"plugins": discovered,
|
||||
"count": len(discovered)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/list", response_model=List[PluginInfoResponse])
|
||||
async def list_plugins():
|
||||
"""Get list of all plugins.
|
||||
|
||||
Returns:
|
||||
List of plugin information
|
||||
"""
|
||||
try:
|
||||
manager = get_plugin_manager()
|
||||
plugins = manager.get_all_plugins()
|
||||
|
||||
result = []
|
||||
for plugin_id, plugin_info in plugins.items():
|
||||
result.append(PluginInfoResponse(
|
||||
metadata=PluginMetadataResponse(
|
||||
id=plugin_info.metadata.id,
|
||||
name=plugin_info.metadata.name,
|
||||
version=plugin_info.metadata.version,
|
||||
description=plugin_info.metadata.description,
|
||||
author=plugin_info.metadata.author,
|
||||
homepage=plugin_info.metadata.homepage,
|
||||
dependencies=plugin_info.metadata.dependencies,
|
||||
permissions=plugin_info.metadata.permissions
|
||||
),
|
||||
status=plugin_info.status.value,
|
||||
enabled=plugin_info.enabled,
|
||||
error_message=plugin_info.error_message
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{plugin_id}", response_model=PluginInfoResponse)
|
||||
async def get_plugin_info(plugin_id: str):
|
||||
"""Get information about a specific plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: ID of the plugin
|
||||
|
||||
Returns:
|
||||
Plugin information
|
||||
"""
|
||||
try:
|
||||
manager = get_plugin_manager()
|
||||
plugin_info = manager.get_plugin_info(plugin_id)
|
||||
|
||||
if not plugin_info:
|
||||
raise HTTPException(status_code=404, detail="Plugin not found")
|
||||
|
||||
return PluginInfoResponse(
|
||||
metadata=PluginMetadataResponse(
|
||||
id=plugin_info.metadata.id,
|
||||
name=plugin_info.metadata.name,
|
||||
version=plugin_info.metadata.version,
|
||||
description=plugin_info.metadata.description,
|
||||
author=plugin_info.metadata.author,
|
||||
homepage=plugin_info.metadata.homepage,
|
||||
dependencies=plugin_info.metadata.dependencies,
|
||||
permissions=plugin_info.metadata.permissions
|
||||
),
|
||||
status=plugin_info.status.value,
|
||||
enabled=plugin_info.enabled,
|
||||
error_message=plugin_info.error_message
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/enable")
|
||||
async def enable_plugin(request: PluginActionRequest):
|
||||
"""Enable a plugin.
|
||||
|
||||
Args:
|
||||
request: Plugin action request with plugin_id
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
try:
|
||||
manager = get_plugin_manager()
|
||||
success = await manager.enable_plugin(request.plugin_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to enable plugin")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Plugin {request.plugin_id} enabled"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/disable")
|
||||
async def disable_plugin(request: PluginActionRequest):
|
||||
"""Disable a plugin.
|
||||
|
||||
Args:
|
||||
request: Plugin action request with plugin_id
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
try:
|
||||
manager = get_plugin_manager()
|
||||
success = await manager.disable_plugin(request.plugin_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to disable plugin")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Plugin {request.plugin_id} disabled"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/load")
|
||||
async def load_plugin(request: PluginActionRequest):
|
||||
"""Load a plugin into memory.
|
||||
|
||||
Args:
|
||||
request: Plugin action request with plugin_id
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
try:
|
||||
manager = get_plugin_manager()
|
||||
success = await manager.load_plugin(request.plugin_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to load plugin")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Plugin {request.plugin_id} loaded"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/unload")
|
||||
async def unload_plugin(request: PluginActionRequest):
|
||||
"""Unload a plugin from memory.
|
||||
|
||||
Args:
|
||||
request: Plugin action request with plugin_id
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
try:
|
||||
manager = get_plugin_manager()
|
||||
success = await manager.unload_plugin(request.plugin_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to unload plugin")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Plugin {request.plugin_id} unloaded"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
398
stageDangerousPi/03-dangerous-pi/files/app/backend/api/pm3.py
Normal file
398
stageDangerousPi/03-dangerous-pi/files/app/backend/api/pm3.py
Normal file
@@ -0,0 +1,398 @@
|
||||
"""Proxmark3 API endpoints.
|
||||
|
||||
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()
|
||||
|
||||
|
||||
class CommandRequest(BaseModel):
|
||||
command: str
|
||||
session_id: Optional[str] = None
|
||||
device_id: Optional[str] = None # Multi-device support
|
||||
|
||||
|
||||
class CommandResponse(BaseModel):
|
||||
success: bool
|
||||
output: str
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
connected: bool
|
||||
device: str
|
||||
version: Optional[str] = None
|
||||
session_active: bool
|
||||
|
||||
|
||||
class FirmwareInfo(BaseModel):
|
||||
"""Firmware information for a device."""
|
||||
bootrom_version: Optional[str] = None
|
||||
os_version: Optional[str] = None
|
||||
compatible: bool = False
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
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=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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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=result.error.message
|
||||
)
|
||||
|
||||
# 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}"
|
||||
|
||||
return CommandResponse(
|
||||
success=False,
|
||||
output="",
|
||||
error=error_msg
|
||||
)
|
||||
|
||||
|
||||
@router.get("/commands/history")
|
||||
async def get_command_history(limit: int = 50):
|
||||
"""Get recent command history.
|
||||
|
||||
TODO: Implement command history in PM3Service or separate HistoryService
|
||||
"""
|
||||
# TODO: Implement database query
|
||||
return {"history": []}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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"]
|
||||
)
|
||||
571
stageDangerousPi/03-dangerous-pi/files/app/backend/api/system.py
Normal file
571
stageDangerousPi/03-dangerous-pi/files/app/backend/api/system.py
Normal file
@@ -0,0 +1,571 @@
|
||||
"""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 ..services.container import container
|
||||
from ..managers.ups_manager import get_ups_manager
|
||||
from ..managers.ble_manager import get_ble_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
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
|
||||
disk_total: float
|
||||
|
||||
|
||||
@router.post("/session/create", response_model=CreateSessionResponse)
|
||||
async def create_session(request: Request, body: CreateSessionRequest):
|
||||
"""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")
|
||||
|
||||
result = await container.pm3_service.create_session(
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
force_takeover=body.force_takeover,
|
||||
device_id=body.device_id
|
||||
)
|
||||
|
||||
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, device_id: Optional[str] = None):
|
||||
"""Release an active session.
|
||||
|
||||
Uses PM3Service for session management.
|
||||
|
||||
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(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
|
||||
|
||||
import time
|
||||
from .. import config
|
||||
|
||||
time_remaining = config.SESSION_TIMEOUT - (time.time() - session.last_activity)
|
||||
|
||||
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,
|
||||
time_remaining=max(0, time_remaining)
|
||||
)
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
Refactored to use SystemService for business logic.
|
||||
"""
|
||||
from ..services.container import container
|
||||
|
||||
result = await container.system_service.get_info()
|
||||
|
||||
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=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"]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_config():
|
||||
"""Get system configuration (non-sensitive values)."""
|
||||
from .. import config
|
||||
|
||||
return {
|
||||
"pm3_device": config.PM3_DEVICE,
|
||||
"session_timeout": config.SESSION_TIMEOUT,
|
||||
"wifi_mode": "auto", # TODO: Get from wifi manager
|
||||
"ble_enabled": config.BLE_ENABLED,
|
||||
"auth_enabled": config.AUTH_ENABLED,
|
||||
"https_enabled": config.HTTPS_ENABLED
|
||||
}
|
||||
|
||||
|
||||
@router.post("/restart")
|
||||
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(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):
|
||||
"""UPS status response model."""
|
||||
battery_percentage: float
|
||||
voltage: float
|
||||
current: float
|
||||
power_source: str
|
||||
battery_status: str
|
||||
time_remaining: Optional[int] = None
|
||||
temperature: Optional[float] = None
|
||||
last_updated: Optional[str] = None
|
||||
is_available: bool
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
class UPSThresholdsRequest(BaseModel):
|
||||
"""Request to set UPS battery thresholds."""
|
||||
shutdown_threshold: Optional[float] = None
|
||||
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."""
|
||||
ups_manager = get_ups_manager()
|
||||
status = await ups_manager.get_status()
|
||||
|
||||
return UPSStatusResponse(
|
||||
battery_percentage=status.battery_percentage,
|
||||
voltage=status.voltage,
|
||||
current=status.current,
|
||||
power_source=status.power_source.value,
|
||||
battery_status=status.battery_status.value,
|
||||
time_remaining=status.time_remaining,
|
||||
temperature=status.temperature,
|
||||
last_updated=status.last_updated,
|
||||
is_available=status.is_available,
|
||||
error_message=status.error_message
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ups/thresholds")
|
||||
async def set_ups_thresholds(request: UPSThresholdsRequest):
|
||||
"""Set UPS battery thresholds for warnings and shutdown."""
|
||||
ups_manager = get_ups_manager()
|
||||
|
||||
if request.shutdown_threshold is not None:
|
||||
ups_manager.set_shutdown_threshold(request.shutdown_threshold)
|
||||
|
||||
if request.warning_threshold is not None:
|
||||
ups_manager.set_warning_threshold(request.warning_threshold)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "UPS thresholds updated"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/ups/shutdown")
|
||||
async def trigger_ups_shutdown(delay: int = 30):
|
||||
"""Trigger safe shutdown via UPS manager.
|
||||
|
||||
Args:
|
||||
delay: Delay in seconds before shutdown (default: 30)
|
||||
"""
|
||||
ups_manager = get_ups_manager()
|
||||
await ups_manager.shutdown_device(delay=delay)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Shutdown initiated with {delay}s delay"
|
||||
}
|
||||
|
||||
|
||||
@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
|
||||
available: bool
|
||||
advertising: bool
|
||||
connected_devices: int
|
||||
device_name: str
|
||||
queued_notifications: int
|
||||
|
||||
|
||||
class SendNotificationRequest(BaseModel):
|
||||
"""Request to send a BLE notification."""
|
||||
type: str
|
||||
message: str
|
||||
data: Optional[Dict] = None
|
||||
|
||||
|
||||
@router.get("/ble/status", response_model=BLEStatusResponse)
|
||||
async def get_ble_status():
|
||||
"""Get BLE manager status."""
|
||||
ble_manager = get_ble_manager()
|
||||
status = await ble_manager.get_status()
|
||||
|
||||
return BLEStatusResponse(**status)
|
||||
|
||||
|
||||
@router.post("/ble/advertising/start")
|
||||
async def start_ble_advertising():
|
||||
"""Start BLE advertising."""
|
||||
ble_manager = get_ble_manager()
|
||||
|
||||
if not ble_manager.is_available():
|
||||
raise HTTPException(status_code=503, detail="BLE not available")
|
||||
|
||||
await ble_manager.start_advertising()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "BLE advertising started"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/ble/advertising/stop")
|
||||
async def stop_ble_advertising():
|
||||
"""Stop BLE advertising."""
|
||||
ble_manager = get_ble_manager()
|
||||
await ble_manager.stop_advertising()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "BLE advertising stopped"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/ble/notify")
|
||||
async def send_ble_notification(request: SendNotificationRequest):
|
||||
"""Send a BLE notification to connected devices."""
|
||||
ble_manager = get_ble_manager()
|
||||
|
||||
if not ble_manager.is_available():
|
||||
raise HTTPException(status_code=503, detail="BLE not available")
|
||||
|
||||
from ..managers.ble_manager import NotificationType
|
||||
|
||||
# Validate notification type
|
||||
try:
|
||||
notification_type = NotificationType(request.type)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid notification type: {request.type}")
|
||||
|
||||
await ble_manager.send_notification(
|
||||
notification_type=notification_type,
|
||||
message=request.message,
|
||||
data=request.data
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Notification sent"
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"""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 ..services.container import container
|
||||
from ..managers.ble_manager import get_ble_manager, NotificationType
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class UpdateCheckResponse(BaseModel):
|
||||
"""Response model for update check."""
|
||||
update_available: bool
|
||||
current_version: str
|
||||
latest_version: Optional[str] = None
|
||||
release_date: Optional[str] = None
|
||||
changelog: Optional[str] = None
|
||||
is_prerelease: bool = False
|
||||
download_size: Optional[int] = None
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class UpdateProgressResponse(BaseModel):
|
||||
"""Response model for update progress."""
|
||||
status: str
|
||||
current_version: str
|
||||
available_version: Optional[str] = None
|
||||
download_progress: float = 0.0
|
||||
error_message: Optional[str] = None
|
||||
last_check: Optional[str] = None
|
||||
|
||||
|
||||
class ReleaseNotesRequest(BaseModel):
|
||||
"""Request model for release notes."""
|
||||
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.
|
||||
|
||||
Uses UpdateService for business logic.
|
||||
"""
|
||||
result = await container.update_service.check_for_updates()
|
||||
|
||||
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.data['latest_version']}",
|
||||
{"version": result.data["latest_version"]}
|
||||
)
|
||||
except Exception:
|
||||
# BLE notification failure shouldn't affect the response
|
||||
pass
|
||||
|
||||
return UpdateCheckResponse(**result.data)
|
||||
|
||||
|
||||
@router.get("/progress", response_model=UpdateProgressResponse)
|
||||
async def get_update_progress():
|
||||
"""Get current update progress.
|
||||
|
||||
Uses UpdateService for business logic.
|
||||
"""
|
||||
result = await container.update_service.get_progress()
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
return UpdateProgressResponse(**result.data)
|
||||
|
||||
|
||||
@router.post("/download")
|
||||
async def download_update():
|
||||
"""Download the available update.
|
||||
|
||||
Uses UpdateService for business logic.
|
||||
"""
|
||||
result = await container.update_service.download_update()
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
return {"message": result.data["message"]}
|
||||
|
||||
|
||||
@router.post("/install")
|
||||
async def install_update():
|
||||
"""Install the downloaded update.
|
||||
|
||||
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:
|
||||
ble_manager = get_ble_manager()
|
||||
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
|
||||
|
||||
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)
|
||||
"""
|
||||
result = await container.update_service.get_release_notes(request.version)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
return {
|
||||
"version": result.data["version"],
|
||||
"notes": result.data["release_notes"]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/current-version")
|
||||
async def get_current_version():
|
||||
"""Get current system version.
|
||||
|
||||
Uses UpdateService for business logic.
|
||||
"""
|
||||
result = await container.update_service.get_progress()
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
return {
|
||||
"version": result.data["current_version"],
|
||||
"last_check": result.data["last_check"]
|
||||
}
|
||||
319
stageDangerousPi/03-dangerous-pi/files/app/backend/api/wifi.py
Normal file
319
stageDangerousPi/03-dangerous-pi/files/app/backend/api/wifi.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""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 ..services.container import container
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class WiFiStatusResponse(BaseModel):
|
||||
"""WiFi status response."""
|
||||
mode: str
|
||||
interfaces: List[dict]
|
||||
current_ssid: Optional[str]
|
||||
current_ip: Optional[str]
|
||||
ap_ssid: Optional[str]
|
||||
ap_ip: Optional[str]
|
||||
supports_dual: bool
|
||||
|
||||
|
||||
class WiFiNetworkResponse(BaseModel):
|
||||
"""WiFi network response."""
|
||||
ssid: str
|
||||
bssid: str
|
||||
signal_strength: int
|
||||
frequency: int
|
||||
encrypted: bool
|
||||
in_use: bool
|
||||
|
||||
|
||||
class SetModeRequest(BaseModel):
|
||||
"""Request to set WiFi mode."""
|
||||
mode: str # "ap", "client", "dual", "auto", "off"
|
||||
|
||||
|
||||
class ConnectRequest(BaseModel):
|
||||
"""Request to connect to network."""
|
||||
ssid: str
|
||||
password: Optional[str] = None
|
||||
interface: Optional[str] = None
|
||||
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.
|
||||
|
||||
Uses WiFiService for business logic.
|
||||
"""
|
||||
result = await container.wifi_service.get_status()
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
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
|
||||
"""
|
||||
result = await container.wifi_service.scan_networks(interface)
|
||||
|
||||
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)
|
||||
"""
|
||||
result = await container.wifi_service.set_mode(request.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": 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)
|
||||
"""
|
||||
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
|
||||
)
|
||||
|
||||
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.
|
||||
|
||||
Uses WiFiService for business logic.
|
||||
"""
|
||||
result = await container.wifi_service.get_status()
|
||||
|
||||
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)
|
||||
"""
|
||||
result = await container.wifi_service.disconnect(interface)
|
||||
|
||||
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("/saved")
|
||||
async def get_saved_networks():
|
||||
"""Get list of saved networks.
|
||||
|
||||
Uses WiFiService for business logic.
|
||||
"""
|
||||
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
|
||||
"""
|
||||
result = await container.wifi_service.forget_network(ssid)
|
||||
|
||||
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("/static-ip")
|
||||
async def set_static_ip(
|
||||
interface: str,
|
||||
ip_address: str,
|
||||
netmask: str = "255.255.255.0",
|
||||
gateway: Optional[str] = None,
|
||||
dns: Optional[List[str]] = None,
|
||||
):
|
||||
"""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)
|
||||
"""
|
||||
try:
|
||||
success = await container.wifi_manager.set_static_ip(
|
||||
interface, ip_address, netmask, gateway, dns
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to set static IP")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Static IP {ip_address} set for {interface}",
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to set static IP: {e}")
|
||||
|
||||
|
||||
@router.post("/dhcp")
|
||||
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
|
||||
"""
|
||||
try:
|
||||
success = await container.wifi_manager.enable_dhcp(interface)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to enable DHCP")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"DHCP enabled for {interface}",
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to enable DHCP: {e}")
|
||||
@@ -0,0 +1,20 @@
|
||||
"""BLE GATT server implementation for Dangerous Pi.
|
||||
|
||||
This module provides Bluetooth Low Energy (BLE) GATT server functionality
|
||||
that reuses the service layer for business logic, ensuring consistency
|
||||
with the REST API.
|
||||
"""
|
||||
|
||||
from .gatt_server import DangerousPiGATTServer
|
||||
from .characteristics import (
|
||||
PM3CharacteristicUUIDs,
|
||||
WiFiCharacteristicUUIDs,
|
||||
SystemCharacteristicUUIDs,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DangerousPiGATTServer",
|
||||
"PM3CharacteristicUUIDs",
|
||||
"WiFiCharacteristicUUIDs",
|
||||
"SystemCharacteristicUUIDs",
|
||||
]
|
||||
@@ -0,0 +1,100 @@
|
||||
"""GATT Characteristic UUIDs for Dangerous Pi BLE interface.
|
||||
|
||||
This module defines the UUIDs for all GATT services and characteristics
|
||||
used by the Dangerous Pi BLE interface.
|
||||
|
||||
UUID Namespace: Dangerous Pi uses custom UUIDs in the format:
|
||||
- Base UUID: 00000000-1234-5678-1234-56789abcdef0
|
||||
- Service UUIDs end in 00-0f
|
||||
- Characteristic UUIDs end in 10-ff
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
# Base UUID template
|
||||
BASE_UUID = "00000000-1234-5678-1234-56789abcdef{}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PM3CharacteristicUUIDs:
|
||||
"""PM3 GATT Service and Characteristics.
|
||||
|
||||
Service for Proxmark3 operations (command execution, status).
|
||||
"""
|
||||
# Service UUID
|
||||
SERVICE = BASE_UUID.format("0")
|
||||
|
||||
# Characteristics
|
||||
COMMAND_WRITE = BASE_UUID.format("1") # Write: Execute PM3 command
|
||||
COMMAND_RESULT = BASE_UUID.format("2") # Notify: Command result
|
||||
STATUS = BASE_UUID.format("3") # Read: Get PM3 status
|
||||
SESSION_CREATE = BASE_UUID.format("4") # Write: Create session
|
||||
SESSION_RELEASE = BASE_UUID.format("5") # Write: Release session
|
||||
SESSION_INFO = BASE_UUID.format("6") # Read: Get session info
|
||||
|
||||
|
||||
@dataclass
|
||||
class WiFiCharacteristicUUIDs:
|
||||
"""WiFi GATT Service and Characteristics.
|
||||
|
||||
Service for WiFi network management (scan, connect, status).
|
||||
"""
|
||||
# Service UUID
|
||||
SERVICE = BASE_UUID.format("10")
|
||||
|
||||
# Characteristics
|
||||
STATUS = BASE_UUID.format("11") # Read: Get WiFi status
|
||||
SCAN = BASE_UUID.format("12") # Write: Trigger scan, Notify: Results
|
||||
CONNECT = BASE_UUID.format("13") # Write: Connect to network
|
||||
DISCONNECT = BASE_UUID.format("14") # Write: Disconnect
|
||||
MODE = BASE_UUID.format("15") # Read/Write: WiFi mode
|
||||
SAVED_NETWORKS = BASE_UUID.format("16") # Read: Get saved networks
|
||||
FORGET_NETWORK = BASE_UUID.format("17") # Write: Forget network
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemCharacteristicUUIDs:
|
||||
"""System GATT Service and Characteristics.
|
||||
|
||||
Service for system operations (info, shutdown, restart).
|
||||
"""
|
||||
# Service UUID
|
||||
SERVICE = BASE_UUID.format("20")
|
||||
|
||||
# Characteristics
|
||||
INFO = BASE_UUID.format("21") # Read: Get system info
|
||||
SHUTDOWN = BASE_UUID.format("22") # Write: Initiate shutdown
|
||||
RESTART = BASE_UUID.format("23") # Write: Initiate restart
|
||||
LOGS = BASE_UUID.format("24") # Read: Get service logs
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateCharacteristicUUIDs:
|
||||
"""Update GATT Service and Characteristics.
|
||||
|
||||
Service for software update management.
|
||||
"""
|
||||
# Service UUID
|
||||
SERVICE = BASE_UUID.format("30")
|
||||
|
||||
# Characteristics
|
||||
CHECK = BASE_UUID.format("31") # Write: Check for updates, Notify: Result
|
||||
DOWNLOAD = BASE_UUID.format("32") # Write: Download update
|
||||
INSTALL = BASE_UUID.format("33") # Write: Install update
|
||||
PROGRESS = BASE_UUID.format("34") # Read/Notify: Update progress
|
||||
RELEASE_NOTES = BASE_UUID.format("35") # Read: Get release notes
|
||||
|
||||
|
||||
# Characteristic properties
|
||||
class CharacteristicProperties:
|
||||
"""Standard GATT characteristic properties."""
|
||||
READ = "read"
|
||||
WRITE = "write"
|
||||
WRITE_WITHOUT_RESPONSE = "write-without-response"
|
||||
NOTIFY = "notify"
|
||||
INDICATE = "indicate"
|
||||
|
||||
|
||||
# Characteristic descriptors
|
||||
CHARACTERISTIC_USER_DESCRIPTION_UUID = "00002901-0000-1000-8000-00805f9b34fb"
|
||||
CLIENT_CHARACTERISTIC_CONFIG_UUID = "00002902-0000-1000-8000-00805f9b34fb"
|
||||
@@ -0,0 +1,706 @@
|
||||
"""GATT Server implementation for Dangerous Pi.
|
||||
|
||||
This GATT server provides BLE access to all Dangerous Pi functionality by
|
||||
reusing the service layer. This ensures identical behavior between REST API
|
||||
and BLE interface - NO CODE DUPLICATION!
|
||||
|
||||
Architecture:
|
||||
BLE GATT Characteristic Handler
|
||||
↓
|
||||
Service Layer (PM3Service, WiFiService, etc.)
|
||||
↓
|
||||
Managers/Workers (PM3Worker, WiFiManager, etc.)
|
||||
|
||||
The handlers are thin adapters, just like REST endpoints.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..services.container import container
|
||||
from .characteristics import (
|
||||
PM3CharacteristicUUIDs,
|
||||
WiFiCharacteristicUUIDs,
|
||||
SystemCharacteristicUUIDs,
|
||||
UpdateCharacteristicUUIDs,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CharacteristicHandler:
|
||||
"""Configuration for a GATT characteristic handler."""
|
||||
uuid: str
|
||||
properties: list # ["read", "write", "notify"]
|
||||
read_handler: Optional[Callable] = None
|
||||
write_handler: Optional[Callable] = None
|
||||
description: str = ""
|
||||
|
||||
|
||||
class DangerousPiGATTServer:
|
||||
"""GATT server for Dangerous Pi BLE interface.
|
||||
|
||||
This server exposes Dangerous Pi functionality over BLE by reusing
|
||||
the service layer. All business logic comes from services, ensuring
|
||||
consistency with the REST API.
|
||||
|
||||
Key Features:
|
||||
- Uses ServiceContainer for all operations (same as REST!)
|
||||
- Converts BLE data formats to/from service calls
|
||||
- Sends notifications for async operations
|
||||
- Zero business logic duplication
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize GATT server."""
|
||||
self.is_running = False
|
||||
self._notification_callbacks: Dict[str, Callable] = {}
|
||||
self._characteristic_handlers: Dict[str, CharacteristicHandler] = {}
|
||||
|
||||
# Register all characteristic handlers
|
||||
self._register_pm3_characteristics()
|
||||
self._register_wifi_characteristics()
|
||||
self._register_system_characteristics()
|
||||
self._register_update_characteristics()
|
||||
|
||||
logger.info("GATT server initialized with %d characteristics",
|
||||
len(self._characteristic_handlers))
|
||||
|
||||
# ========================================================================
|
||||
# PM3 Characteristic Handlers
|
||||
# ========================================================================
|
||||
|
||||
def _register_pm3_characteristics(self):
|
||||
"""Register PM3 GATT characteristics.
|
||||
|
||||
All handlers delegate to PM3Service - NO business logic here!
|
||||
"""
|
||||
self._characteristic_handlers.update({
|
||||
PM3CharacteristicUUIDs.COMMAND_WRITE: CharacteristicHandler(
|
||||
uuid=PM3CharacteristicUUIDs.COMMAND_WRITE,
|
||||
properties=["write"],
|
||||
write_handler=self._handle_pm3_command_write,
|
||||
description="Execute PM3 command"
|
||||
),
|
||||
PM3CharacteristicUUIDs.STATUS: CharacteristicHandler(
|
||||
uuid=PM3CharacteristicUUIDs.STATUS,
|
||||
properties=["read"],
|
||||
read_handler=self._handle_pm3_status_read,
|
||||
description="Get PM3 status"
|
||||
),
|
||||
PM3CharacteristicUUIDs.SESSION_CREATE: CharacteristicHandler(
|
||||
uuid=PM3CharacteristicUUIDs.SESSION_CREATE,
|
||||
properties=["write"],
|
||||
write_handler=self._handle_session_create_write,
|
||||
description="Create PM3 session"
|
||||
),
|
||||
PM3CharacteristicUUIDs.SESSION_RELEASE: CharacteristicHandler(
|
||||
uuid=PM3CharacteristicUUIDs.SESSION_RELEASE,
|
||||
properties=["write"],
|
||||
write_handler=self._handle_session_release_write,
|
||||
description="Release PM3 session"
|
||||
),
|
||||
})
|
||||
|
||||
async def _handle_pm3_command_write(self, value: bytes) -> Dict[str, Any]:
|
||||
"""Handle PM3 command execution via BLE.
|
||||
|
||||
This is a THIN ADAPTER - delegates to PM3Service!
|
||||
|
||||
Args:
|
||||
value: JSON bytes: {"command": "hw version", "session_id": "..."}
|
||||
|
||||
Returns:
|
||||
Response dict to send via notification
|
||||
"""
|
||||
try:
|
||||
# Parse BLE request
|
||||
data = json.loads(value.decode('utf-8'))
|
||||
command = data.get("command")
|
||||
session_id = data.get("session_id")
|
||||
|
||||
if not command:
|
||||
return {
|
||||
"success": False,
|
||||
"error": {"code": "invalid_request", "message": "Command required"}
|
||||
}
|
||||
|
||||
# ✅ REUSE PM3Service - same logic as REST API!
|
||||
result = await container.pm3_service.execute_command(
|
||||
command=command,
|
||||
session_id=session_id
|
||||
)
|
||||
|
||||
# Convert service result to BLE response
|
||||
if result.success:
|
||||
response = {
|
||||
"success": True,
|
||||
"output": result.data["output"],
|
||||
"command": result.data["command"]
|
||||
}
|
||||
else:
|
||||
response = {
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": result.error.code,
|
||||
"message": result.error.message
|
||||
}
|
||||
}
|
||||
|
||||
# Send notification with result
|
||||
await self._notify_characteristic(
|
||||
PM3CharacteristicUUIDs.COMMAND_RESULT,
|
||||
json.dumps(response).encode('utf-8')
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return {
|
||||
"success": False,
|
||||
"error": {"code": "invalid_json", "message": "Invalid JSON"}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Error handling PM3 command: %s", e)
|
||||
return {
|
||||
"success": False,
|
||||
"error": {"code": "internal_error", "message": str(e)}
|
||||
}
|
||||
|
||||
async def _handle_pm3_status_read(self) -> bytes:
|
||||
"""Handle PM3 status read via BLE.
|
||||
|
||||
This is a THIN ADAPTER - delegates to PM3Service!
|
||||
|
||||
Returns:
|
||||
JSON bytes with PM3 status
|
||||
"""
|
||||
try:
|
||||
# ✅ REUSE PM3Service - same logic as REST API!
|
||||
result = await container.pm3_service.get_status()
|
||||
|
||||
if result.success:
|
||||
response = {
|
||||
"success": True,
|
||||
"connected": result.data["connected"],
|
||||
"device_path": result.data["device_path"],
|
||||
"version": result.data.get("version"),
|
||||
"session_active": result.data["session_active"]
|
||||
}
|
||||
else:
|
||||
response = {
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": result.error.code,
|
||||
"message": result.error.message
|
||||
}
|
||||
}
|
||||
|
||||
return json.dumps(response).encode('utf-8')
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting PM3 status: %s", e)
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": {"code": "internal_error", "message": str(e)}
|
||||
}).encode('utf-8')
|
||||
|
||||
async def _handle_session_create_write(self, value: bytes) -> Dict[str, Any]:
|
||||
"""Handle session creation via BLE.
|
||||
|
||||
Args:
|
||||
value: JSON bytes: {"force_takeover": false}
|
||||
"""
|
||||
try:
|
||||
data = json.loads(value.decode('utf-8'))
|
||||
force_takeover = data.get("force_takeover", False)
|
||||
|
||||
# ✅ REUSE PM3Service
|
||||
result = container.pm3_service.create_session(
|
||||
force_takeover=force_takeover
|
||||
)
|
||||
|
||||
if result.success:
|
||||
return {
|
||||
"success": True,
|
||||
"session_id": result.data["session_id"]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": result.error.code,
|
||||
"message": result.error.message
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error creating session: %s", e)
|
||||
return {
|
||||
"success": False,
|
||||
"error": {"code": "internal_error", "message": str(e)}
|
||||
}
|
||||
|
||||
async def _handle_session_release_write(self, value: bytes) -> Dict[str, Any]:
|
||||
"""Handle session release via BLE.
|
||||
|
||||
Args:
|
||||
value: JSON bytes: {"session_id": "..."}
|
||||
"""
|
||||
try:
|
||||
data = json.loads(value.decode('utf-8'))
|
||||
session_id = data.get("session_id")
|
||||
|
||||
if not session_id:
|
||||
return {
|
||||
"success": False,
|
||||
"error": {"code": "invalid_request", "message": "Session ID required"}
|
||||
}
|
||||
|
||||
# ✅ REUSE PM3Service
|
||||
result = container.pm3_service.release_session(session_id)
|
||||
|
||||
if result.success:
|
||||
return {"success": True, "message": result.data["message"]}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": result.error.code,
|
||||
"message": result.error.message
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error releasing session: %s", e)
|
||||
return {
|
||||
"success": False,
|
||||
"error": {"code": "internal_error", "message": str(e)}
|
||||
}
|
||||
|
||||
# ========================================================================
|
||||
# WiFi Characteristic Handlers
|
||||
# ========================================================================
|
||||
|
||||
def _register_wifi_characteristics(self):
|
||||
"""Register WiFi GATT characteristics."""
|
||||
self._characteristic_handlers.update({
|
||||
WiFiCharacteristicUUIDs.STATUS: CharacteristicHandler(
|
||||
uuid=WiFiCharacteristicUUIDs.STATUS,
|
||||
properties=["read"],
|
||||
read_handler=self._handle_wifi_status_read,
|
||||
description="Get WiFi status"
|
||||
),
|
||||
WiFiCharacteristicUUIDs.SCAN: CharacteristicHandler(
|
||||
uuid=WiFiCharacteristicUUIDs.SCAN,
|
||||
properties=["write", "notify"],
|
||||
write_handler=self._handle_wifi_scan_write,
|
||||
description="Scan for WiFi networks"
|
||||
),
|
||||
WiFiCharacteristicUUIDs.CONNECT: CharacteristicHandler(
|
||||
uuid=WiFiCharacteristicUUIDs.CONNECT,
|
||||
properties=["write"],
|
||||
write_handler=self._handle_wifi_connect_write,
|
||||
description="Connect to WiFi network"
|
||||
),
|
||||
WiFiCharacteristicUUIDs.MODE: CharacteristicHandler(
|
||||
uuid=WiFiCharacteristicUUIDs.MODE,
|
||||
properties=["read", "write"],
|
||||
read_handler=self._handle_wifi_mode_read,
|
||||
write_handler=self._handle_wifi_mode_write,
|
||||
description="WiFi mode"
|
||||
),
|
||||
})
|
||||
|
||||
async def _handle_wifi_status_read(self) -> bytes:
|
||||
"""Handle WiFi status read via BLE."""
|
||||
try:
|
||||
# ✅ REUSE WiFiService
|
||||
result = await container.wifi_service.get_status()
|
||||
|
||||
if result.success:
|
||||
return json.dumps(result.data).encode('utf-8')
|
||||
else:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": result.error.code,
|
||||
"message": result.error.message
|
||||
}
|
||||
}).encode('utf-8')
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting WiFi status: %s", e)
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": {"code": "internal_error", "message": str(e)}
|
||||
}).encode('utf-8')
|
||||
|
||||
async def _handle_wifi_scan_write(self, value: bytes) -> Dict[str, Any]:
|
||||
"""Handle WiFi scan request via BLE."""
|
||||
try:
|
||||
data = json.loads(value.decode('utf-8')) if value else {}
|
||||
interface = data.get("interface")
|
||||
|
||||
# ✅ REUSE WiFiService
|
||||
result = await container.wifi_service.scan_networks(interface)
|
||||
|
||||
if result.success:
|
||||
# Send scan results via notification
|
||||
await self._notify_characteristic(
|
||||
WiFiCharacteristicUUIDs.SCAN,
|
||||
json.dumps(result.data).encode('utf-8')
|
||||
)
|
||||
return {"success": True, "count": result.data["count"]}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": result.error.code,
|
||||
"message": result.error.message
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error scanning WiFi: %s", e)
|
||||
return {
|
||||
"success": False,
|
||||
"error": {"code": "internal_error", "message": str(e)}
|
||||
}
|
||||
|
||||
async def _handle_wifi_connect_write(self, value: bytes) -> Dict[str, Any]:
|
||||
"""Handle WiFi connect request via BLE."""
|
||||
try:
|
||||
data = json.loads(value.decode('utf-8'))
|
||||
ssid = data.get("ssid")
|
||||
password = data.get("password")
|
||||
hidden = data.get("hidden", False)
|
||||
|
||||
if not ssid:
|
||||
return {
|
||||
"success": False,
|
||||
"error": {"code": "invalid_request", "message": "SSID required"}
|
||||
}
|
||||
|
||||
# ✅ REUSE WiFiService
|
||||
result = await container.wifi_service.connect(
|
||||
ssid=ssid,
|
||||
password=password,
|
||||
hidden=hidden
|
||||
)
|
||||
|
||||
if result.success:
|
||||
return {
|
||||
"success": True,
|
||||
"ssid": result.data["ssid"],
|
||||
"ip": result.data.get("ip")
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": result.error.code,
|
||||
"message": result.error.message
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error connecting to WiFi: %s", e)
|
||||
return {
|
||||
"success": False,
|
||||
"error": {"code": "internal_error", "message": str(e)}
|
||||
}
|
||||
|
||||
async def _handle_wifi_mode_read(self) -> bytes:
|
||||
"""Handle WiFi mode read via BLE."""
|
||||
try:
|
||||
result = await container.wifi_service.get_status()
|
||||
if result.success:
|
||||
return json.dumps({"mode": result.data["mode"]}).encode('utf-8')
|
||||
else:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": {"code": result.error.code, "message": result.error.message}
|
||||
}).encode('utf-8')
|
||||
except Exception as e:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": {"code": "internal_error", "message": str(e)}
|
||||
}).encode('utf-8')
|
||||
|
||||
async def _handle_wifi_mode_write(self, value: bytes) -> Dict[str, Any]:
|
||||
"""Handle WiFi mode change via BLE."""
|
||||
try:
|
||||
data = json.loads(value.decode('utf-8'))
|
||||
mode = data.get("mode")
|
||||
|
||||
if not mode:
|
||||
return {
|
||||
"success": False,
|
||||
"error": {"code": "invalid_request", "message": "Mode required"}
|
||||
}
|
||||
|
||||
# ✅ REUSE WiFiService
|
||||
result = await container.wifi_service.set_mode(mode)
|
||||
|
||||
if result.success:
|
||||
return {"success": True, "mode": result.data["mode"]}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": result.error.code,
|
||||
"message": result.error.message
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error setting WiFi mode: %s", e)
|
||||
return {
|
||||
"success": False,
|
||||
"error": {"code": "internal_error", "message": str(e)}
|
||||
}
|
||||
|
||||
# ========================================================================
|
||||
# System Characteristic Handlers
|
||||
# ========================================================================
|
||||
|
||||
def _register_system_characteristics(self):
|
||||
"""Register System GATT characteristics."""
|
||||
self._characteristic_handlers.update({
|
||||
SystemCharacteristicUUIDs.INFO: CharacteristicHandler(
|
||||
uuid=SystemCharacteristicUUIDs.INFO,
|
||||
properties=["read"],
|
||||
read_handler=self._handle_system_info_read,
|
||||
description="Get system information"
|
||||
),
|
||||
SystemCharacteristicUUIDs.SHUTDOWN: CharacteristicHandler(
|
||||
uuid=SystemCharacteristicUUIDs.SHUTDOWN,
|
||||
properties=["write"],
|
||||
write_handler=self._handle_system_shutdown_write,
|
||||
description="Initiate system shutdown"
|
||||
),
|
||||
SystemCharacteristicUUIDs.RESTART: CharacteristicHandler(
|
||||
uuid=SystemCharacteristicUUIDs.RESTART,
|
||||
properties=["write"],
|
||||
write_handler=self._handle_system_restart_write,
|
||||
description="Initiate system restart"
|
||||
),
|
||||
})
|
||||
|
||||
async def _handle_system_info_read(self) -> bytes:
|
||||
"""Handle system info read via BLE."""
|
||||
try:
|
||||
# ✅ REUSE SystemService
|
||||
result = await container.system_service.get_info()
|
||||
|
||||
if result.success:
|
||||
return json.dumps(result.data).encode('utf-8')
|
||||
else:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": result.error.code,
|
||||
"message": result.error.message
|
||||
}
|
||||
}).encode('utf-8')
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting system info: %s", e)
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": {"code": "internal_error", "message": str(e)}
|
||||
}).encode('utf-8')
|
||||
|
||||
async def _handle_system_shutdown_write(self, value: bytes) -> Dict[str, Any]:
|
||||
"""Handle system shutdown request via BLE."""
|
||||
try:
|
||||
data = json.loads(value.decode('utf-8')) if value else {}
|
||||
delay = data.get("delay", 0)
|
||||
|
||||
# ✅ REUSE SystemService
|
||||
result = await container.system_service.shutdown(delay=delay)
|
||||
|
||||
if result.success:
|
||||
return {"success": True, "message": result.data["message"]}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": result.error.code,
|
||||
"message": result.error.message
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error initiating shutdown: %s", e)
|
||||
return {
|
||||
"success": False,
|
||||
"error": {"code": "internal_error", "message": str(e)}
|
||||
}
|
||||
|
||||
async def _handle_system_restart_write(self, value: bytes) -> Dict[str, Any]:
|
||||
"""Handle system restart request via BLE."""
|
||||
try:
|
||||
data = json.loads(value.decode('utf-8')) if value else {}
|
||||
delay = data.get("delay", 0)
|
||||
|
||||
# ✅ REUSE SystemService
|
||||
result = await container.system_service.restart(delay=delay)
|
||||
|
||||
if result.success:
|
||||
return {"success": True, "message": result.data["message"]}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": result.error.code,
|
||||
"message": result.error.message
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error initiating restart: %s", e)
|
||||
return {
|
||||
"success": False,
|
||||
"error": {"code": "internal_error", "message": str(e)}
|
||||
}
|
||||
|
||||
# ========================================================================
|
||||
# Update Characteristic Handlers
|
||||
# ========================================================================
|
||||
|
||||
def _register_update_characteristics(self):
|
||||
"""Register Update GATT characteristics."""
|
||||
self._characteristic_handlers.update({
|
||||
UpdateCharacteristicUUIDs.CHECK: CharacteristicHandler(
|
||||
uuid=UpdateCharacteristicUUIDs.CHECK,
|
||||
properties=["write", "notify"],
|
||||
write_handler=self._handle_update_check_write,
|
||||
description="Check for updates"
|
||||
),
|
||||
UpdateCharacteristicUUIDs.PROGRESS: CharacteristicHandler(
|
||||
uuid=UpdateCharacteristicUUIDs.PROGRESS,
|
||||
properties=["read", "notify"],
|
||||
read_handler=self._handle_update_progress_read,
|
||||
description="Get update progress"
|
||||
),
|
||||
})
|
||||
|
||||
async def _handle_update_check_write(self, value: bytes) -> Dict[str, Any]:
|
||||
"""Handle update check request via BLE."""
|
||||
try:
|
||||
# ✅ REUSE UpdateService
|
||||
result = await container.update_service.check_for_updates()
|
||||
|
||||
if result.success:
|
||||
# Send result via notification
|
||||
await self._notify_characteristic(
|
||||
UpdateCharacteristicUUIDs.CHECK,
|
||||
json.dumps(result.data).encode('utf-8')
|
||||
)
|
||||
return result.data
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": result.error.code,
|
||||
"message": result.error.message
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error checking for updates: %s", e)
|
||||
return {
|
||||
"success": False,
|
||||
"error": {"code": "internal_error", "message": str(e)}
|
||||
}
|
||||
|
||||
async def _handle_update_progress_read(self) -> bytes:
|
||||
"""Handle update progress read via BLE."""
|
||||
try:
|
||||
# ✅ REUSE UpdateService
|
||||
result = await container.update_service.get_progress()
|
||||
|
||||
if result.success:
|
||||
return json.dumps(result.data).encode('utf-8')
|
||||
else:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": result.error.code,
|
||||
"message": result.error.message
|
||||
}
|
||||
}).encode('utf-8')
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting update progress: %s", e)
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": {"code": "internal_error", "message": str(e)}
|
||||
}).encode('utf-8')
|
||||
|
||||
# ========================================================================
|
||||
# GATT Server Management
|
||||
# ========================================================================
|
||||
|
||||
async def start(self):
|
||||
"""Start the GATT server."""
|
||||
logger.info("Starting Dangerous Pi GATT server")
|
||||
self.is_running = True
|
||||
logger.info("GATT server started with %d characteristics",
|
||||
len(self._characteristic_handlers))
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the GATT server."""
|
||||
logger.info("Stopping Dangerous Pi GATT server")
|
||||
self.is_running = False
|
||||
logger.info("GATT server stopped")
|
||||
|
||||
def get_characteristic_handler(self, uuid: str) -> Optional[CharacteristicHandler]:
|
||||
"""Get handler for a characteristic UUID.
|
||||
|
||||
Args:
|
||||
uuid: Characteristic UUID
|
||||
|
||||
Returns:
|
||||
CharacteristicHandler or None if not found
|
||||
"""
|
||||
return self._characteristic_handlers.get(uuid)
|
||||
|
||||
async def _notify_characteristic(self, uuid: str, value: bytes):
|
||||
"""Send notification for a characteristic.
|
||||
|
||||
Args:
|
||||
uuid: Characteristic UUID
|
||||
value: Notification value (bytes)
|
||||
"""
|
||||
if uuid in self._notification_callbacks:
|
||||
try:
|
||||
await self._notification_callbacks[uuid](value)
|
||||
logger.debug("Sent notification for %s", uuid)
|
||||
except Exception as e:
|
||||
logger.error("Error sending notification for %s: %s", uuid, e)
|
||||
else:
|
||||
logger.debug("No notification callback registered for %s", uuid)
|
||||
|
||||
def register_notification_callback(self, uuid: str, callback: Callable):
|
||||
"""Register callback for sending notifications.
|
||||
|
||||
Args:
|
||||
uuid: Characteristic UUID
|
||||
callback: Async callable that sends the notification
|
||||
"""
|
||||
self._notification_callbacks[uuid] = callback
|
||||
logger.debug("Registered notification callback for %s", uuid)
|
||||
|
||||
def get_all_characteristics(self) -> Dict[str, CharacteristicHandler]:
|
||||
"""Get all registered characteristic handlers.
|
||||
|
||||
Returns:
|
||||
Dict mapping UUID to CharacteristicHandler
|
||||
"""
|
||||
return self._characteristic_handlers.copy()
|
||||
58
stageDangerousPi/03-dangerous-pi/files/app/backend/config.py
Normal file
58
stageDangerousPi/03-dangerous-pi/files/app/backend/config.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Configuration settings for Dangerous Pi backend."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Base paths
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
DATA_DIR = BASE_DIR / "data"
|
||||
LOGS_DIR = BASE_DIR / "logs"
|
||||
|
||||
# Database
|
||||
DATABASE_PATH = DATA_DIR / "dangerous_pi.db"
|
||||
|
||||
# Proxmark3 settings
|
||||
PM3_DEVICE = os.getenv("PM3_DEVICE", "/dev/ttyACM0")
|
||||
PM3_TIMEOUT = int(os.getenv("PM3_TIMEOUT", "30"))
|
||||
|
||||
# Session settings
|
||||
SESSION_TIMEOUT = int(os.getenv("SESSION_TIMEOUT", "300")) # 5 minutes
|
||||
MAX_SESSIONS = 1
|
||||
|
||||
# Server settings
|
||||
HOST = os.getenv("HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("PORT", "8000"))
|
||||
VERSION = os.getenv("VERSION", "0.1.0")
|
||||
|
||||
# Update settings
|
||||
GITHUB_REPO = os.getenv("GITHUB_REPO", "yourusername/dangerous-pi") # TODO: Update this
|
||||
UPDATE_CHECK_INTERVAL = int(os.getenv("UPDATE_CHECK_INTERVAL", "3600")) # 1 hour
|
||||
|
||||
# Wi-Fi settings
|
||||
WLAN_INTERFACE = os.getenv("WLAN_INTERFACE", "wlan0")
|
||||
USB_WLAN_INTERFACE = os.getenv("USB_WLAN_INTERFACE", "wlan1")
|
||||
|
||||
# UPS settings
|
||||
UPS_TYPE = os.getenv("UPS_TYPE", "auto") # Options: "auto", "pisugar", "i2c", "none"
|
||||
UPS_CHECK_INTERVAL = int(os.getenv("UPS_CHECK_INTERVAL", "60")) # 1 minute
|
||||
|
||||
# I2C UPS settings (for generic fuel gauge HATs)
|
||||
UPS_I2C_ADDRESS = os.getenv("UPS_I2C_ADDRESS", "0x36")
|
||||
|
||||
# PiSugar UPS settings
|
||||
UPS_PISUGAR_HOST = os.getenv("UPS_PISUGAR_HOST", "127.0.0.1")
|
||||
UPS_PISUGAR_PORT = int(os.getenv("UPS_PISUGAR_PORT", "8423"))
|
||||
|
||||
# BLE settings
|
||||
BLE_ENABLED = os.getenv("BLE_ENABLED", "true").lower() == "true"
|
||||
BLE_DEVICE_NAME = os.getenv("BLE_DEVICE_NAME", "DangerousPi")
|
||||
|
||||
# Security
|
||||
AUTH_ENABLED = os.getenv("AUTH_ENABLED", "false").lower() == "true"
|
||||
AUTH_USERNAME = os.getenv("AUTH_USERNAME", "admin")
|
||||
AUTH_PASSWORD = os.getenv("AUTH_PASSWORD", "") # Must be set when AUTH_ENABLED=true
|
||||
HTTPS_ENABLED = os.getenv("HTTPS_ENABLED", "false").lower() == "true"
|
||||
|
||||
# CPU cores settings
|
||||
# Set to 0 or "auto" to use model-specific defaults
|
||||
# Pi Zero 2 W defaults to 2 cores (out of 4) for thermal/power management
|
||||
CPU_CORES = os.getenv("CPU_CORES", "auto")
|
||||
252
stageDangerousPi/03-dangerous-pi/files/app/backend/main.py
Normal file
252
stageDangerousPi/03-dangerous-pi/files/app/backend/main.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""Main FastAPI application for Dangerous Pi."""
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Depends
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import config
|
||||
from .models.database import init_db
|
||||
from .api import health, pm3, system, wifi, updates, plugins
|
||||
from .api.auth import verify_credentials
|
||||
from .sse import events
|
||||
from .managers.update_manager import get_update_manager
|
||||
from .managers.ups_manager import get_ups_manager
|
||||
from .managers.ble_manager import get_ble_manager, NotificationType
|
||||
from .managers.plugin_manager import get_plugin_manager
|
||||
from .services.container import container
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan manager."""
|
||||
# Startup
|
||||
print(f"🚀 Starting Dangerous Pi backend...")
|
||||
await init_db()
|
||||
print(f"✅ Database initialized")
|
||||
|
||||
# Start periodic update checks
|
||||
update_manager = get_update_manager()
|
||||
update_task = asyncio.create_task(update_manager.start_periodic_checks())
|
||||
print(f"✅ Update manager started")
|
||||
|
||||
# Initialize and start BLE manager
|
||||
ble_manager = get_ble_manager()
|
||||
await ble_manager.initialize()
|
||||
if ble_manager.is_available():
|
||||
await ble_manager.start_advertising()
|
||||
print(f"✅ BLE manager started")
|
||||
else:
|
||||
print(f"⚠️ BLE not available")
|
||||
|
||||
# Start UPS monitoring
|
||||
ups_manager = get_ups_manager()
|
||||
|
||||
# Register UPS event callbacks for SSE notifications and BLE
|
||||
async def ups_event_handler(event):
|
||||
"""Handle UPS events and broadcast via SSE and BLE."""
|
||||
event_type = event.get("type")
|
||||
data = event.get("data", {})
|
||||
|
||||
if event_type == "battery_warning":
|
||||
await events.notify_ups_warning(data["percentage"], data["threshold"])
|
||||
await ble_manager.send_notification(
|
||||
NotificationType.BATTERY_WARNING,
|
||||
f"Battery low: {data['percentage']:.1f}%",
|
||||
data
|
||||
)
|
||||
elif event_type == "battery_low":
|
||||
await events.notify_ups_critical(data["percentage"])
|
||||
await ble_manager.send_notification(
|
||||
NotificationType.BATTERY_CRITICAL,
|
||||
f"Battery critical: {data['percentage']:.1f}%",
|
||||
data
|
||||
)
|
||||
elif event_type == "battery_critical":
|
||||
await events.notify_ups_shutdown(data.get("delay", 60), data["percentage"])
|
||||
await ble_manager.send_notification(
|
||||
NotificationType.SHUTDOWN_INITIATED,
|
||||
f"Shutdown initiated: {data['percentage']:.1f}% battery",
|
||||
data
|
||||
)
|
||||
elif event_type == "ups_config_required":
|
||||
await events.notify_ups_config_required(
|
||||
data.get("model", "Unknown"),
|
||||
data.get("message", "UPS configuration required"),
|
||||
data.get("hint", "")
|
||||
)
|
||||
await ble_manager.send_notification(
|
||||
NotificationType.CONFIG_REQUIRED,
|
||||
data.get("message", "UPS configuration required"),
|
||||
data
|
||||
)
|
||||
|
||||
ups_manager.register_event_callback(ups_event_handler)
|
||||
ups_task = asyncio.create_task(ups_manager.start_monitoring())
|
||||
print(f"✅ UPS manager started")
|
||||
|
||||
# Discover and load plugins
|
||||
plugin_manager = get_plugin_manager()
|
||||
discovered = await plugin_manager.discover_plugins()
|
||||
print(f"✅ Plugin manager started ({len(discovered)} plugins discovered)")
|
||||
|
||||
# Start PM3 device manager for multi-device support
|
||||
pm3_device_manager = container.pm3_device_manager
|
||||
|
||||
# Register PM3 device change callback
|
||||
async def pm3_device_change_handler(device_list):
|
||||
"""Handle PM3 device list changes and broadcast via SSE."""
|
||||
devices_data = [
|
||||
{
|
||||
"device_id": d.device_id,
|
||||
"device_path": d.device_path,
|
||||
"status": d.status.value if hasattr(d.status, 'value') else d.status,
|
||||
"friendly_name": d.friendly_name,
|
||||
}
|
||||
for d in device_list
|
||||
]
|
||||
await events.notify_pm3_devices(devices_data)
|
||||
|
||||
pm3_device_manager.register_device_change_callback(pm3_device_change_handler)
|
||||
await pm3_device_manager.start()
|
||||
print(f"✅ PM3 device manager started")
|
||||
|
||||
# Start periodic system stats broadcasting (every 5 seconds)
|
||||
async def broadcast_system_stats():
|
||||
"""Periodically broadcast system stats via SSE."""
|
||||
while True:
|
||||
try:
|
||||
result = await container.system_service.get_info()
|
||||
if result.success:
|
||||
cpu_data = result.data["cpu"]
|
||||
memory_data = result.data["memory"]
|
||||
await events.notify_system_stats(
|
||||
cpu_percent=cpu_data.get("percent", 0),
|
||||
cpu_per_core=cpu_data.get("per_core", []),
|
||||
load_average=cpu_data.get("load_average", []),
|
||||
memory_percent=memory_data.get("percent", 0),
|
||||
temperature=cpu_data.get("temperature")
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error broadcasting system stats: {e}")
|
||||
await asyncio.sleep(5) # Update every 5 seconds
|
||||
|
||||
system_stats_task = asyncio.create_task(broadcast_system_stats())
|
||||
print(f"✅ System stats broadcaster started")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
print(f"🛑 Shutting down Dangerous Pi backend...")
|
||||
|
||||
# Stop PM3 device manager
|
||||
await pm3_device_manager.stop()
|
||||
print(f"✅ PM3 device manager stopped")
|
||||
|
||||
update_task.cancel()
|
||||
ups_task.cancel()
|
||||
system_stats_task.cancel()
|
||||
try:
|
||||
await update_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
try:
|
||||
await ups_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
try:
|
||||
await system_stats_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Close UPS manager I2C connection
|
||||
ups_manager.close()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Dangerous Pi API",
|
||||
description="Backend API for Proxmark3 management on Raspberry Pi Zero 2 W",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# CORS middleware for frontend
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # TODO: Restrict in production
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Auth dependency for protected routes (applied when AUTH_ENABLED=true)
|
||||
auth_dependency = [Depends(verify_credentials)] if config.AUTH_ENABLED else []
|
||||
|
||||
# Include routers - all protected when AUTH_ENABLED=true
|
||||
app.include_router(health.router, prefix="/api", tags=["health"], dependencies=auth_dependency)
|
||||
app.include_router(pm3.router, prefix="/api/pm3", tags=["proxmark3"], dependencies=auth_dependency)
|
||||
app.include_router(system.router, prefix="/api/system", tags=["system"], dependencies=auth_dependency)
|
||||
app.include_router(wifi.router, prefix="/api/wifi", tags=["wifi"], dependencies=auth_dependency)
|
||||
app.include_router(updates.router, prefix="/api/updates", tags=["updates"], dependencies=auth_dependency)
|
||||
app.include_router(plugins.router, prefix="/api/plugins", tags=["plugins"], dependencies=auth_dependency)
|
||||
app.include_router(events.router, prefix="/sse", tags=["events"], dependencies=auth_dependency)
|
||||
|
||||
# Serve frontend static files if build directory exists
|
||||
# In production, frontend is built and served from /opt/dangerous-pi/app/frontend/build
|
||||
# Priority: 1) check relative to working directory, 2) check absolute production path
|
||||
frontend_build_paths = [
|
||||
Path(__file__).parent.parent / "frontend" / "build" / "client", # Development
|
||||
Path("/opt/dangerous-pi/app/frontend/build/client"), # Production
|
||||
]
|
||||
|
||||
frontend_path = None
|
||||
for path in frontend_build_paths:
|
||||
if path.exists() and path.is_dir():
|
||||
frontend_path = path
|
||||
break
|
||||
|
||||
if frontend_path:
|
||||
# Mount static assets (JS, CSS, images)
|
||||
assets_path = frontend_path / "assets"
|
||||
if assets_path.exists():
|
||||
app.mount("/assets", StaticFiles(directory=str(assets_path)), name="assets")
|
||||
|
||||
# Serve index.html for all non-API routes (SPA support)
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_frontend(full_path: str):
|
||||
"""Serve frontend files, fall back to index.html for SPA routing."""
|
||||
# Check for exact file match first
|
||||
file_path = frontend_path / full_path
|
||||
if file_path.exists() and file_path.is_file():
|
||||
return FileResponse(str(file_path))
|
||||
# Fall back to index.html for SPA routing
|
||||
index_path = frontend_path / "index.html"
|
||||
if index_path.exists():
|
||||
return FileResponse(str(index_path))
|
||||
return JSONResponse(status_code=404, content={"error": "Not found"})
|
||||
|
||||
print(f"📁 Serving frontend from: {frontend_path}")
|
||||
else:
|
||||
print(f"⚠️ Frontend build not found, API-only mode")
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request, exc):
|
||||
"""Global exception handler."""
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": str(exc)}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"app.backend.main:app",
|
||||
host=config.HOST,
|
||||
port=config.PORT,
|
||||
reload=True
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Business logic managers."""
|
||||
@@ -0,0 +1,336 @@
|
||||
"""BLE Manager for Dangerous Pi.
|
||||
|
||||
Handles Bluetooth Low Energy functionality including:
|
||||
- GATT server with full PM3/WiFi/System/Update services
|
||||
- Notifications for updates, backups, and battery alerts
|
||||
- Uses the Pi Zero 2 W built-in Bluetooth via bless library
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from .. import config
|
||||
|
||||
# Try to import bless for GATT server
|
||||
try:
|
||||
from ..ble.bluez_adapter import BlueZGATTAdapter, get_ble_adapter
|
||||
BLESS_AVAILABLE = True
|
||||
except ImportError:
|
||||
BLESS_AVAILABLE = False
|
||||
|
||||
# Fallback to dbus for basic operations
|
||||
try:
|
||||
import dbus
|
||||
import dbus.mainloop.glib
|
||||
from gi.repository import GLib
|
||||
DBUS_AVAILABLE = True
|
||||
except ImportError:
|
||||
DBUS_AVAILABLE = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationType(str, Enum):
|
||||
"""BLE notification type enum."""
|
||||
UPDATE_AVAILABLE = "update_available"
|
||||
UPDATE_COMPLETE = "update_complete"
|
||||
BACKUP_COMPLETE = "backup_complete"
|
||||
BATTERY_WARNING = "battery_warning"
|
||||
BATTERY_CRITICAL = "battery_critical"
|
||||
SHUTDOWN_INITIATED = "shutdown_initiated"
|
||||
PM3_STATUS = "pm3_status"
|
||||
CONFIG_REQUIRED = "config_required"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BLENotification:
|
||||
"""BLE notification data."""
|
||||
type: NotificationType
|
||||
message: str
|
||||
data: Dict[str, Any]
|
||||
timestamp: str
|
||||
|
||||
|
||||
class BLEManager:
|
||||
"""Manages BLE functionality via Pi Zero 2 W Bluetooth.
|
||||
|
||||
Provides both:
|
||||
- Full GATT server with PM3/WiFi/System/Update services (via bless)
|
||||
- Simple notifications for system events
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the BLE manager."""
|
||||
self._enabled = config.BLE_ENABLED
|
||||
self._device_name = config.BLE_DEVICE_NAME
|
||||
self._is_available = False
|
||||
self._is_advertising = False
|
||||
self._gatt_server_running = False
|
||||
self._connected_devices: List[str] = []
|
||||
self._notification_queue: asyncio.Queue = asyncio.Queue()
|
||||
self._service_uuid = "12345678-1234-5678-1234-56789abcdef0" # Custom service UUID
|
||||
self._char_uuid = "12345678-1234-5678-1234-56789abcdef1" # Notification characteristic
|
||||
self._bus = None
|
||||
self._adapter = None
|
||||
self._gatt_adapter: Optional[BlueZGATTAdapter] = None
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""Initialize BLE adapter and check availability.
|
||||
|
||||
Returns:
|
||||
True if initialization successful, False otherwise
|
||||
"""
|
||||
if not self._enabled:
|
||||
logger.info("BLE is disabled in configuration")
|
||||
return False
|
||||
|
||||
# Check if Bluetooth adapter is available first
|
||||
has_adapter = await self._check_bluetooth_adapter()
|
||||
if not has_adapter:
|
||||
logger.warning("No Bluetooth adapter found")
|
||||
return False
|
||||
|
||||
self._is_available = True
|
||||
|
||||
# Try to initialize GATT server with bless (preferred)
|
||||
if BLESS_AVAILABLE:
|
||||
try:
|
||||
self._gatt_adapter = get_ble_adapter()
|
||||
self._gatt_adapter.device_name = self._device_name
|
||||
logger.info("BLE GATT adapter initialized (bless): %s", self._device_name)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to initialize bless GATT adapter: %s", e)
|
||||
self._gatt_adapter = None
|
||||
else:
|
||||
logger.info("bless library not available, using basic BLE mode")
|
||||
|
||||
logger.info("BLE initialized: %s", self._device_name)
|
||||
return True
|
||||
|
||||
async def start_advertising(self):
|
||||
"""Start BLE advertising and GATT server."""
|
||||
if not self._is_available:
|
||||
logger.warning("BLE not available, cannot start advertising")
|
||||
return
|
||||
|
||||
try:
|
||||
# Start full GATT server if available (preferred)
|
||||
if self._gatt_adapter:
|
||||
success = await self._gatt_adapter.start()
|
||||
if success:
|
||||
self._gatt_server_running = True
|
||||
self._is_advertising = True
|
||||
logger.info("BLE GATT server started: %s", self._device_name)
|
||||
return
|
||||
else:
|
||||
logger.warning("Failed to start GATT server, falling back to basic mode")
|
||||
|
||||
# Fallback to basic advertising via bluetoothctl
|
||||
await self._set_device_name(self._device_name)
|
||||
await self._set_discoverable(True)
|
||||
|
||||
self._is_advertising = True
|
||||
logger.info("BLE advertising started (basic mode): %s", self._device_name)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to start BLE advertising: %s", e)
|
||||
self._is_advertising = False
|
||||
|
||||
async def stop_advertising(self):
|
||||
"""Stop BLE advertising and GATT server."""
|
||||
if not self._is_advertising:
|
||||
return
|
||||
|
||||
try:
|
||||
# Stop GATT server if running
|
||||
if self._gatt_adapter and self._gatt_server_running:
|
||||
await self._gatt_adapter.stop()
|
||||
self._gatt_server_running = False
|
||||
logger.info("BLE GATT server stopped")
|
||||
else:
|
||||
# Stop basic advertising
|
||||
await self._set_discoverable(False)
|
||||
|
||||
self._is_advertising = False
|
||||
logger.info("BLE advertising stopped")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to stop BLE advertising: %s", e)
|
||||
|
||||
async def send_notification(
|
||||
self,
|
||||
notification_type: NotificationType,
|
||||
message: str,
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""Send a BLE notification to connected devices.
|
||||
|
||||
Args:
|
||||
notification_type: Type of notification
|
||||
message: Human-readable message
|
||||
data: Optional additional data
|
||||
"""
|
||||
if not self._is_available:
|
||||
return
|
||||
|
||||
notification = BLENotification(
|
||||
type=notification_type,
|
||||
message=message,
|
||||
data=data or {},
|
||||
timestamp=datetime.utcnow().isoformat()
|
||||
)
|
||||
|
||||
await self._notification_queue.put(notification)
|
||||
|
||||
# Process notification - always broadcast if GATT server is running
|
||||
if self._connected_devices or self._gatt_server_running:
|
||||
await self._broadcast_notification(notification)
|
||||
else:
|
||||
logger.debug("BLE notification queued (no devices connected): %s", message)
|
||||
|
||||
async def get_status(self) -> Dict[str, Any]:
|
||||
"""Get BLE manager status.
|
||||
|
||||
Returns:
|
||||
Dictionary with BLE status information
|
||||
"""
|
||||
return {
|
||||
"enabled": self._enabled,
|
||||
"available": self._is_available,
|
||||
"advertising": self._is_advertising,
|
||||
"gatt_server_running": self._gatt_server_running,
|
||||
"gatt_available": BLESS_AVAILABLE,
|
||||
"connected_devices": len(self._connected_devices),
|
||||
"device_name": self._device_name,
|
||||
"queued_notifications": self._notification_queue.qsize()
|
||||
}
|
||||
|
||||
async def _check_bluetooth_adapter(self) -> bool:
|
||||
"""Check if Bluetooth adapter is available.
|
||||
|
||||
Returns:
|
||||
True if adapter is available, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Use bluetoothctl to check for adapter
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"bluetoothctl", "list",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
# If we get output with "Controller", we have an adapter
|
||||
return b"Controller" in stdout
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.warning("bluetoothctl not found - BlueZ not installed")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Error checking Bluetooth adapter: %s", e)
|
||||
return False
|
||||
|
||||
async def _set_device_name(self, name: str):
|
||||
"""Set the Bluetooth device name.
|
||||
|
||||
Args:
|
||||
name: Device name to set
|
||||
"""
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"bluetoothctl", "system-alias", name,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
await process.communicate()
|
||||
except Exception as e:
|
||||
logger.error("Failed to set device name: %s", e)
|
||||
|
||||
async def _set_discoverable(self, enabled: bool):
|
||||
"""Set Bluetooth discoverable state.
|
||||
|
||||
Args:
|
||||
enabled: True to enable discoverable, False to disable
|
||||
"""
|
||||
try:
|
||||
command = "on" if enabled else "off"
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"bluetoothctl", "discoverable", command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
await process.communicate()
|
||||
except Exception as e:
|
||||
logger.error("Failed to set discoverable: %s", e)
|
||||
|
||||
async def _broadcast_notification(self, notification: BLENotification):
|
||||
"""Broadcast notification to all connected devices.
|
||||
|
||||
Args:
|
||||
notification: Notification to broadcast
|
||||
"""
|
||||
# Convert notification to JSON
|
||||
payload = {
|
||||
"type": notification.type.value,
|
||||
"message": notification.message,
|
||||
"data": notification.data,
|
||||
"timestamp": notification.timestamp
|
||||
}
|
||||
payload_bytes = json.dumps(payload).encode('utf-8')
|
||||
|
||||
# Use GATT server if available
|
||||
if self._gatt_adapter and self._gatt_server_running:
|
||||
try:
|
||||
# Send via system notification characteristic
|
||||
from ..ble.characteristics import SystemCharacteristicUUIDs
|
||||
await self._gatt_adapter.send_notification(
|
||||
SystemCharacteristicUUIDs.INFO,
|
||||
payload_bytes
|
||||
)
|
||||
logger.debug("BLE notification sent via GATT: %s", notification.type.value)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning("Failed to send GATT notification: %s", e)
|
||||
|
||||
# Fallback: log the notification
|
||||
logger.info("BLE Notification: %s - %s", notification.type.value, notification.message)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if BLE is available.
|
||||
|
||||
Returns:
|
||||
True if BLE is available, False otherwise
|
||||
"""
|
||||
return self._is_available
|
||||
|
||||
def is_advertising(self) -> bool:
|
||||
"""Check if BLE is currently advertising.
|
||||
|
||||
Returns:
|
||||
True if advertising, False otherwise
|
||||
"""
|
||||
return self._is_advertising
|
||||
|
||||
def get_connected_devices(self) -> List[str]:
|
||||
"""Get list of connected device addresses.
|
||||
|
||||
Returns:
|
||||
List of connected device MAC addresses
|
||||
"""
|
||||
return self._connected_devices.copy()
|
||||
|
||||
|
||||
# Global BLE manager instance
|
||||
_ble_manager: Optional[BLEManager] = None
|
||||
|
||||
|
||||
def get_ble_manager() -> BLEManager:
|
||||
"""Get the global BLE manager instance."""
|
||||
global _ble_manager
|
||||
if _ble_manager is None:
|
||||
_ble_manager = BLEManager()
|
||||
return _ble_manager
|
||||
@@ -0,0 +1,419 @@
|
||||
"""Plugin Manager for Dangerous Pi.
|
||||
|
||||
Provides a plugin framework for extending functionality.
|
||||
Supports dynamic loading, enabling/disabling, and lifecycle management.
|
||||
"""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import inspect
|
||||
import json
|
||||
from dataclasses import dataclass, asdict
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List, Callable
|
||||
import sys
|
||||
|
||||
from .. import config
|
||||
|
||||
|
||||
class PluginStatus(str, Enum):
|
||||
"""Plugin status enum."""
|
||||
LOADED = "loaded"
|
||||
ENABLED = "enabled"
|
||||
DISABLED = "disabled"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginMetadata:
|
||||
"""Plugin metadata information."""
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
description: str
|
||||
author: str
|
||||
homepage: Optional[str] = None
|
||||
dependencies: List[str] = None
|
||||
permissions: List[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.dependencies is None:
|
||||
self.dependencies = []
|
||||
if self.permissions is None:
|
||||
self.permissions = []
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginInfo:
|
||||
"""Plugin runtime information."""
|
||||
metadata: PluginMetadata
|
||||
status: PluginStatus
|
||||
path: Path
|
||||
error_message: Optional[str] = None
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
class PluginBase:
|
||||
"""Base class for all plugins.
|
||||
|
||||
Plugins should inherit from this class and implement the required methods.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the plugin."""
|
||||
self.metadata: Optional[PluginMetadata] = None
|
||||
self.hooks: Dict[str, List[Callable]] = {}
|
||||
|
||||
async def on_load(self):
|
||||
"""Called when the plugin is loaded.
|
||||
|
||||
Override this to perform initialization tasks.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def on_enable(self):
|
||||
"""Called when the plugin is enabled.
|
||||
|
||||
Override this to start plugin functionality.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def on_disable(self):
|
||||
"""Called when the plugin is disabled.
|
||||
|
||||
Override this to stop plugin functionality.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def on_unload(self):
|
||||
"""Called when the plugin is unloaded.
|
||||
|
||||
Override this to perform cleanup tasks.
|
||||
"""
|
||||
pass
|
||||
|
||||
def register_hook(self, hook_name: str, callback: Callable):
|
||||
"""Register a hook callback.
|
||||
|
||||
Args:
|
||||
hook_name: Name of the hook (e.g., "pm3_command", "update_check")
|
||||
callback: Async callback function
|
||||
"""
|
||||
if hook_name not in self.hooks:
|
||||
self.hooks[hook_name] = []
|
||||
self.hooks[hook_name].append(callback)
|
||||
|
||||
def get_metadata(self) -> PluginMetadata:
|
||||
"""Get plugin metadata.
|
||||
|
||||
Returns:
|
||||
PluginMetadata object
|
||||
|
||||
Raises:
|
||||
NotImplementedError if not implemented by plugin
|
||||
"""
|
||||
raise NotImplementedError("Plugin must implement get_metadata()")
|
||||
|
||||
|
||||
class PluginManager:
|
||||
"""Manages plugin loading, enabling, and lifecycle."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the plugin manager."""
|
||||
self._plugins: Dict[str, PluginInfo] = {}
|
||||
self._plugin_instances: Dict[str, PluginBase] = {}
|
||||
self._plugin_dir = config.BASE_DIR / "app" / "plugins"
|
||||
self._hooks: Dict[str, List[Callable]] = {}
|
||||
self._enabled_plugins: List[str] = []
|
||||
|
||||
# Create plugins directory if it doesn't exist
|
||||
self._plugin_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async def discover_plugins(self) -> List[str]:
|
||||
"""Discover all available plugins in the plugins directory.
|
||||
|
||||
Returns:
|
||||
List of discovered plugin IDs
|
||||
"""
|
||||
discovered = []
|
||||
|
||||
# Look for plugin directories
|
||||
for plugin_path in self._plugin_dir.iterdir():
|
||||
if not plugin_path.is_dir() or plugin_path.name.startswith("_"):
|
||||
continue
|
||||
|
||||
# Check for plugin.json metadata file
|
||||
metadata_file = plugin_path / "plugin.json"
|
||||
if not metadata_file.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
# Load metadata
|
||||
with open(metadata_file, 'r') as f:
|
||||
metadata_dict = json.load(f)
|
||||
metadata = PluginMetadata(**metadata_dict)
|
||||
|
||||
# Check for main.py
|
||||
main_file = plugin_path / "main.py"
|
||||
if not main_file.exists():
|
||||
print(f"Plugin {metadata.id} missing main.py")
|
||||
continue
|
||||
|
||||
# Create plugin info
|
||||
plugin_info = PluginInfo(
|
||||
metadata=metadata,
|
||||
status=PluginStatus.LOADED,
|
||||
path=plugin_path,
|
||||
enabled=False
|
||||
)
|
||||
|
||||
self._plugins[metadata.id] = plugin_info
|
||||
discovered.append(metadata.id)
|
||||
print(f"Discovered plugin: {metadata.name} v{metadata.version}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error discovering plugin in {plugin_path}: {e}")
|
||||
continue
|
||||
|
||||
return discovered
|
||||
|
||||
async def load_plugin(self, plugin_id: str) -> bool:
|
||||
"""Load a plugin into memory.
|
||||
|
||||
Args:
|
||||
plugin_id: ID of the plugin to load
|
||||
|
||||
Returns:
|
||||
True if loaded successfully, False otherwise
|
||||
"""
|
||||
if plugin_id not in self._plugins:
|
||||
print(f"Plugin {plugin_id} not found")
|
||||
return False
|
||||
|
||||
plugin_info = self._plugins[plugin_id]
|
||||
|
||||
try:
|
||||
# Import the plugin module
|
||||
main_file = plugin_info.path / "main.py"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"plugins.{plugin_id}",
|
||||
main_file
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[f"plugins.{plugin_id}"] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# Find the plugin class (should inherit from PluginBase)
|
||||
plugin_class = None
|
||||
for name, obj in inspect.getmembers(module, inspect.isclass):
|
||||
if issubclass(obj, PluginBase) and obj is not PluginBase:
|
||||
plugin_class = obj
|
||||
break
|
||||
|
||||
if not plugin_class:
|
||||
raise ValueError("No plugin class found in main.py")
|
||||
|
||||
# Instantiate the plugin
|
||||
plugin_instance = plugin_class()
|
||||
plugin_instance.metadata = plugin_info.metadata
|
||||
|
||||
# Call on_load lifecycle method
|
||||
await plugin_instance.on_load()
|
||||
|
||||
# Store the instance
|
||||
self._plugin_instances[plugin_id] = plugin_instance
|
||||
plugin_info.status = PluginStatus.LOADED
|
||||
|
||||
print(f"Loaded plugin: {plugin_info.metadata.name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading plugin {plugin_id}: {e}")
|
||||
plugin_info.status = PluginStatus.ERROR
|
||||
plugin_info.error_message = str(e)
|
||||
return False
|
||||
|
||||
async def enable_plugin(self, plugin_id: str) -> bool:
|
||||
"""Enable a loaded plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: ID of the plugin to enable
|
||||
|
||||
Returns:
|
||||
True if enabled successfully, False otherwise
|
||||
"""
|
||||
if plugin_id not in self._plugin_instances:
|
||||
# Try to load it first
|
||||
if not await self.load_plugin(plugin_id):
|
||||
return False
|
||||
|
||||
plugin_instance = self._plugin_instances[plugin_id]
|
||||
plugin_info = self._plugins[plugin_id]
|
||||
|
||||
try:
|
||||
# Call on_enable lifecycle method
|
||||
await plugin_instance.on_enable()
|
||||
|
||||
# Register plugin hooks
|
||||
for hook_name, callbacks in plugin_instance.hooks.items():
|
||||
if hook_name not in self._hooks:
|
||||
self._hooks[hook_name] = []
|
||||
self._hooks[hook_name].extend(callbacks)
|
||||
|
||||
plugin_info.status = PluginStatus.ENABLED
|
||||
plugin_info.enabled = True
|
||||
|
||||
if plugin_id not in self._enabled_plugins:
|
||||
self._enabled_plugins.append(plugin_id)
|
||||
|
||||
print(f"Enabled plugin: {plugin_info.metadata.name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error enabling plugin {plugin_id}: {e}")
|
||||
plugin_info.status = PluginStatus.ERROR
|
||||
plugin_info.error_message = str(e)
|
||||
return False
|
||||
|
||||
async def disable_plugin(self, plugin_id: str) -> bool:
|
||||
"""Disable an enabled plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: ID of the plugin to disable
|
||||
|
||||
Returns:
|
||||
True if disabled successfully, False otherwise
|
||||
"""
|
||||
if plugin_id not in self._plugin_instances:
|
||||
return False
|
||||
|
||||
plugin_instance = self._plugin_instances[plugin_id]
|
||||
plugin_info = self._plugins[plugin_id]
|
||||
|
||||
try:
|
||||
# Call on_disable lifecycle method
|
||||
await plugin_instance.on_disable()
|
||||
|
||||
# Unregister plugin hooks
|
||||
for hook_name, callbacks in plugin_instance.hooks.items():
|
||||
if hook_name in self._hooks:
|
||||
for callback in callbacks:
|
||||
if callback in self._hooks[hook_name]:
|
||||
self._hooks[hook_name].remove(callback)
|
||||
|
||||
plugin_info.status = PluginStatus.DISABLED
|
||||
plugin_info.enabled = False
|
||||
|
||||
if plugin_id in self._enabled_plugins:
|
||||
self._enabled_plugins.remove(plugin_id)
|
||||
|
||||
print(f"Disabled plugin: {plugin_info.metadata.name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error disabling plugin {plugin_id}: {e}")
|
||||
plugin_info.error_message = str(e)
|
||||
return False
|
||||
|
||||
async def unload_plugin(self, plugin_id: str) -> bool:
|
||||
"""Unload a plugin from memory.
|
||||
|
||||
Args:
|
||||
plugin_id: ID of the plugin to unload
|
||||
|
||||
Returns:
|
||||
True if unloaded successfully, False otherwise
|
||||
"""
|
||||
if plugin_id not in self._plugin_instances:
|
||||
return False
|
||||
|
||||
# Disable first if enabled
|
||||
if self._plugins[plugin_id].enabled:
|
||||
await self.disable_plugin(plugin_id)
|
||||
|
||||
plugin_instance = self._plugin_instances[plugin_id]
|
||||
|
||||
try:
|
||||
# Call on_unload lifecycle method
|
||||
await plugin_instance.on_unload()
|
||||
|
||||
# Remove from instances
|
||||
del self._plugin_instances[plugin_id]
|
||||
|
||||
# Remove from sys.modules
|
||||
module_name = f"plugins.{plugin_id}"
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
|
||||
print(f"Unloaded plugin: {plugin_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error unloading plugin {plugin_id}: {e}")
|
||||
return False
|
||||
|
||||
async def trigger_hook(self, hook_name: str, *args, **kwargs) -> List[Any]:
|
||||
"""Trigger a hook and collect results from all registered callbacks.
|
||||
|
||||
Args:
|
||||
hook_name: Name of the hook to trigger
|
||||
*args: Positional arguments for hook callbacks
|
||||
**kwargs: Keyword arguments for hook callbacks
|
||||
|
||||
Returns:
|
||||
List of results from all callbacks
|
||||
"""
|
||||
if hook_name not in self._hooks:
|
||||
return []
|
||||
|
||||
results = []
|
||||
for callback in self._hooks[hook_name]:
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(callback):
|
||||
result = await callback(*args, **kwargs)
|
||||
else:
|
||||
result = callback(*args, **kwargs)
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
print(f"Error in hook {hook_name}: {e}")
|
||||
|
||||
return results
|
||||
|
||||
def get_plugin_info(self, plugin_id: str) -> Optional[PluginInfo]:
|
||||
"""Get information about a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: ID of the plugin
|
||||
|
||||
Returns:
|
||||
PluginInfo object or None if not found
|
||||
"""
|
||||
return self._plugins.get(plugin_id)
|
||||
|
||||
def get_all_plugins(self) -> Dict[str, PluginInfo]:
|
||||
"""Get information about all plugins.
|
||||
|
||||
Returns:
|
||||
Dictionary of plugin ID to PluginInfo
|
||||
"""
|
||||
return self._plugins.copy()
|
||||
|
||||
def get_enabled_plugins(self) -> List[str]:
|
||||
"""Get list of enabled plugin IDs.
|
||||
|
||||
Returns:
|
||||
List of enabled plugin IDs
|
||||
"""
|
||||
return self._enabled_plugins.copy()
|
||||
|
||||
|
||||
# Global plugin manager instance
|
||||
_plugin_manager: Optional[PluginManager] = None
|
||||
|
||||
|
||||
def get_plugin_manager() -> PluginManager:
|
||||
"""Get the global plugin manager instance."""
|
||||
global _plugin_manager
|
||||
if _plugin_manager is None:
|
||||
_plugin_manager = PluginManager()
|
||||
return _plugin_manager
|
||||
@@ -0,0 +1,532 @@
|
||||
"""Proxmark3 Device Manager for multi-device support."""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import json
|
||||
|
||||
try:
|
||||
import pyudev
|
||||
UDEV_AVAILABLE = True
|
||||
except ImportError:
|
||||
UDEV_AVAILABLE = False
|
||||
|
||||
try:
|
||||
import serial.tools.list_ports
|
||||
SERIAL_AVAILABLE = True
|
||||
except ImportError:
|
||||
SERIAL_AVAILABLE = False
|
||||
|
||||
from ..workers.pm3_worker import PM3Worker, SubprocessPM3Worker
|
||||
from .. import config
|
||||
|
||||
# Flag to track if Python bindings work (set once on first try)
|
||||
_python_bindings_work = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeviceStatus(Enum):
|
||||
"""Device availability status."""
|
||||
CONNECTED = "connected" # Ready to use
|
||||
DISCONNECTED = "disconnected" # Not detected
|
||||
IN_USE = "in_use" # Active session
|
||||
ERROR = "error" # Communication error
|
||||
VERSION_MISMATCH = "version_mismatch" # Firmware incompatible
|
||||
FLASHING = "flashing" # Firmware update in progress
|
||||
BOOTLOADER_MODE = "bootloader_mode" # In bootloader, needs flash
|
||||
DISABLED = "disabled" # Mismatch ignored by user
|
||||
|
||||
|
||||
@dataclass
|
||||
class PM3FirmwareInfo:
|
||||
"""Firmware version information."""
|
||||
bootrom_version: str = "unknown" # e.g., "v4.14831"
|
||||
os_version: str = "unknown" # e.g., "v4.14831"
|
||||
client_version: str = "unknown" # Local client version
|
||||
compatible: bool = False # Versions match
|
||||
needs_upgrade: bool = False # Device firmware older
|
||||
needs_downgrade: bool = False # Device firmware newer
|
||||
bootloader_outdated: bool = False # Bootloader needs update
|
||||
|
||||
|
||||
@dataclass
|
||||
class PM3Device:
|
||||
"""Represents a single Proxmark3 device."""
|
||||
device_id: str # Unique ID (hash of serial + device path)
|
||||
device_path: str # /dev/ttyACM0, /dev/ttyACM1, etc.
|
||||
serial_number: Optional[str] = None # USB serial number (if available)
|
||||
friendly_name: Optional[str] = None # User-assigned name
|
||||
usb_vid: Optional[str] = None # USB Vendor ID
|
||||
usb_pid: Optional[str] = None # USB Product ID
|
||||
status: DeviceStatus = DeviceStatus.DISCONNECTED
|
||||
firmware_info: PM3FirmwareInfo = field(default_factory=PM3FirmwareInfo)
|
||||
last_seen: datetime = field(default_factory=datetime.now)
|
||||
first_seen: datetime = field(default_factory=datetime.now)
|
||||
worker: Optional[PM3Worker] = None # Dedicated worker instance
|
||||
metadata: Dict = field(default_factory=dict) # Additional metadata
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for API responses."""
|
||||
return {
|
||||
"device_id": self.device_id,
|
||||
"device_path": self.device_path,
|
||||
"serial_number": self.serial_number,
|
||||
"friendly_name": self.friendly_name or self.device_path.split('/')[-1],
|
||||
"usb_vid": self.usb_vid,
|
||||
"usb_pid": self.usb_pid,
|
||||
"status": self.status.value,
|
||||
"firmware_info": {
|
||||
"bootrom_version": self.firmware_info.bootrom_version,
|
||||
"os_version": self.firmware_info.os_version,
|
||||
"client_version": self.firmware_info.client_version,
|
||||
"compatible": self.firmware_info.compatible,
|
||||
"needs_upgrade": self.firmware_info.needs_upgrade,
|
||||
"needs_downgrade": self.firmware_info.needs_downgrade,
|
||||
},
|
||||
"last_seen": self.last_seen.isoformat(),
|
||||
"first_seen": self.first_seen.isoformat(),
|
||||
"connected": self.status == DeviceStatus.CONNECTED,
|
||||
"in_use": self.status == DeviceStatus.IN_USE,
|
||||
}
|
||||
|
||||
|
||||
class PM3DeviceManager:
|
||||
"""Manages multiple Proxmark3 devices."""
|
||||
|
||||
# USB VID/PID for Proxmark3 devices
|
||||
PM3_USB_VID_PRIMARY = "9ac4" # Standard PM3
|
||||
PM3_USB_PID_PRIMARY = "4b8f"
|
||||
PM3_USB_VID_EASY = "502d" # PM3 Easy
|
||||
PM3_USB_PID_EASY = "502d"
|
||||
|
||||
# Alternative identifiers
|
||||
PM3_VENDOR_IDS = ["9ac4", "502d", "2d0d"] # Known PM3 vendor IDs
|
||||
PM3_PRODUCT_IDS = ["4b8f", "502d"] # Known PM3 product IDs
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize device manager."""
|
||||
self._devices: Dict[str, PM3Device] = {} # device_id -> PM3Device
|
||||
self._lock = asyncio.Lock()
|
||||
self._monitor_task: Optional[asyncio.Task] = None
|
||||
self._udev_context = None
|
||||
self._udev_monitor = None
|
||||
|
||||
# Device discovery settings
|
||||
self.auto_discover = True
|
||||
self.discovery_interval = 30 # seconds
|
||||
self.device_timeout = 300 # seconds
|
||||
|
||||
logger.info("PM3DeviceManager initialized")
|
||||
|
||||
async def start(self):
|
||||
"""Start device manager and monitoring."""
|
||||
logger.info("Starting PM3 device manager")
|
||||
|
||||
# Initial device discovery
|
||||
await self.discover_devices()
|
||||
|
||||
# Start monitoring for hotplug events
|
||||
if UDEV_AVAILABLE:
|
||||
await self._start_udev_monitor()
|
||||
else:
|
||||
logger.warning("pyudev not available, using polling fallback")
|
||||
await self._start_polling_monitor()
|
||||
|
||||
async def stop(self):
|
||||
"""Stop device manager and monitoring."""
|
||||
logger.info("Stopping PM3 device manager")
|
||||
|
||||
if self._monitor_task:
|
||||
self._monitor_task.cancel()
|
||||
try:
|
||||
await self._monitor_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Disconnect all devices
|
||||
async with self._lock:
|
||||
for device in self._devices.values():
|
||||
if device.worker:
|
||||
await device.worker.disconnect()
|
||||
|
||||
async def discover_devices(self) -> List[PM3Device]:
|
||||
"""Scan USB ports for PM3 devices.
|
||||
|
||||
Returns:
|
||||
List of discovered PM3Device objects
|
||||
"""
|
||||
async with self._lock:
|
||||
discovered_paths = set()
|
||||
|
||||
# Method 1: Use pyserial to enumerate serial ports
|
||||
if SERIAL_AVAILABLE:
|
||||
discovered_paths.update(await self._discover_via_serial())
|
||||
|
||||
# Method 2: Scan /dev/ttyACM* directly
|
||||
discovered_paths.update(await self._discover_via_dev())
|
||||
|
||||
# Process discovered devices
|
||||
current_devices = {}
|
||||
for device_path in discovered_paths:
|
||||
device_id = self._generate_device_id(device_path)
|
||||
|
||||
if device_id in self._devices:
|
||||
# Update existing device
|
||||
device = self._devices[device_id]
|
||||
device.last_seen = datetime.now()
|
||||
device.status = DeviceStatus.CONNECTED
|
||||
else:
|
||||
# Create new device
|
||||
device = await self._create_device(device_path)
|
||||
|
||||
current_devices[device_id] = device
|
||||
|
||||
# Mark missing devices as disconnected
|
||||
for device_id, device in self._devices.items():
|
||||
if device_id not in current_devices:
|
||||
device.status = DeviceStatus.DISCONNECTED
|
||||
current_devices[device_id] = device
|
||||
|
||||
self._devices = current_devices
|
||||
|
||||
logger.info(f"Discovered {len([d for d in self._devices.values() if d.status == DeviceStatus.CONNECTED])} connected PM3 devices")
|
||||
|
||||
return list(self._devices.values())
|
||||
|
||||
async def _discover_via_serial(self) -> set:
|
||||
"""Discover devices using pyserial."""
|
||||
devices = set()
|
||||
|
||||
try:
|
||||
ports = serial.tools.list_ports.comports()
|
||||
for port in ports:
|
||||
# Check if it's a PM3 device by VID/PID
|
||||
if port.vid and port.pid:
|
||||
vid = f"{port.vid:04x}"
|
||||
pid = f"{port.pid:04x}"
|
||||
|
||||
if vid in self.PM3_VENDOR_IDS or pid in self.PM3_PRODUCT_IDS:
|
||||
devices.add(port.device)
|
||||
logger.debug(f"Found PM3 device via serial: {port.device} (VID:{vid} PID:{pid})")
|
||||
except Exception as e:
|
||||
logger.error(f"Error discovering devices via serial: {e}")
|
||||
|
||||
return devices
|
||||
|
||||
async def _discover_via_dev(self) -> set:
|
||||
"""Discover devices by scanning /dev/ttyACM*."""
|
||||
devices = set()
|
||||
|
||||
try:
|
||||
# Look for /dev/ttyACM* devices
|
||||
dev_path = Path("/dev")
|
||||
for device_file in dev_path.glob("ttyACM*"):
|
||||
if device_file.is_char_device():
|
||||
devices.add(str(device_file))
|
||||
logger.debug(f"Found device: {device_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error scanning /dev: {e}")
|
||||
|
||||
return devices
|
||||
|
||||
async def _create_device(self, device_path: str) -> PM3Device:
|
||||
"""Create a new PM3Device object.
|
||||
|
||||
Args:
|
||||
device_path: Path to device (e.g., /dev/ttyACM0)
|
||||
|
||||
Returns:
|
||||
PM3Device object
|
||||
"""
|
||||
device_id = self._generate_device_id(device_path)
|
||||
|
||||
# Get USB info if available
|
||||
usb_info = await self._get_usb_info(device_path)
|
||||
|
||||
# Create device object
|
||||
device = PM3Device(
|
||||
device_id=device_id,
|
||||
device_path=device_path,
|
||||
serial_number=usb_info.get("serial"),
|
||||
usb_vid=usb_info.get("vid"),
|
||||
usb_pid=usb_info.get("pid"),
|
||||
status=DeviceStatus.CONNECTED,
|
||||
friendly_name=None, # Will be set by user or auto-generated
|
||||
first_seen=datetime.now(),
|
||||
last_seen=datetime.now()
|
||||
)
|
||||
|
||||
# Create dedicated worker - use SubprocessPM3Worker for robustness
|
||||
# SubprocessPM3Worker calls pm3 CLI directly and doesn't need SWIG bindings
|
||||
device.worker = SubprocessPM3Worker(device_path=device_path)
|
||||
|
||||
# Query firmware version (in background, don't block)
|
||||
asyncio.create_task(self._query_firmware_version(device))
|
||||
|
||||
logger.info(f"Created device {device_id} at {device_path}")
|
||||
|
||||
return device
|
||||
|
||||
def _generate_device_id(self, device_path: str, serial: Optional[str] = None) -> str:
|
||||
"""Generate unique device ID.
|
||||
|
||||
Args:
|
||||
device_path: Device path
|
||||
serial: Optional serial number
|
||||
|
||||
Returns:
|
||||
Unique device ID
|
||||
"""
|
||||
# Use serial number if available, otherwise use path
|
||||
identifier = serial if serial else device_path
|
||||
|
||||
# Generate short hash
|
||||
hash_obj = hashlib.md5(identifier.encode())
|
||||
return f"pm3_{hash_obj.hexdigest()[:8]}"
|
||||
|
||||
async def _get_usb_info(self, device_path: str) -> dict:
|
||||
"""Get USB device information.
|
||||
|
||||
Args:
|
||||
device_path: Device path
|
||||
|
||||
Returns:
|
||||
Dictionary with vid, pid, serial
|
||||
"""
|
||||
info = {}
|
||||
|
||||
if SERIAL_AVAILABLE:
|
||||
try:
|
||||
# Find port info
|
||||
ports = serial.tools.list_ports.comports()
|
||||
for port in ports:
|
||||
if port.device == device_path:
|
||||
if port.vid:
|
||||
info["vid"] = f"{port.vid:04x}"
|
||||
if port.pid:
|
||||
info["pid"] = f"{port.pid:04x}"
|
||||
if port.serial_number:
|
||||
info["serial"] = port.serial_number
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting USB info: {e}")
|
||||
|
||||
return info
|
||||
|
||||
async def _query_firmware_version(self, device: PM3Device):
|
||||
"""Query device firmware version.
|
||||
|
||||
Args:
|
||||
device: PM3Device to query
|
||||
"""
|
||||
try:
|
||||
if not device.worker:
|
||||
return
|
||||
|
||||
# Execute hw version command
|
||||
result = await device.worker.execute_command("hw version")
|
||||
|
||||
if result.success:
|
||||
# Parse firmware version from output
|
||||
firmware_info = self._parse_firmware_version(result.output)
|
||||
device.firmware_info = firmware_info
|
||||
|
||||
# Update device status based on compatibility
|
||||
if not firmware_info.compatible:
|
||||
device.status = DeviceStatus.VERSION_MISMATCH
|
||||
|
||||
logger.info(f"Device {device.device_id} firmware: {firmware_info.os_version}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error querying firmware version for {device.device_id}: {e}")
|
||||
device.status = DeviceStatus.ERROR
|
||||
|
||||
def _parse_firmware_version(self, output: str) -> PM3FirmwareInfo:
|
||||
"""Parse firmware version from hw version output.
|
||||
|
||||
Args:
|
||||
output: Output from 'hw version' command
|
||||
|
||||
Returns:
|
||||
PM3FirmwareInfo object
|
||||
"""
|
||||
import re
|
||||
|
||||
info = PM3FirmwareInfo()
|
||||
|
||||
# Parse bootrom version (handles Iceman format: "Iceman/master/v4.20469-104-ge509967ab-suspect")
|
||||
bootrom_match = re.search(r'Bootrom[:\s.]+(.+?)(?:\s+\d{4}-\d{2}-\d{2}|\s+[0-9a-f]{9}|$)', output, re.IGNORECASE | re.MULTILINE)
|
||||
if bootrom_match:
|
||||
info.bootrom_version = bootrom_match.group(1).strip()
|
||||
|
||||
# Parse OS version (handles Iceman format)
|
||||
os_match = re.search(r'OS[:\s.]+(.+?)(?:\s+\d{4}-\d{2}-\d{2}|\s+[0-9a-f]{9}|$)', output, re.IGNORECASE | re.MULTILINE)
|
||||
if os_match:
|
||||
info.os_version = os_match.group(1).strip()
|
||||
|
||||
# Parse client version (handles Iceman format: "Iceman/master/4fa8f27-dirty-suspect")
|
||||
client_match = re.search(r'\[\s*Client\s*\]\s+(.+?)(?:\s+\d{4}-\d{2}-\d{2}|\s+[0-9a-f]{9}|$)', output, re.IGNORECASE | re.MULTILINE)
|
||||
if client_match:
|
||||
info.client_version = client_match.group(1).strip()
|
||||
|
||||
# Check compatibility (exact match required per plan)
|
||||
# For Iceman firmware, we compare the version strings directly
|
||||
info.compatible = (
|
||||
info.os_version != "unknown" and
|
||||
info.bootrom_version != "unknown" and
|
||||
info.client_version != "unknown" and
|
||||
info.os_version == info.client_version and
|
||||
info.bootrom_version == info.client_version
|
||||
)
|
||||
|
||||
# For version comparison, extract just the version number
|
||||
def extract_version(ver_str: str) -> str:
|
||||
"""Extract version number from version string."""
|
||||
ver_match = re.search(r'v?(\d+\.\d+)', ver_str)
|
||||
return ver_match.group(1) if ver_match else ver_str
|
||||
|
||||
os_ver = extract_version(info.os_version)
|
||||
client_ver = extract_version(info.client_version)
|
||||
bootrom_ver = extract_version(info.bootrom_version)
|
||||
|
||||
info.needs_upgrade = os_ver < client_ver if os_ver != "unknown" and client_ver != "unknown" else False
|
||||
info.needs_downgrade = os_ver > client_ver if os_ver != "unknown" and client_ver != "unknown" else False
|
||||
info.bootloader_outdated = bootrom_ver < client_ver if bootrom_ver != "unknown" and client_ver != "unknown" else False
|
||||
|
||||
return info
|
||||
|
||||
def _get_local_client_version(self) -> str:
|
||||
"""Get version of locally installed proxmark3 client.
|
||||
|
||||
Returns:
|
||||
Client version string or "unknown"
|
||||
"""
|
||||
# TODO: Implement actual version detection
|
||||
# For now, return a placeholder
|
||||
return "v4.14831"
|
||||
|
||||
async def get_device(self, device_id: str) -> Optional[PM3Device]:
|
||||
"""Get device by ID.
|
||||
|
||||
Args:
|
||||
device_id: Device ID
|
||||
|
||||
Returns:
|
||||
PM3Device or None if not found
|
||||
"""
|
||||
async with self._lock:
|
||||
return self._devices.get(device_id)
|
||||
|
||||
async def get_all_devices(self) -> List[PM3Device]:
|
||||
"""Get all known devices.
|
||||
|
||||
Returns:
|
||||
List of all PM3Device objects
|
||||
"""
|
||||
async with self._lock:
|
||||
return list(self._devices.values())
|
||||
|
||||
async def get_available_devices(self) -> List[PM3Device]:
|
||||
"""Get devices not currently in use.
|
||||
|
||||
Returns:
|
||||
List of available PM3Device objects
|
||||
"""
|
||||
async with self._lock:
|
||||
return [
|
||||
device for device in self._devices.values()
|
||||
if device.status == DeviceStatus.CONNECTED
|
||||
]
|
||||
|
||||
async def get_connected_devices(self) -> List[PM3Device]:
|
||||
"""Get connected devices.
|
||||
|
||||
Returns:
|
||||
List of connected PM3Device objects
|
||||
"""
|
||||
async with self._lock:
|
||||
return [
|
||||
device for device in self._devices.values()
|
||||
if device.status not in [DeviceStatus.DISCONNECTED, DeviceStatus.ERROR]
|
||||
]
|
||||
|
||||
async def set_device_status(self, device_id: str, status: DeviceStatus) -> bool:
|
||||
"""Set device status.
|
||||
|
||||
Args:
|
||||
device_id: Device ID
|
||||
status: New status
|
||||
|
||||
Returns:
|
||||
True if successful, False if device not found
|
||||
"""
|
||||
async with self._lock:
|
||||
device = self._devices.get(device_id)
|
||||
if device:
|
||||
device.status = status
|
||||
return True
|
||||
return False
|
||||
|
||||
async def identify_device(self, device_id: str, duration_ms: int = 2000):
|
||||
"""Blink LEDs on device for identification.
|
||||
|
||||
Args:
|
||||
device_id: Device ID
|
||||
duration_ms: Blink duration in milliseconds
|
||||
|
||||
Raises:
|
||||
ValueError: If device not found
|
||||
RuntimeError: If identification fails
|
||||
"""
|
||||
device = await self.get_device(device_id)
|
||||
if not device:
|
||||
raise ValueError(f"Device {device_id} not found")
|
||||
|
||||
if not device.worker:
|
||||
raise RuntimeError(f"Device {device_id} has no worker")
|
||||
|
||||
# Use hw led command to identify the device with visual LED pattern
|
||||
try:
|
||||
result = await device.worker.execute_command(
|
||||
f"hw led --identify --duration {duration_ms}"
|
||||
)
|
||||
if not result.success:
|
||||
raise RuntimeError(f"Failed to identify device: {result.error}")
|
||||
|
||||
logger.info(f"Identified device {device_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error identifying device {device_id}: {e}")
|
||||
raise RuntimeError(f"Failed to identify device: {e}")
|
||||
|
||||
async def _start_udev_monitor(self):
|
||||
"""Start udev monitoring for device hotplug events."""
|
||||
try:
|
||||
logger.info("Starting udev device monitor")
|
||||
|
||||
# This will be implemented in a separate task
|
||||
# For now, fall back to polling
|
||||
await self._start_polling_monitor()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting udev monitor: {e}")
|
||||
await self._start_polling_monitor()
|
||||
|
||||
async def _start_polling_monitor(self):
|
||||
"""Start polling-based device monitoring."""
|
||||
logger.info(f"Starting polling device monitor (interval: {self.discovery_interval}s)")
|
||||
|
||||
async def poll_devices():
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(self.discovery_interval)
|
||||
await self.discover_devices()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in device polling: {e}")
|
||||
|
||||
self._monitor_task = asyncio.create_task(poll_devices())
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Session manager for per-device access control.
|
||||
|
||||
Supports multiple concurrent sessions, one per PM3 device. Each device
|
||||
can have at most one active session at a time.
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Optional, Dict
|
||||
from dataclasses import dataclass
|
||||
import uuid
|
||||
|
||||
from .. import config
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
"""Active session information."""
|
||||
session_id: str
|
||||
device_id: Optional[str] # None for legacy single-device mode
|
||||
client_ip: str
|
||||
user_agent: Optional[str]
|
||||
created_at: float
|
||||
last_activity: float
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""Manages per-device sessions for PM3 access.
|
||||
|
||||
Each PM3 device can have at most one active session. Multiple devices
|
||||
can be used concurrently by different sessions.
|
||||
"""
|
||||
|
||||
# Key used for legacy single-device mode
|
||||
DEFAULT_DEVICE_KEY = "_default"
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize session manager."""
|
||||
self._active_sessions: Dict[str, Session] = {} # keyed by device_id
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def _get_device_key(self, device_id: Optional[str]) -> str:
|
||||
"""Get the key to use for session lookup.
|
||||
|
||||
Args:
|
||||
device_id: Device ID or None for legacy mode
|
||||
|
||||
Returns:
|
||||
Key to use in _active_sessions dict
|
||||
"""
|
||||
return device_id or self.DEFAULT_DEVICE_KEY
|
||||
|
||||
def _cleanup_expired_sessions(self) -> None:
|
||||
"""Remove any expired sessions from the active sessions dict."""
|
||||
current_time = time.time()
|
||||
expired_keys = [
|
||||
key for key, session in self._active_sessions.items()
|
||||
if current_time - session.last_activity > config.SESSION_TIMEOUT
|
||||
]
|
||||
for key in expired_keys:
|
||||
del self._active_sessions[key]
|
||||
|
||||
def has_active_session(self, device_id: Optional[str] = None) -> bool:
|
||||
"""Check if there's an active session for a device.
|
||||
|
||||
Args:
|
||||
device_id: Device ID to check. If None, checks for any active session.
|
||||
|
||||
Returns:
|
||||
True if active session exists
|
||||
"""
|
||||
self._cleanup_expired_sessions()
|
||||
|
||||
if device_id is None:
|
||||
# Check if ANY session is active (legacy behavior)
|
||||
return len(self._active_sessions) > 0
|
||||
|
||||
key = self._get_device_key(device_id)
|
||||
return key in self._active_sessions
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
client_ip: str,
|
||||
user_agent: Optional[str] = None,
|
||||
force_takeover: bool = False,
|
||||
device_id: Optional[str] = None
|
||||
) -> tuple[bool, Optional[str], Optional[str]]:
|
||||
"""Create a new session for a device.
|
||||
|
||||
Args:
|
||||
client_ip: Client IP address
|
||||
user_agent: Client user agent string
|
||||
force_takeover: Force takeover of existing session
|
||||
device_id: Device ID to create session for (None for legacy mode)
|
||||
|
||||
Returns:
|
||||
Tuple of (success, session_id, error_message)
|
||||
"""
|
||||
async with self._lock:
|
||||
self._cleanup_expired_sessions()
|
||||
key = self._get_device_key(device_id)
|
||||
|
||||
# Check if another session is active for this device
|
||||
if key in self._active_sessions and not force_takeover:
|
||||
return False, None, f"Another session is active for device {device_id or 'default'}"
|
||||
|
||||
# Create new session
|
||||
session_id = str(uuid.uuid4())
|
||||
current_time = time.time()
|
||||
|
||||
self._active_sessions[key] = Session(
|
||||
session_id=session_id,
|
||||
device_id=device_id,
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
created_at=current_time,
|
||||
last_activity=current_time
|
||||
)
|
||||
|
||||
return True, session_id, None
|
||||
|
||||
async def release_session(self, session_id: str, device_id: Optional[str] = None) -> bool:
|
||||
"""Release a session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to release
|
||||
device_id: Device ID (if known). If None, searches all sessions.
|
||||
|
||||
Returns:
|
||||
True if session was released, False if not found
|
||||
"""
|
||||
async with self._lock:
|
||||
if device_id is not None:
|
||||
# Direct lookup by device_id
|
||||
key = self._get_device_key(device_id)
|
||||
if key in self._active_sessions and self._active_sessions[key].session_id == session_id:
|
||||
del self._active_sessions[key]
|
||||
return True
|
||||
else:
|
||||
# Search all sessions for the session_id
|
||||
for key, session in list(self._active_sessions.items()):
|
||||
if session.session_id == session_id:
|
||||
del self._active_sessions[key]
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_activity(self, session_id: str, device_id: Optional[str] = None) -> bool:
|
||||
"""Update session activity timestamp.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to update
|
||||
device_id: Device ID (if known). If None, searches all sessions.
|
||||
|
||||
Returns:
|
||||
True if updated, False if session not found
|
||||
"""
|
||||
if device_id is not None:
|
||||
key = self._get_device_key(device_id)
|
||||
if key in self._active_sessions and self._active_sessions[key].session_id == session_id:
|
||||
self._active_sessions[key].last_activity = time.time()
|
||||
return True
|
||||
else:
|
||||
# Search all sessions
|
||||
for session in self._active_sessions.values():
|
||||
if session.session_id == session_id:
|
||||
session.last_activity = time.time()
|
||||
return True
|
||||
return False
|
||||
|
||||
def can_execute(self, session_id: Optional[str], device_id: Optional[str] = None) -> bool:
|
||||
"""Check if a session can execute commands on a device.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to check (None for no session)
|
||||
device_id: Device ID to check (None for legacy single-device mode)
|
||||
|
||||
Returns:
|
||||
True if session can execute, False otherwise
|
||||
"""
|
||||
self._cleanup_expired_sessions()
|
||||
key = self._get_device_key(device_id)
|
||||
|
||||
# No active session for this device - allow execution
|
||||
if key not in self._active_sessions:
|
||||
return True
|
||||
|
||||
# Check if the provided session ID matches the device's active session
|
||||
if session_id and self._active_sessions[key].session_id == session_id:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_active_session(self, device_id: Optional[str] = None) -> Optional[Session]:
|
||||
"""Get the currently active session for a device.
|
||||
|
||||
Args:
|
||||
device_id: Device ID to get session for. If None, returns any active session.
|
||||
|
||||
Returns:
|
||||
Active session or None
|
||||
"""
|
||||
self._cleanup_expired_sessions()
|
||||
|
||||
if device_id is None:
|
||||
# Return first active session (legacy behavior)
|
||||
return next(iter(self._active_sessions.values()), None)
|
||||
|
||||
key = self._get_device_key(device_id)
|
||||
return self._active_sessions.get(key)
|
||||
|
||||
def get_session_for_device(self, device_id: str) -> Optional[Session]:
|
||||
"""Get the active session for a specific device.
|
||||
|
||||
Args:
|
||||
device_id: Device ID to get session for
|
||||
|
||||
Returns:
|
||||
Active session for the device or None
|
||||
"""
|
||||
return self.get_active_session(device_id)
|
||||
|
||||
def get_all_active_sessions(self) -> Dict[str, Session]:
|
||||
"""Get all active sessions.
|
||||
|
||||
Returns:
|
||||
Dict of device_id -> Session for all active sessions
|
||||
"""
|
||||
self._cleanup_expired_sessions()
|
||||
return dict(self._active_sessions)
|
||||
@@ -0,0 +1,479 @@
|
||||
"""Update Manager for Dangerous Pi.
|
||||
|
||||
Handles checking for updates from GitHub releases, downloading,
|
||||
and applying updates to the system.
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List
|
||||
import aiohttp
|
||||
import aiosqlite
|
||||
|
||||
from .. import config
|
||||
|
||||
|
||||
class UpdateStatus(str, Enum):
|
||||
"""Update status enum."""
|
||||
IDLE = "idle"
|
||||
CHECKING = "checking"
|
||||
AVAILABLE = "available"
|
||||
DOWNLOADING = "downloading"
|
||||
INSTALLING = "installing"
|
||||
COMPLETE = "complete"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReleaseInfo:
|
||||
"""GitHub release information."""
|
||||
version: str
|
||||
tag_name: str
|
||||
published_at: str
|
||||
download_url: str
|
||||
changelog: str
|
||||
is_prerelease: bool
|
||||
asset_name: str
|
||||
asset_size: int
|
||||
checksum: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateProgress:
|
||||
"""Update progress information."""
|
||||
status: UpdateStatus
|
||||
current_version: str
|
||||
available_version: Optional[str] = None
|
||||
download_progress: float = 0.0
|
||||
error_message: Optional[str] = None
|
||||
last_check: Optional[str] = None
|
||||
|
||||
|
||||
class UpdateManager:
|
||||
"""Manages system updates from GitHub releases."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the update manager."""
|
||||
self._current_version = config.VERSION
|
||||
self._github_repo = config.GITHUB_REPO
|
||||
self._github_api_base = "https://api.github.com"
|
||||
self._status = UpdateStatus.IDLE
|
||||
self._progress = UpdateProgress(
|
||||
status=UpdateStatus.IDLE,
|
||||
current_version=self._current_version
|
||||
)
|
||||
self._latest_release: Optional[ReleaseInfo] = None
|
||||
self._update_lock = asyncio.Lock()
|
||||
self._download_path: Optional[Path] = None
|
||||
self._check_interval = config.UPDATE_CHECK_INTERVAL
|
||||
|
||||
async def start_periodic_checks(self):
|
||||
"""Start periodic update checks in background."""
|
||||
while True:
|
||||
try:
|
||||
await self.check_for_updates()
|
||||
await asyncio.sleep(self._check_interval)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Error in periodic update check: {e}")
|
||||
await asyncio.sleep(self._check_interval)
|
||||
|
||||
async def check_for_updates(self) -> Dict[str, Any]:
|
||||
"""Check for available updates from GitHub.
|
||||
|
||||
Returns:
|
||||
Dict containing update status and info
|
||||
"""
|
||||
async with self._update_lock:
|
||||
try:
|
||||
self._status = UpdateStatus.CHECKING
|
||||
self._progress.status = UpdateStatus.CHECKING
|
||||
|
||||
# Fetch latest release from GitHub
|
||||
release = await self._fetch_latest_release()
|
||||
|
||||
if not release:
|
||||
self._status = UpdateStatus.IDLE
|
||||
self._progress.status = UpdateStatus.IDLE
|
||||
self._progress.last_check = datetime.utcnow().isoformat()
|
||||
return {
|
||||
"update_available": False,
|
||||
"current_version": self._current_version,
|
||||
"message": "No releases found"
|
||||
}
|
||||
|
||||
# Compare versions
|
||||
if self._is_newer_version(release.version, self._current_version):
|
||||
self._latest_release = release
|
||||
self._status = UpdateStatus.AVAILABLE
|
||||
self._progress.status = UpdateStatus.AVAILABLE
|
||||
self._progress.available_version = release.version
|
||||
|
||||
return {
|
||||
"update_available": True,
|
||||
"current_version": self._current_version,
|
||||
"latest_version": release.version,
|
||||
"release_date": release.published_at,
|
||||
"changelog": release.changelog,
|
||||
"is_prerelease": release.is_prerelease,
|
||||
"download_size": release.asset_size
|
||||
}
|
||||
else:
|
||||
self._status = UpdateStatus.IDLE
|
||||
self._progress.status = UpdateStatus.IDLE
|
||||
self._progress.last_check = datetime.utcnow().isoformat()
|
||||
|
||||
return {
|
||||
"update_available": False,
|
||||
"current_version": self._current_version,
|
||||
"latest_version": release.version,
|
||||
"message": "System is up to date"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self._status = UpdateStatus.FAILED
|
||||
self._progress.status = UpdateStatus.FAILED
|
||||
self._progress.error_message = str(e)
|
||||
raise Exception(f"Failed to check for updates: {e}")
|
||||
|
||||
async def download_update(self) -> bool:
|
||||
"""Download the latest update.
|
||||
|
||||
Returns:
|
||||
True if download successful, False otherwise
|
||||
"""
|
||||
async with self._update_lock:
|
||||
if not self._latest_release:
|
||||
raise ValueError("No update available to download")
|
||||
|
||||
try:
|
||||
self._status = UpdateStatus.DOWNLOADING
|
||||
self._progress.status = UpdateStatus.DOWNLOADING
|
||||
self._progress.download_progress = 0.0
|
||||
|
||||
# Create temp directory for download
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="dangerous-pi-update-"))
|
||||
self._download_path = temp_dir / self._latest_release.asset_name
|
||||
|
||||
# Download the release asset
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(self._latest_release.download_url) as response:
|
||||
if response.status != 200:
|
||||
raise Exception(f"Download failed with status {response.status}")
|
||||
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
downloaded = 0
|
||||
|
||||
with open(self._download_path, 'wb') as f:
|
||||
async for chunk in response.content.iter_chunked(8192):
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if total_size > 0:
|
||||
self._progress.download_progress = (downloaded / total_size) * 100
|
||||
|
||||
# Verify checksum if available
|
||||
if self._latest_release.checksum:
|
||||
if not await self._verify_checksum(self._download_path, self._latest_release.checksum):
|
||||
raise Exception("Checksum verification failed")
|
||||
|
||||
self._progress.download_progress = 100.0
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self._status = UpdateStatus.FAILED
|
||||
self._progress.status = UpdateStatus.FAILED
|
||||
self._progress.error_message = str(e)
|
||||
|
||||
# Cleanup on failure
|
||||
if self._download_path and self._download_path.parent.exists():
|
||||
shutil.rmtree(self._download_path.parent, ignore_errors=True)
|
||||
|
||||
raise Exception(f"Failed to download update: {e}")
|
||||
|
||||
async def install_update(self) -> bool:
|
||||
"""Install the downloaded update.
|
||||
|
||||
Returns:
|
||||
True if installation successful, False otherwise
|
||||
"""
|
||||
async with self._update_lock:
|
||||
if not self._download_path or not self._download_path.exists():
|
||||
raise ValueError("No update downloaded")
|
||||
|
||||
try:
|
||||
self._status = UpdateStatus.INSTALLING
|
||||
self._progress.status = UpdateStatus.INSTALLING
|
||||
|
||||
# Extract the update archive
|
||||
install_dir = Path("/opt/dangerous-pi")
|
||||
backup_dir = Path("/opt/dangerous-pi-backup")
|
||||
|
||||
# Create backup of current installation
|
||||
if install_dir.exists():
|
||||
if backup_dir.exists():
|
||||
shutil.rmtree(backup_dir)
|
||||
shutil.copytree(install_dir, backup_dir)
|
||||
|
||||
# Extract update (assuming tar.gz format)
|
||||
await self._run_command(
|
||||
f"tar -xzf {self._download_path} -C {install_dir.parent}"
|
||||
)
|
||||
|
||||
# Run post-install script if exists
|
||||
post_install = install_dir / "scripts" / "post-install.sh"
|
||||
if post_install.exists():
|
||||
await self._run_command(f"sudo bash {post_install}")
|
||||
|
||||
# Rebuild PM3 client if needed
|
||||
await self._rebuild_pm3_client()
|
||||
|
||||
# Update version in config
|
||||
await self._update_version_file(self._latest_release.version)
|
||||
|
||||
# Cleanup
|
||||
shutil.rmtree(self._download_path.parent, ignore_errors=True)
|
||||
self._download_path = None
|
||||
|
||||
self._status = UpdateStatus.COMPLETE
|
||||
self._progress.status = UpdateStatus.COMPLETE
|
||||
self._current_version = self._latest_release.version
|
||||
self._progress.current_version = self._latest_release.version
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self._status = UpdateStatus.FAILED
|
||||
self._progress.status = UpdateStatus.FAILED
|
||||
self._progress.error_message = str(e)
|
||||
|
||||
# Restore backup on failure
|
||||
if backup_dir.exists():
|
||||
if install_dir.exists():
|
||||
shutil.rmtree(install_dir)
|
||||
shutil.copytree(backup_dir, install_dir)
|
||||
|
||||
raise Exception(f"Failed to install update: {e}")
|
||||
|
||||
async def get_progress(self) -> UpdateProgress:
|
||||
"""Get current update progress.
|
||||
|
||||
Returns:
|
||||
UpdateProgress object
|
||||
"""
|
||||
return self._progress
|
||||
|
||||
async def get_release_notes(self, version: Optional[str] = None) -> str:
|
||||
"""Get release notes for a specific version.
|
||||
|
||||
Args:
|
||||
version: Version to get notes for (latest if None)
|
||||
|
||||
Returns:
|
||||
Release notes as markdown string
|
||||
"""
|
||||
try:
|
||||
if version:
|
||||
release = await self._fetch_release_by_version(version)
|
||||
else:
|
||||
release = await self._fetch_latest_release()
|
||||
|
||||
return release.changelog if release else "No release notes available"
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to get release notes: {e}")
|
||||
|
||||
async def _fetch_latest_release(self) -> Optional[ReleaseInfo]:
|
||||
"""Fetch latest release from GitHub API.
|
||||
|
||||
Returns:
|
||||
ReleaseInfo object or None
|
||||
"""
|
||||
url = f"{self._github_api_base}/repos/{self._github_repo}/releases/latest"
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as response:
|
||||
if response.status == 404:
|
||||
return None
|
||||
|
||||
if response.status != 200:
|
||||
raise Exception(f"GitHub API returned status {response.status}")
|
||||
|
||||
data = await response.json()
|
||||
return self._parse_release_data(data)
|
||||
|
||||
async def _fetch_release_by_version(self, version: str) -> Optional[ReleaseInfo]:
|
||||
"""Fetch specific release by version tag.
|
||||
|
||||
Args:
|
||||
version: Version tag (e.g., "v1.0.0")
|
||||
|
||||
Returns:
|
||||
ReleaseInfo object or None
|
||||
"""
|
||||
tag = version if version.startswith('v') else f'v{version}'
|
||||
url = f"{self._github_api_base}/repos/{self._github_repo}/releases/tags/{tag}"
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as response:
|
||||
if response.status == 404:
|
||||
return None
|
||||
|
||||
if response.status != 200:
|
||||
raise Exception(f"GitHub API returned status {response.status}")
|
||||
|
||||
data = await response.json()
|
||||
return self._parse_release_data(data)
|
||||
|
||||
def _parse_release_data(self, data: Dict[str, Any]) -> ReleaseInfo:
|
||||
"""Parse GitHub release API response.
|
||||
|
||||
Args:
|
||||
data: GitHub API release data
|
||||
|
||||
Returns:
|
||||
ReleaseInfo object
|
||||
"""
|
||||
# Find the main release asset (tar.gz)
|
||||
assets = data.get('assets', [])
|
||||
main_asset = None
|
||||
checksum_asset = None
|
||||
|
||||
for asset in assets:
|
||||
name = asset['name']
|
||||
if name.endswith('.tar.gz'):
|
||||
main_asset = asset
|
||||
elif name.endswith('.sha256'):
|
||||
checksum_asset = asset
|
||||
|
||||
if not main_asset:
|
||||
raise ValueError("No suitable release asset found")
|
||||
|
||||
# Get version from tag (remove 'v' prefix)
|
||||
version = data['tag_name'].lstrip('v')
|
||||
|
||||
# Get checksum if available
|
||||
checksum = None
|
||||
if checksum_asset:
|
||||
# Would need to download and read checksum file
|
||||
# For now, we'll skip this step
|
||||
pass
|
||||
|
||||
return ReleaseInfo(
|
||||
version=version,
|
||||
tag_name=data['tag_name'],
|
||||
published_at=data['published_at'],
|
||||
download_url=main_asset['browser_download_url'],
|
||||
changelog=data.get('body', ''),
|
||||
is_prerelease=data.get('prerelease', False),
|
||||
asset_name=main_asset['name'],
|
||||
asset_size=main_asset['size'],
|
||||
checksum=checksum
|
||||
)
|
||||
|
||||
def _is_newer_version(self, version1: str, version2: str) -> bool:
|
||||
"""Compare two semantic versions.
|
||||
|
||||
Args:
|
||||
version1: First version (e.g., "1.2.3")
|
||||
version2: Second version (e.g., "1.1.0")
|
||||
|
||||
Returns:
|
||||
True if version1 is newer than version2
|
||||
"""
|
||||
def parse_version(v: str) -> tuple:
|
||||
# Remove 'v' prefix and split
|
||||
v = v.lstrip('v')
|
||||
parts = re.split(r'[-+]', v)[0] # Remove pre-release/build metadata
|
||||
return tuple(map(int, parts.split('.')))
|
||||
|
||||
try:
|
||||
v1_parts = parse_version(version1)
|
||||
v2_parts = parse_version(version2)
|
||||
return v1_parts > v2_parts
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
async def _verify_checksum(self, file_path: Path, expected_checksum: str) -> bool:
|
||||
"""Verify file checksum.
|
||||
|
||||
Args:
|
||||
file_path: Path to file to verify
|
||||
expected_checksum: Expected SHA256 checksum
|
||||
|
||||
Returns:
|
||||
True if checksum matches, False otherwise
|
||||
"""
|
||||
sha256 = hashlib.sha256()
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
while True:
|
||||
data = f.read(65536) # 64KB chunks
|
||||
if not data:
|
||||
break
|
||||
sha256.update(data)
|
||||
|
||||
actual_checksum = sha256.hexdigest()
|
||||
return actual_checksum.lower() == expected_checksum.lower()
|
||||
|
||||
async def _rebuild_pm3_client(self):
|
||||
"""Rebuild Proxmark3 client after update."""
|
||||
pm3_dir = Path("/opt/proxmark3")
|
||||
|
||||
if not pm3_dir.exists():
|
||||
print("Proxmark3 directory not found, skipping rebuild")
|
||||
return
|
||||
|
||||
# Run make clean and make
|
||||
await self._run_command(f"cd {pm3_dir} && make clean && make")
|
||||
|
||||
async def _update_version_file(self, version: str):
|
||||
"""Update version file with new version.
|
||||
|
||||
Args:
|
||||
version: New version string
|
||||
"""
|
||||
version_file = Path("/opt/dangerous-pi/VERSION")
|
||||
version_file.write_text(version)
|
||||
|
||||
async def _run_command(self, command: str) -> str:
|
||||
"""Run a shell command.
|
||||
|
||||
Args:
|
||||
command: Command to run
|
||||
|
||||
Returns:
|
||||
Command output
|
||||
"""
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
raise Exception(f"Command failed: {stderr.decode()}")
|
||||
|
||||
return stdout.decode()
|
||||
|
||||
|
||||
# Global update manager instance
|
||||
_update_manager: Optional[UpdateManager] = None
|
||||
|
||||
|
||||
def get_update_manager() -> UpdateManager:
|
||||
"""Get the global update manager instance."""
|
||||
global _update_manager
|
||||
if _update_manager is None:
|
||||
_update_manager = UpdateManager()
|
||||
return _update_manager
|
||||
@@ -0,0 +1,75 @@
|
||||
"""UPS driver implementations for different hardware.
|
||||
|
||||
Supports multiple UPS types:
|
||||
- pisugar: PiSugar 2/3 via native I2C (no daemon needed)
|
||||
- pisugar-daemon: PiSugar via pisugar-server TCP daemon (legacy)
|
||||
- i2c: Generic I2C fuel gauge (MAX17040/MAX17048)
|
||||
- auto: Automatically detect available UPS hardware
|
||||
"""
|
||||
from typing import Optional, Tuple
|
||||
from .base import UPSDriver, UPSData
|
||||
from .pisugar_driver import PiSugarDriver # TCP daemon driver (legacy)
|
||||
from .pisugar_i2c_driver import PiSugarI2CDriver, PiSugarModel, ButtonEvent
|
||||
from .i2c_driver import I2CFuelGaugeDriver
|
||||
|
||||
|
||||
async def auto_detect_driver(
|
||||
pisugar_host: str = "127.0.0.1",
|
||||
pisugar_port: int = 8423,
|
||||
prefer_native_i2c: bool = True
|
||||
) -> Tuple[Optional[UPSDriver], str]:
|
||||
"""Auto-detect available UPS hardware and return appropriate driver.
|
||||
|
||||
Detection order (first match wins):
|
||||
1. PiSugar via native I2C (preferred - no daemon overhead)
|
||||
2. PiSugar via TCP daemon (fallback if I2C fails)
|
||||
3. I2C Fuel Gauge (MAX17040/MAX17048 at 0x36 or other addresses)
|
||||
|
||||
Args:
|
||||
pisugar_host: PiSugar server host for daemon detection (legacy)
|
||||
pisugar_port: PiSugar server port (legacy)
|
||||
prefer_native_i2c: If True, prefer native I2C driver over daemon
|
||||
|
||||
Returns:
|
||||
Tuple of (driver_instance or None, detection_message)
|
||||
"""
|
||||
# Try native PiSugar I2C first (no daemon overhead, minimal CPU)
|
||||
if prefer_native_i2c:
|
||||
detected, driver = await PiSugarI2CDriver.detect()
|
||||
if detected and driver:
|
||||
model = driver.get_model_name()
|
||||
return (driver, f"Detected PiSugar UPS via I2C: {model}")
|
||||
|
||||
# Try PiSugar daemon (legacy fallback)
|
||||
detected, model = await PiSugarDriver.detect(host=pisugar_host, port=pisugar_port)
|
||||
if detected:
|
||||
driver = PiSugarDriver(host=pisugar_host, port=pisugar_port)
|
||||
return (driver, f"Detected PiSugar UPS via daemon: {model}")
|
||||
|
||||
# Try native I2C again if we skipped it earlier
|
||||
if not prefer_native_i2c:
|
||||
detected, driver = await PiSugarI2CDriver.detect()
|
||||
if detected and driver:
|
||||
model = driver.get_model_name()
|
||||
return (driver, f"Detected PiSugar UPS via I2C: {model}")
|
||||
|
||||
# Try generic I2C fuel gauge (exclude PiSugar I2C addresses)
|
||||
exclude_addrs = list(PiSugarI2CDriver.KNOWN_ADDRESSES.keys()) + [PiSugarI2CDriver.RTC_ADDRESS]
|
||||
detected, i2c_addr = await I2CFuelGaugeDriver.detect(exclude_addresses=exclude_addrs)
|
||||
if detected:
|
||||
driver = I2CFuelGaugeDriver(i2c_address=i2c_addr)
|
||||
return (driver, f"Detected I2C fuel gauge at address 0x{i2c_addr:02X}")
|
||||
|
||||
return (None, "No UPS hardware detected")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"UPSDriver",
|
||||
"UPSData",
|
||||
"PiSugarDriver",
|
||||
"PiSugarI2CDriver",
|
||||
"PiSugarModel",
|
||||
"ButtonEvent",
|
||||
"I2CFuelGaugeDriver",
|
||||
"auto_detect_driver",
|
||||
]
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Base class for UPS drivers."""
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class UPSData:
|
||||
"""Battery data from UPS hardware."""
|
||||
percentage: float # 0-100%
|
||||
voltage: float # mV
|
||||
current: float # A (positive=charging, negative=discharging)
|
||||
is_charging: bool
|
||||
temperature: Optional[float] = None # Celsius
|
||||
time_remaining: Optional[int] = None # Minutes
|
||||
|
||||
|
||||
class UPSDriver(ABC):
|
||||
"""Abstract base class for UPS hardware drivers."""
|
||||
|
||||
@abstractmethod
|
||||
async def initialize(self) -> bool:
|
||||
"""Initialize connection to UPS hardware.
|
||||
|
||||
Returns:
|
||||
True if initialization successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def read_data(self) -> UPSData:
|
||||
"""Read current battery data from UPS.
|
||||
|
||||
Returns:
|
||||
UPSData object with current readings
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def is_available(self) -> bool:
|
||||
"""Check if UPS hardware is available.
|
||||
|
||||
Returns:
|
||||
True if hardware is accessible, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def close(self):
|
||||
"""Close connection to UPS hardware."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_model_name(self) -> str:
|
||||
"""Get the UPS model/type name.
|
||||
|
||||
Returns:
|
||||
Human-readable model name
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,210 @@
|
||||
"""I2C Fuel Gauge driver for generic UPS HATs.
|
||||
|
||||
Supports MAX17040/MAX17048 and similar I2C fuel gauge chips.
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Optional, Tuple, List
|
||||
try:
|
||||
from smbus2 import SMBus
|
||||
except ImportError:
|
||||
SMBus = None
|
||||
|
||||
from .base import UPSDriver, UPSData
|
||||
|
||||
|
||||
class I2CFuelGaugeDriver(UPSDriver):
|
||||
"""Driver for I2C-based fuel gauge UPS HATs."""
|
||||
|
||||
# Known I2C addresses for fuel gauge chips
|
||||
# Excludes PiSugar addresses (0x57, 0x32) which are handled by PiSugarDriver
|
||||
FUEL_GAUGE_ADDRESSES = [
|
||||
0x36, # MAX17040/MAX17048/MAX17050 (most common)
|
||||
0x55, # BQ27441 (TI fuel gauge)
|
||||
0x62, # BQ27510 (TI fuel gauge)
|
||||
0x0B, # Some SBS battery monitors
|
||||
]
|
||||
|
||||
@classmethod
|
||||
async def detect(cls, i2c_bus: int = 1, exclude_addresses: List[int] = None) -> Tuple[bool, Optional[int]]:
|
||||
"""Detect if an I2C fuel gauge is available.
|
||||
|
||||
Scans known fuel gauge I2C addresses to find hardware.
|
||||
|
||||
Args:
|
||||
i2c_bus: I2C bus number (default: 1)
|
||||
exclude_addresses: List of addresses to skip (e.g., PiSugar addresses)
|
||||
|
||||
Returns:
|
||||
Tuple of (detected: bool, i2c_address: Optional[int])
|
||||
"""
|
||||
if SMBus is None:
|
||||
return (False, None)
|
||||
|
||||
exclude = exclude_addresses or []
|
||||
|
||||
try:
|
||||
bus = SMBus(i2c_bus)
|
||||
try:
|
||||
for addr in cls.FUEL_GAUGE_ADDRESSES:
|
||||
if addr in exclude:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Try to read a byte - if device exists, this will succeed
|
||||
bus.read_byte(addr)
|
||||
|
||||
# Additional validation: try to read SOC register (0x04)
|
||||
# MAX17040/MAX17048 should respond to this
|
||||
try:
|
||||
bus.read_i2c_block_data(addr, 0x04, 2)
|
||||
return (True, addr)
|
||||
except OSError:
|
||||
# Device responded to address but not to register read
|
||||
# Still likely a fuel gauge, just different protocol
|
||||
return (True, addr)
|
||||
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
finally:
|
||||
bus.close()
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return (False, None)
|
||||
|
||||
def __init__(self, i2c_address: int = 0x36, i2c_bus: int = 1):
|
||||
"""Initialize I2C fuel gauge driver.
|
||||
|
||||
Args:
|
||||
i2c_address: I2C address of fuel gauge chip (default: 0x36)
|
||||
i2c_bus: I2C bus number (default: 1 for Raspberry Pi)
|
||||
"""
|
||||
self._i2c_address = i2c_address
|
||||
self._i2c_bus = i2c_bus
|
||||
self._bus: Optional[SMBus] = None
|
||||
self._available = False
|
||||
self._critical_threshold = 15.0 # For status determination
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""Initialize I2C connection to fuel gauge.
|
||||
|
||||
Returns:
|
||||
True if initialization successful, False otherwise
|
||||
"""
|
||||
if SMBus is None:
|
||||
print("smbus2 library not available")
|
||||
self._available = False
|
||||
return False
|
||||
|
||||
try:
|
||||
# Open I2C bus
|
||||
self._bus = SMBus(self._i2c_bus)
|
||||
|
||||
# Try to read from device to verify it exists
|
||||
await self._test_read()
|
||||
|
||||
self._available = True
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to initialize I2C fuel gauge: {e}")
|
||||
self._available = False
|
||||
self._bus = None
|
||||
return False
|
||||
|
||||
async def read_data(self) -> UPSData:
|
||||
"""Read current battery data from fuel gauge.
|
||||
|
||||
Returns:
|
||||
UPSData object with current readings
|
||||
"""
|
||||
if not self._bus or not self._available:
|
||||
raise RuntimeError("I2C fuel gauge not initialized")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Read voltage (registers 0x02-0x03)
|
||||
voltage_bytes = await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.read_i2c_block_data,
|
||||
self._i2c_address,
|
||||
0x02,
|
||||
2
|
||||
)
|
||||
voltage_raw = (voltage_bytes[0] << 8) | voltage_bytes[1]
|
||||
voltage = (voltage_raw >> 4) * 1.25 # Convert to mV
|
||||
|
||||
# Read state of charge (registers 0x04-0x05)
|
||||
soc_bytes = await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.read_i2c_block_data,
|
||||
self._i2c_address,
|
||||
0x04,
|
||||
2
|
||||
)
|
||||
soc_raw = (soc_bytes[0] << 8) | soc_bytes[1]
|
||||
percentage = soc_raw / 256.0
|
||||
|
||||
# Estimate current (simple approach, some HATs don't provide this)
|
||||
current = 0.0
|
||||
|
||||
# Determine if charging based on voltage
|
||||
# Typically voltage > 4.1V means charging
|
||||
is_charging = voltage > 4100 # 4.1V in mV
|
||||
|
||||
return UPSData(
|
||||
percentage=percentage,
|
||||
voltage=voltage,
|
||||
current=current,
|
||||
is_charging=is_charging,
|
||||
temperature=None, # Not all fuel gauges provide temperature
|
||||
time_remaining=None # Would need battery capacity config to calculate
|
||||
)
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
"""Check if I2C fuel gauge is available.
|
||||
|
||||
Returns:
|
||||
True if hardware is accessible, False otherwise
|
||||
"""
|
||||
if not self._available or not self._bus:
|
||||
# Try to reinitialize
|
||||
return await self.initialize()
|
||||
return True
|
||||
|
||||
def close(self):
|
||||
"""Close I2C bus connection."""
|
||||
if self._bus:
|
||||
self._bus.close()
|
||||
self._bus = None
|
||||
self._available = False
|
||||
|
||||
def get_model_name(self) -> str:
|
||||
"""Get the fuel gauge model name.
|
||||
|
||||
Returns:
|
||||
Human-readable model name
|
||||
"""
|
||||
return "I2C Fuel Gauge (MAX17040/MAX17048)"
|
||||
|
||||
async def _test_read(self):
|
||||
"""Test read from device to verify it exists.
|
||||
|
||||
Raises:
|
||||
Exception if device doesn't respond
|
||||
"""
|
||||
if not self._bus:
|
||||
raise RuntimeError("I2C bus not initialized")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Try to read version register (0x08)
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.read_i2c_block_data,
|
||||
self._i2c_address,
|
||||
0x08,
|
||||
2
|
||||
)
|
||||
@@ -0,0 +1,282 @@
|
||||
"""PiSugar UPS driver.
|
||||
|
||||
Communicates with pisugar-server daemon via TCP socket.
|
||||
Supports PiSugar 2, PiSugar 2 Pro, PiSugar 3, and PiSugar 3 Plus.
|
||||
"""
|
||||
import asyncio
|
||||
import socket
|
||||
from typing import Optional, Tuple
|
||||
from .base import UPSDriver, UPSData
|
||||
|
||||
|
||||
class PiSugarDriver(UPSDriver):
|
||||
"""Driver for PiSugar UPS devices."""
|
||||
|
||||
# Known I2C addresses for PiSugar devices
|
||||
PISUGAR_I2C_ADDRESSES = [0x57, 0x32] # Battery IC, RTC
|
||||
|
||||
@classmethod
|
||||
async def detect(cls, host: str = "127.0.0.1", port: int = 8423) -> Tuple[bool, Optional[str]]:
|
||||
"""Detect if PiSugar hardware is available.
|
||||
|
||||
Tries multiple detection methods:
|
||||
1. Check if pisugar-server daemon is responding
|
||||
2. Scan I2C bus for known PiSugar addresses (fallback)
|
||||
|
||||
Args:
|
||||
host: PiSugar server host
|
||||
port: PiSugar server port
|
||||
|
||||
Returns:
|
||||
Tuple of (detected: bool, model_name: Optional[str])
|
||||
"""
|
||||
# Method 1: Try to connect to pisugar-server daemon
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(host, port),
|
||||
timeout=2.0
|
||||
)
|
||||
|
||||
try:
|
||||
# Send model query
|
||||
writer.write(b"get model\n")
|
||||
await writer.drain()
|
||||
|
||||
response = await asyncio.wait_for(
|
||||
reader.readline(),
|
||||
timeout=2.0
|
||||
)
|
||||
|
||||
model = response.decode().strip()
|
||||
if model and "model:" in model.lower():
|
||||
# Parse "model: PiSugar 3" format
|
||||
parts = model.split(":", 1)
|
||||
if len(parts) > 1:
|
||||
model_name = parts[1].strip()
|
||||
if model_name and model_name.lower() != "unknown":
|
||||
return (True, model_name)
|
||||
|
||||
finally:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
except (asyncio.TimeoutError, ConnectionRefusedError, OSError):
|
||||
pass
|
||||
|
||||
# Method 2: Check I2C addresses (requires smbus2)
|
||||
# NOTE: We only log if hardware is found - PiSugarDriver requires the daemon
|
||||
# If daemon isn't running, we can't use PiSugarDriver even if hardware exists
|
||||
i2c_found = False
|
||||
try:
|
||||
from smbus2 import SMBus
|
||||
bus = SMBus(1)
|
||||
try:
|
||||
for addr in cls.PISUGAR_I2C_ADDRESSES:
|
||||
try:
|
||||
bus.read_byte(addr)
|
||||
i2c_found = True
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
finally:
|
||||
bus.close()
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if i2c_found:
|
||||
# Hardware found but daemon not running - can't use PiSugarDriver
|
||||
print("PiSugar hardware detected via I2C but pisugar-server daemon not running")
|
||||
print("Install pisugar-server or start the daemon to enable PiSugar UPS support")
|
||||
|
||||
return (False, None)
|
||||
|
||||
def __init__(self, host: str = "127.0.0.1", port: int = 8423, timeout: float = 5.0):
|
||||
"""Initialize PiSugar driver.
|
||||
|
||||
Args:
|
||||
host: PiSugar server host (default: localhost)
|
||||
port: PiSugar server port (default: 8423)
|
||||
timeout: Command timeout in seconds (default: 5.0)
|
||||
"""
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._timeout = timeout
|
||||
self._model: Optional[str] = None
|
||||
self._available = False
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""Initialize connection to PiSugar daemon.
|
||||
|
||||
Returns:
|
||||
True if initialization successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Test connection by getting model
|
||||
self._model = await self._send_command("get model")
|
||||
if self._model and self._model != "Unknown":
|
||||
self._available = True
|
||||
return True
|
||||
else:
|
||||
self._available = False
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to initialize PiSugar: {e}")
|
||||
self._available = False
|
||||
return False
|
||||
|
||||
async def read_data(self) -> UPSData:
|
||||
"""Read current battery data from PiSugar.
|
||||
|
||||
Returns:
|
||||
UPSData object with current readings
|
||||
"""
|
||||
if not self._available:
|
||||
raise RuntimeError("PiSugar not initialized or not available")
|
||||
|
||||
# Execute commands in parallel for efficiency
|
||||
results = await asyncio.gather(
|
||||
self._send_command("get battery"),
|
||||
self._send_command("get battery_v"),
|
||||
self._send_command("get battery_i"),
|
||||
self._send_command("get battery_power_plugged"),
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
# Parse results
|
||||
percentage = self._parse_float(results[0], 0.0)
|
||||
voltage = self._parse_float(results[1], 0.0)
|
||||
current = self._parse_float(results[2], 0.0)
|
||||
is_charging = self._parse_bool(results[3], False)
|
||||
|
||||
return UPSData(
|
||||
percentage=percentage,
|
||||
voltage=voltage,
|
||||
current=current,
|
||||
is_charging=is_charging,
|
||||
temperature=None, # PiSugar doesn't provide temperature
|
||||
time_remaining=None # Would need battery capacity config to calculate
|
||||
)
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
"""Check if PiSugar hardware is available.
|
||||
|
||||
Returns:
|
||||
True if hardware is accessible, False otherwise
|
||||
"""
|
||||
if not self._available:
|
||||
# Try to reinitialize
|
||||
return await self.initialize()
|
||||
return True
|
||||
|
||||
def close(self):
|
||||
"""Close connection to PiSugar (stateless, nothing to close)."""
|
||||
self._available = False
|
||||
|
||||
def get_model_name(self) -> str:
|
||||
"""Get the PiSugar model name.
|
||||
|
||||
Returns:
|
||||
Human-readable model name
|
||||
"""
|
||||
return self._model or "PiSugar"
|
||||
|
||||
async def _send_command(self, command: str) -> str:
|
||||
"""Send command to PiSugar server and get response.
|
||||
|
||||
Args:
|
||||
command: Command to send (e.g., "get battery")
|
||||
|
||||
Returns:
|
||||
Response string from server
|
||||
|
||||
Raises:
|
||||
RuntimeError: If connection fails or times out
|
||||
"""
|
||||
try:
|
||||
# Connect to PiSugar server
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(self._host, self._port),
|
||||
timeout=self._timeout
|
||||
)
|
||||
|
||||
try:
|
||||
# Send command
|
||||
writer.write(f"{command}\n".encode())
|
||||
await writer.drain()
|
||||
|
||||
# Read response (single line)
|
||||
response = await asyncio.wait_for(
|
||||
reader.readline(),
|
||||
timeout=self._timeout
|
||||
)
|
||||
|
||||
return response.decode().strip()
|
||||
|
||||
finally:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
raise RuntimeError(f"PiSugar command timeout: {command}")
|
||||
except ConnectionRefusedError:
|
||||
raise RuntimeError("PiSugar server not running (connection refused)")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"PiSugar communication error: {e}")
|
||||
|
||||
def _parse_float(self, value, default: float = 0.0) -> float:
|
||||
"""Parse float value from response.
|
||||
|
||||
Args:
|
||||
value: Response value (could be string, float, or Exception)
|
||||
default: Default value if parsing fails
|
||||
|
||||
Returns:
|
||||
Parsed float value or default
|
||||
"""
|
||||
if isinstance(value, Exception):
|
||||
return default
|
||||
|
||||
try:
|
||||
# PiSugar responses are often in format "battery: 85.5"
|
||||
if isinstance(value, str):
|
||||
# Try to extract number from response
|
||||
parts = value.split(":")
|
||||
if len(parts) > 1:
|
||||
return float(parts[1].strip())
|
||||
else:
|
||||
return float(value.strip())
|
||||
return float(value)
|
||||
except (ValueError, AttributeError):
|
||||
return default
|
||||
|
||||
def _parse_bool(self, value, default: bool = False) -> bool:
|
||||
"""Parse boolean value from response.
|
||||
|
||||
Args:
|
||||
value: Response value (could be string, bool, or Exception)
|
||||
default: Default value if parsing fails
|
||||
|
||||
Returns:
|
||||
Parsed boolean value or default
|
||||
"""
|
||||
if isinstance(value, Exception):
|
||||
return default
|
||||
|
||||
try:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
|
||||
if isinstance(value, str):
|
||||
# PiSugar returns "battery_power_plugged: true" or "false"
|
||||
parts = value.split(":")
|
||||
if len(parts) > 1:
|
||||
return parts[1].strip().lower() == "true"
|
||||
else:
|
||||
return value.strip().lower() in ("true", "1", "yes")
|
||||
|
||||
return bool(value)
|
||||
except (ValueError, AttributeError):
|
||||
return default
|
||||
@@ -0,0 +1,648 @@
|
||||
"""Native PiSugar I2C driver - direct hardware communication.
|
||||
|
||||
Bypasses pisugar-server daemon entirely for minimal CPU usage.
|
||||
Supports:
|
||||
- PiSugar 2 (4-LEDs) - IP5209 chip at I2C address 0x75
|
||||
- PiSugar 2 Pro - IP5209 chip at I2C address 0x75
|
||||
- PiSugar 3 / 3 Plus - IP5312 chip at I2C address 0x57
|
||||
|
||||
Features:
|
||||
- Battery voltage, current, and percentage reading
|
||||
- Power plug/charging detection
|
||||
- Button tap detection (single, double, long press)
|
||||
- RTC alarm for scheduled wake-up
|
||||
- Force shutdown capability
|
||||
|
||||
Register information extracted from:
|
||||
https://github.com/PiSugar/pisugar-power-manager-rs
|
||||
"""
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Callable, List, Optional, Tuple
|
||||
|
||||
try:
|
||||
from smbus2 import SMBus
|
||||
except ImportError:
|
||||
SMBus = None
|
||||
|
||||
from .base import UPSDriver, UPSData
|
||||
|
||||
|
||||
class PiSugarModel(Enum):
|
||||
"""Supported PiSugar models."""
|
||||
PISUGAR_2 = "PiSugar 2"
|
||||
PISUGAR_2_PRO = "PiSugar 2 Pro"
|
||||
PISUGAR_3 = "PiSugar 3"
|
||||
UNKNOWN = "Unknown PiSugar"
|
||||
|
||||
|
||||
class ButtonEvent(Enum):
|
||||
"""Button event types."""
|
||||
SINGLE_TAP = "single"
|
||||
DOUBLE_TAP = "double"
|
||||
LONG_PRESS = "long"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChipConfig:
|
||||
"""Configuration for a specific battery management chip."""
|
||||
i2c_address: int
|
||||
voltage_reg_low: int
|
||||
voltage_reg_high: int
|
||||
current_reg_low: int
|
||||
current_reg_high: int
|
||||
power_status_reg: int
|
||||
power_plugged_mask: int
|
||||
model: PiSugarModel
|
||||
|
||||
|
||||
# IP5209 chip used in PiSugar 2 series
|
||||
IP5209_CONFIG = ChipConfig(
|
||||
i2c_address=0x75,
|
||||
voltage_reg_low=0xA2,
|
||||
voltage_reg_high=0xA3,
|
||||
current_reg_low=0xA4,
|
||||
current_reg_high=0xA5,
|
||||
power_status_reg=0x55,
|
||||
power_plugged_mask=0x10, # Bit 4
|
||||
model=PiSugarModel.PISUGAR_2,
|
||||
)
|
||||
|
||||
# IP5312 chip used in PiSugar 3 series
|
||||
IP5312_CONFIG = ChipConfig(
|
||||
i2c_address=0x57,
|
||||
voltage_reg_low=0xD0,
|
||||
voltage_reg_high=0xD1,
|
||||
current_reg_low=0xD2,
|
||||
current_reg_high=0xD3,
|
||||
power_status_reg=0xDD,
|
||||
power_plugged_mask=0x1F, # Value 0x1F = plugged in
|
||||
model=PiSugarModel.PISUGAR_3,
|
||||
)
|
||||
|
||||
# Battery voltage to percentage lookup curve (voltage in mV -> percentage)
|
||||
# Based on typical LiPo discharge curve
|
||||
BATTERY_CURVE = [
|
||||
(4160, 100),
|
||||
(4050, 90),
|
||||
(3920, 80),
|
||||
(3800, 70),
|
||||
(3720, 60),
|
||||
(3650, 50),
|
||||
(3580, 40),
|
||||
(3520, 30),
|
||||
(3420, 20),
|
||||
(3300, 10),
|
||||
(3100, 0),
|
||||
]
|
||||
|
||||
|
||||
class PiSugarI2CDriver(UPSDriver):
|
||||
"""Native I2C driver for PiSugar UPS devices.
|
||||
|
||||
Reads directly from the IP5209/IP5312 battery management chip,
|
||||
eliminating the need for the pisugar-server daemon.
|
||||
"""
|
||||
|
||||
# Known I2C addresses for auto-detection
|
||||
KNOWN_ADDRESSES = {
|
||||
0x75: IP5209_CONFIG, # PiSugar 2 series
|
||||
0x57: IP5312_CONFIG, # PiSugar 3 series
|
||||
}
|
||||
|
||||
# RTC address (SD3078) - used for detection but not read
|
||||
RTC_ADDRESS = 0x32
|
||||
|
||||
@classmethod
|
||||
async def detect(cls, i2c_bus: int = 1) -> Tuple[bool, Optional["PiSugarI2CDriver"]]:
|
||||
"""Detect PiSugar hardware by probing I2C addresses.
|
||||
|
||||
Args:
|
||||
i2c_bus: I2C bus number (default: 1 for Raspberry Pi)
|
||||
|
||||
Returns:
|
||||
Tuple of (detected: bool, driver_instance or None)
|
||||
"""
|
||||
if SMBus is None:
|
||||
return (False, None)
|
||||
|
||||
try:
|
||||
bus = SMBus(i2c_bus)
|
||||
try:
|
||||
# Try each known address
|
||||
for addr, config in cls.KNOWN_ADDRESSES.items():
|
||||
try:
|
||||
# Try to read a byte from the address
|
||||
bus.read_byte(addr)
|
||||
|
||||
# Validate by reading voltage registers
|
||||
try:
|
||||
bus.read_byte_data(addr, config.voltage_reg_low)
|
||||
bus.read_byte_data(addr, config.voltage_reg_high)
|
||||
|
||||
# Success - create driver instance
|
||||
driver = cls(chip_config=config, i2c_bus=i2c_bus)
|
||||
return (True, driver)
|
||||
except OSError:
|
||||
# Address responded but not the expected chip
|
||||
continue
|
||||
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
finally:
|
||||
bus.close()
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return (False, None)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chip_config: ChipConfig = IP5209_CONFIG,
|
||||
i2c_bus: int = 1
|
||||
):
|
||||
"""Initialize PiSugar I2C driver.
|
||||
|
||||
Args:
|
||||
chip_config: Configuration for the specific chip
|
||||
i2c_bus: I2C bus number (default: 1)
|
||||
"""
|
||||
self._config = chip_config
|
||||
self._i2c_bus = i2c_bus
|
||||
self._bus: Optional[SMBus] = None
|
||||
self._available = False
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""Initialize I2C connection.
|
||||
|
||||
Returns:
|
||||
True if initialization successful
|
||||
"""
|
||||
if SMBus is None:
|
||||
print("smbus2 library not available for PiSugar I2C driver")
|
||||
return False
|
||||
|
||||
try:
|
||||
self._bus = SMBus(self._i2c_bus)
|
||||
|
||||
# Verify we can read from the chip
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.read_byte_data,
|
||||
self._config.i2c_address,
|
||||
self._config.voltage_reg_low
|
||||
)
|
||||
|
||||
self._available = True
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to initialize PiSugar I2C: {e}")
|
||||
self._available = False
|
||||
if self._bus:
|
||||
self._bus.close()
|
||||
self._bus = None
|
||||
return False
|
||||
|
||||
async def read_data(self) -> UPSData:
|
||||
"""Read battery data directly from I2C.
|
||||
|
||||
Returns:
|
||||
UPSData with current readings
|
||||
"""
|
||||
if not self._bus or not self._available:
|
||||
raise RuntimeError("PiSugar I2C not initialized")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Read voltage
|
||||
voltage = await self._read_voltage(loop)
|
||||
|
||||
# Read current
|
||||
current = await self._read_current(loop)
|
||||
|
||||
# Read power status
|
||||
is_charging = await self._read_power_status(loop)
|
||||
|
||||
# Convert voltage to percentage using lookup curve
|
||||
percentage = self._voltage_to_percentage(voltage)
|
||||
|
||||
return UPSData(
|
||||
percentage=percentage,
|
||||
voltage=voltage,
|
||||
current=current,
|
||||
is_charging=is_charging,
|
||||
temperature=None,
|
||||
time_remaining=None
|
||||
)
|
||||
|
||||
async def _read_voltage(self, loop) -> float:
|
||||
"""Read battery voltage in mV."""
|
||||
low = await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.read_byte_data,
|
||||
self._config.i2c_address,
|
||||
self._config.voltage_reg_low
|
||||
)
|
||||
high = await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.read_byte_data,
|
||||
self._config.i2c_address,
|
||||
self._config.voltage_reg_high
|
||||
)
|
||||
|
||||
if self._config.i2c_address == 0x75: # IP5209
|
||||
# Two's complement with sign bit at 0x20
|
||||
raw = ((high & 0x1F) << 8) | low
|
||||
if high & 0x20:
|
||||
voltage = 2600.0 - (raw * 0.26855)
|
||||
else:
|
||||
voltage = 2600.0 + (raw * 0.26855)
|
||||
else: # IP5312
|
||||
raw = ((high & 0x3F) << 8) | low
|
||||
voltage = (raw * 0.26855) + 2600.0
|
||||
|
||||
return voltage
|
||||
|
||||
async def _read_current(self, loop) -> float:
|
||||
"""Read battery current in Amps.
|
||||
|
||||
Returns:
|
||||
Current in Amps (positive = charging, negative = discharging)
|
||||
"""
|
||||
low = await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.read_byte_data,
|
||||
self._config.i2c_address,
|
||||
self._config.current_reg_low
|
||||
)
|
||||
high = await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.read_byte_data,
|
||||
self._config.i2c_address,
|
||||
self._config.current_reg_high
|
||||
)
|
||||
|
||||
if self._config.i2c_address == 0x75: # IP5209
|
||||
if high & 0x20:
|
||||
# Sign bit set = discharging = negative current
|
||||
# Sign extend for proper 2's complement
|
||||
raw_signed = (((high | 0xC0) << 8) | low)
|
||||
# Convert to signed 16-bit
|
||||
if raw_signed > 32767:
|
||||
raw_signed -= 65536
|
||||
current = raw_signed * 0.745985 / 1000.0 # Result in A
|
||||
else:
|
||||
# Sign bit clear = charging = positive current
|
||||
raw = ((high & 0x1F) << 8) | low
|
||||
current = raw * 0.745985 / 1000.0 # Result in A
|
||||
else: # IP5312
|
||||
raw = ((high & 0x1F) << 8) | low
|
||||
current = raw * 2.68554 / 1000.0 # Result in A
|
||||
if high & 0x20:
|
||||
current = -current # Sign bit = discharging
|
||||
|
||||
return current
|
||||
|
||||
async def _read_power_status(self, loop) -> bool:
|
||||
"""Read power plugged/charging status."""
|
||||
status = await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.read_byte_data,
|
||||
self._config.i2c_address,
|
||||
self._config.power_status_reg
|
||||
)
|
||||
|
||||
if self._config.i2c_address == 0x75: # IP5209
|
||||
return bool(status & self._config.power_plugged_mask)
|
||||
else: # IP5312
|
||||
# For IP5312, 0x1F means plugged in
|
||||
return status == self._config.power_plugged_mask
|
||||
|
||||
def _voltage_to_percentage(self, voltage_mv: float) -> float:
|
||||
"""Convert voltage to battery percentage using lookup curve."""
|
||||
if voltage_mv >= BATTERY_CURVE[0][0]:
|
||||
return 100.0
|
||||
if voltage_mv <= BATTERY_CURVE[-1][0]:
|
||||
return 0.0
|
||||
|
||||
# Linear interpolation between curve points
|
||||
for i in range(len(BATTERY_CURVE) - 1):
|
||||
v_high, p_high = BATTERY_CURVE[i]
|
||||
v_low, p_low = BATTERY_CURVE[i + 1]
|
||||
|
||||
if v_low <= voltage_mv <= v_high:
|
||||
# Interpolate
|
||||
ratio = (voltage_mv - v_low) / (v_high - v_low)
|
||||
return p_low + ratio * (p_high - p_low)
|
||||
|
||||
return 50.0 # Fallback
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
"""Check if hardware is available."""
|
||||
if not self._available:
|
||||
return await self.initialize()
|
||||
return True
|
||||
|
||||
def close(self):
|
||||
"""Close I2C bus."""
|
||||
if self._bus:
|
||||
self._bus.close()
|
||||
self._bus = None
|
||||
self._available = False
|
||||
|
||||
def get_model_name(self) -> str:
|
||||
"""Get detected model name."""
|
||||
return self._config.model.value
|
||||
|
||||
# ========== Button Detection ==========
|
||||
|
||||
async def read_button_state(self) -> bool:
|
||||
"""Read current button GPIO state.
|
||||
|
||||
Returns:
|
||||
True if button is pressed
|
||||
"""
|
||||
if not self._bus or not self._available:
|
||||
return False
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
status = await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.read_byte_data,
|
||||
self._config.i2c_address,
|
||||
0x55 # GPIO status register
|
||||
)
|
||||
|
||||
if self._config.i2c_address == 0x75: # IP5209
|
||||
# 4-LED models use GPIO4 (bit 4), 2-LED use GPIO1 (bit 1)
|
||||
# Default to 4-LED behavior
|
||||
return bool(status & 0x10)
|
||||
else: # IP5312
|
||||
return bool(status & 0x10)
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ========== RTC Functions (SD3078 at 0x32) ==========
|
||||
|
||||
async def get_rtc_time(self) -> Optional[datetime]:
|
||||
"""Read current time from RTC.
|
||||
|
||||
Returns:
|
||||
datetime object or None if RTC not available
|
||||
"""
|
||||
if not self._bus:
|
||||
return None
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
# Read 7 bytes starting from register 0x00
|
||||
data = await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.read_i2c_block_data,
|
||||
self.RTC_ADDRESS,
|
||||
0x00,
|
||||
7
|
||||
)
|
||||
|
||||
# Parse BCD format: sec, min, hour, weekday, day, month, year
|
||||
second = self._bcd_to_int(data[0] & 0x7F)
|
||||
minute = self._bcd_to_int(data[1] & 0x7F)
|
||||
hour = self._bcd_to_int(data[2] & 0x3F) # 24-hour format
|
||||
day = self._bcd_to_int(data[4] & 0x3F)
|
||||
month = self._bcd_to_int(data[5] & 0x1F)
|
||||
year = 2000 + self._bcd_to_int(data[6])
|
||||
|
||||
return datetime(year, month, day, hour, minute, second)
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def set_rtc_time(self, dt: datetime) -> bool:
|
||||
"""Set RTC time.
|
||||
|
||||
Args:
|
||||
dt: datetime to set
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
if not self._bus:
|
||||
return False
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
# Enable write mode (CTR2 register 0x10, set WRTC1/WRTC2/WRTC3)
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.write_byte_data,
|
||||
self.RTC_ADDRESS,
|
||||
0x10,
|
||||
0x80 # Enable write
|
||||
)
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.write_byte_data,
|
||||
self.RTC_ADDRESS,
|
||||
0x0F,
|
||||
0x84 # Enable write
|
||||
)
|
||||
|
||||
# Write time data in BCD format
|
||||
data = [
|
||||
self._int_to_bcd(dt.second),
|
||||
self._int_to_bcd(dt.minute),
|
||||
self._int_to_bcd(dt.hour) | 0x80, # 24-hour mode
|
||||
self._int_to_bcd(dt.weekday()),
|
||||
self._int_to_bcd(dt.day),
|
||||
self._int_to_bcd(dt.month),
|
||||
self._int_to_bcd(dt.year % 100)
|
||||
]
|
||||
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.write_i2c_block_data,
|
||||
self.RTC_ADDRESS,
|
||||
0x00,
|
||||
data
|
||||
)
|
||||
|
||||
# Disable write mode
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.write_byte_data,
|
||||
self.RTC_ADDRESS,
|
||||
0x0F,
|
||||
0x00
|
||||
)
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.write_byte_data,
|
||||
self.RTC_ADDRESS,
|
||||
0x10,
|
||||
0x00
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def set_wake_alarm(self, wake_time: datetime, repeat_weekdays: int = 0) -> bool:
|
||||
"""Set RTC alarm for scheduled wake-up.
|
||||
|
||||
Args:
|
||||
wake_time: Time to wake up
|
||||
repeat_weekdays: Bitmask for weekday repeat (0=one-time, 0x7F=daily)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
if not self._bus:
|
||||
return False
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
# Enable write mode
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.write_byte_data,
|
||||
self.RTC_ADDRESS,
|
||||
0x10,
|
||||
0x80
|
||||
)
|
||||
|
||||
# Write alarm time (registers 0x07-0x0D)
|
||||
alarm_data = [
|
||||
self._int_to_bcd(wake_time.second),
|
||||
self._int_to_bcd(wake_time.minute),
|
||||
self._int_to_bcd(wake_time.hour) | 0x80, # 24-hour mode
|
||||
repeat_weekdays, # Weekday repeat mask
|
||||
self._int_to_bcd(wake_time.day),
|
||||
self._int_to_bcd(wake_time.month),
|
||||
self._int_to_bcd(wake_time.year % 100)
|
||||
]
|
||||
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.write_i2c_block_data,
|
||||
self.RTC_ADDRESS,
|
||||
0x07,
|
||||
alarm_data
|
||||
)
|
||||
|
||||
# Enable alarm (CTR2 register 0x10, set INTAE bit)
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.write_byte_data,
|
||||
self.RTC_ADDRESS,
|
||||
0x0E,
|
||||
0x07 # Enable hour/minute/second alarm match
|
||||
)
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.write_byte_data,
|
||||
self.RTC_ADDRESS,
|
||||
0x10,
|
||||
0x04 # Enable alarm interrupt
|
||||
)
|
||||
|
||||
# Set frequency for auto power-on
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.write_byte_data,
|
||||
self.RTC_ADDRESS,
|
||||
0x11,
|
||||
0x01 # 1/2Hz for auto power-on
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def clear_wake_alarm(self) -> bool:
|
||||
"""Clear/disable wake alarm.
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
if not self._bus:
|
||||
return False
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
# Disable alarm interrupt
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.write_byte_data,
|
||||
self.RTC_ADDRESS,
|
||||
0x10,
|
||||
0x00
|
||||
)
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.write_byte_data,
|
||||
self.RTC_ADDRESS,
|
||||
0x0E,
|
||||
0x00
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ========== Power Control ==========
|
||||
|
||||
async def force_shutdown(self) -> bool:
|
||||
"""Force immediate shutdown of the Pi.
|
||||
|
||||
This enables light-load auto-shutdown on the IP5209/IP5312
|
||||
which will cut power when the Pi draws minimal current.
|
||||
|
||||
Returns:
|
||||
True if command was sent
|
||||
"""
|
||||
if not self._bus or not self._available:
|
||||
return False
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
if self._config.i2c_address == 0x75: # IP5209
|
||||
# Enable light-load auto-shutdown and force shutdown
|
||||
# Register 0x01, set appropriate bits
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.write_byte_data,
|
||||
self._config.i2c_address,
|
||||
0x01,
|
||||
0x29 # Enable auto-shutdown
|
||||
)
|
||||
else: # IP5312
|
||||
# IP5312 has different shutdown mechanism
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.write_byte_data,
|
||||
self._config.i2c_address,
|
||||
0x03,
|
||||
0x08 # Force shutdown
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ========== Helpers ==========
|
||||
|
||||
def _bcd_to_int(self, bcd: int) -> int:
|
||||
"""Convert BCD byte to integer."""
|
||||
return (bcd >> 4) * 10 + (bcd & 0x0F)
|
||||
|
||||
def _int_to_bcd(self, value: int) -> int:
|
||||
"""Convert integer to BCD byte."""
|
||||
return ((value // 10) << 4) | (value % 10)
|
||||
@@ -0,0 +1,483 @@
|
||||
"""UPS Manager for Dangerous Pi.
|
||||
|
||||
Handles battery monitoring, safe shutdown triggers,
|
||||
and battery percentage reporting for various UPS HAT devices.
|
||||
|
||||
Supports multiple UPS types via driver architecture:
|
||||
- PiSugar (PiSugar 2/3 series via daemon)
|
||||
- I2C Fuel Gauge (MAX17040/MAX17048)
|
||||
"""
|
||||
import asyncio
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from .. import config
|
||||
from .ups_drivers import UPSDriver, PiSugarDriver, I2CFuelGaugeDriver, auto_detect_driver
|
||||
|
||||
|
||||
class BatteryStatus(str, Enum):
|
||||
"""Battery status enum."""
|
||||
CHARGING = "charging"
|
||||
DISCHARGING = "discharging"
|
||||
FULL = "full"
|
||||
CRITICAL = "critical"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class PowerSource(str, Enum):
|
||||
"""Power source enum."""
|
||||
AC = "ac"
|
||||
BATTERY = "battery"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class UPSStatus:
|
||||
"""UPS status information."""
|
||||
battery_percentage: float
|
||||
voltage: float
|
||||
current: float
|
||||
power_source: PowerSource
|
||||
battery_status: BatteryStatus
|
||||
time_remaining: Optional[int] = None # Minutes remaining on battery
|
||||
temperature: Optional[float] = None
|
||||
last_updated: Optional[str] = None
|
||||
is_available: bool = True
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
class UPSManager:
|
||||
"""Manages UPS battery monitoring and safe shutdown."""
|
||||
|
||||
def __init__(self, driver: Optional[UPSDriver] = None):
|
||||
"""Initialize the UPS manager.
|
||||
|
||||
Args:
|
||||
driver: UPS driver instance. If None, creates driver based on config.
|
||||
"""
|
||||
self._check_interval = config.UPS_CHECK_INTERVAL
|
||||
self._driver = driver or self._create_driver()
|
||||
self._status = UPSStatus(
|
||||
battery_percentage=0.0,
|
||||
voltage=0.0,
|
||||
current=0.0,
|
||||
power_source=PowerSource.UNKNOWN,
|
||||
battery_status=BatteryStatus.UNKNOWN,
|
||||
is_available=False
|
||||
)
|
||||
self._shutdown_threshold = 10.0 # Shutdown at 10% battery
|
||||
self._warning_threshold = 20.0 # Warning at 20% battery
|
||||
self._critical_threshold = 15.0 # Critical at 15% battery
|
||||
self._battery_capacity_mah = 1200 # Default for PiSugar 2 (1200mAh)
|
||||
self._shutdown_initiated = False
|
||||
self._config_warning_sent = False # Only send config warning once
|
||||
self._event_callbacks = []
|
||||
|
||||
def _create_driver(self) -> Optional[UPSDriver]:
|
||||
"""Create UPS driver based on configuration.
|
||||
|
||||
Returns:
|
||||
Appropriate UPS driver instance, or None for auto/none types
|
||||
"""
|
||||
ups_type = config.UPS_TYPE.lower()
|
||||
|
||||
if ups_type == "pisugar":
|
||||
return PiSugarDriver(
|
||||
host=config.UPS_PISUGAR_HOST,
|
||||
port=config.UPS_PISUGAR_PORT
|
||||
)
|
||||
elif ups_type == "i2c":
|
||||
i2c_address = int(config.UPS_I2C_ADDRESS, 16)
|
||||
return I2CFuelGaugeDriver(i2c_address=i2c_address)
|
||||
elif ups_type == "auto":
|
||||
# Auto-detection happens in initialize()
|
||||
return None
|
||||
elif ups_type == "none":
|
||||
# No UPS configured
|
||||
return None
|
||||
else:
|
||||
# Default to auto-detection for unknown types
|
||||
print(f"Unknown UPS type '{ups_type}', will auto-detect")
|
||||
return None
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""Initialize connection to UPS hardware.
|
||||
|
||||
Returns:
|
||||
True if initialization successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# If no driver set, try auto-detection (for "auto" or unknown types)
|
||||
if self._driver is None:
|
||||
ups_type = config.UPS_TYPE.lower()
|
||||
|
||||
if ups_type == "none":
|
||||
# Explicitly disabled
|
||||
print("UPS disabled by configuration (UPS_TYPE=none)")
|
||||
self._status.is_available = False
|
||||
self._status.error_message = "UPS disabled"
|
||||
return False
|
||||
|
||||
# Run auto-detection
|
||||
print("Auto-detecting UPS hardware...")
|
||||
driver, message = await auto_detect_driver(
|
||||
pisugar_host=config.UPS_PISUGAR_HOST,
|
||||
pisugar_port=config.UPS_PISUGAR_PORT
|
||||
)
|
||||
|
||||
if driver:
|
||||
self._driver = driver
|
||||
print(message)
|
||||
else:
|
||||
print(message)
|
||||
self._status.is_available = False
|
||||
self._status.error_message = message
|
||||
return False
|
||||
|
||||
# Initialize the driver
|
||||
success = await self._driver.initialize()
|
||||
|
||||
if success:
|
||||
self._status.is_available = True
|
||||
self._status.error_message = None
|
||||
print(f"UPS initialized: {self._driver.get_model_name()}")
|
||||
else:
|
||||
self._status.is_available = False
|
||||
self._status.error_message = f"Failed to initialize {self._driver.get_model_name()}"
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
self._status.is_available = False
|
||||
self._status.error_message = f"Failed to initialize UPS: {e}"
|
||||
return False
|
||||
|
||||
async def start_monitoring(self):
|
||||
"""Start periodic battery monitoring in background."""
|
||||
if not await self.initialize():
|
||||
print(f"UPS not available: {self._status.error_message}")
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
await self._update_battery_status()
|
||||
await self._check_battery_thresholds()
|
||||
await asyncio.sleep(self._check_interval)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Error in UPS monitoring: {e}")
|
||||
self._status.error_message = str(e)
|
||||
await asyncio.sleep(self._check_interval)
|
||||
|
||||
async def get_status(self) -> UPSStatus:
|
||||
"""Get current UPS status.
|
||||
|
||||
Returns:
|
||||
UPSStatus object with current battery information
|
||||
"""
|
||||
if self._status.is_available:
|
||||
await self._update_battery_status()
|
||||
return self._status
|
||||
|
||||
def get_power_restrictions(self) -> Dict[str, Any]:
|
||||
"""Get current power restrictions based on hardware state.
|
||||
|
||||
Returns:
|
||||
Dict with restriction info including:
|
||||
- restricted: bool - Whether operations are restricted
|
||||
- reason: Optional[str] - Why operations are restricted
|
||||
- power_source: str - Current power source
|
||||
- ups_available: bool - Whether UPS hardware is present
|
||||
- battery_percentage: Optional[float] - Current battery level
|
||||
- allow_firmware_flash: bool - Can flash firmware (fullimage)
|
||||
- allow_bootloader_flash: bool - Can flash bootloader
|
||||
- allow_intensive_operations: bool - Can run intensive ops
|
||||
- message: Optional[str] - User-friendly message
|
||||
- warning: Optional[str] - Warning message
|
||||
- shutdown_imminent: Optional[bool] - Shutdown about to happen
|
||||
"""
|
||||
# UPS not available = assume AC power, no restrictions
|
||||
if not self._status.is_available:
|
||||
return {
|
||||
"restricted": False,
|
||||
"reason": None,
|
||||
"power_source": "assumed_ac",
|
||||
"ups_available": False,
|
||||
"battery_percentage": None,
|
||||
"allow_firmware_flash": True,
|
||||
"allow_bootloader_flash": True,
|
||||
"allow_intensive_operations": True,
|
||||
"message": "UPS not detected. Assuming stable AC power. Ensure power stability before firmware operations."
|
||||
}
|
||||
|
||||
# UPS available - check power source and battery state
|
||||
if self._status.power_source == PowerSource.AC:
|
||||
return {
|
||||
"restricted": False,
|
||||
"reason": None,
|
||||
"power_source": "ac",
|
||||
"ups_available": True,
|
||||
"battery_percentage": self._status.battery_percentage,
|
||||
"allow_firmware_flash": True,
|
||||
"allow_bootloader_flash": True,
|
||||
"allow_intensive_operations": True,
|
||||
"message": "On AC power. All operations allowed."
|
||||
}
|
||||
|
||||
# On battery power - apply restrictions based on battery level
|
||||
battery_pct = self._status.battery_percentage
|
||||
|
||||
if battery_pct >= 80:
|
||||
return {
|
||||
"restricted": False,
|
||||
"reason": None,
|
||||
"power_source": "battery",
|
||||
"ups_available": True,
|
||||
"battery_percentage": battery_pct,
|
||||
"allow_firmware_flash": True,
|
||||
"allow_bootloader_flash": True,
|
||||
"allow_intensive_operations": True,
|
||||
"warning": "On battery power. Bootloader flashing not recommended below 80%."
|
||||
}
|
||||
elif battery_pct >= 50:
|
||||
return {
|
||||
"restricted": True,
|
||||
"reason": "Battery level below 80%",
|
||||
"power_source": "battery",
|
||||
"ups_available": True,
|
||||
"battery_percentage": battery_pct,
|
||||
"allow_firmware_flash": True, # Fullimage only
|
||||
"allow_bootloader_flash": False,
|
||||
"allow_intensive_operations": True,
|
||||
"message": "Bootloader flashing disabled. Battery too low (minimum 80% required)."
|
||||
}
|
||||
elif battery_pct >= 20:
|
||||
return {
|
||||
"restricted": True,
|
||||
"reason": "Battery level below 50%",
|
||||
"power_source": "battery",
|
||||
"ups_available": True,
|
||||
"battery_percentage": battery_pct,
|
||||
"allow_firmware_flash": False,
|
||||
"allow_bootloader_flash": False,
|
||||
"allow_intensive_operations": True,
|
||||
"message": "Firmware flashing disabled. Battery too low (minimum 50% required)."
|
||||
}
|
||||
elif battery_pct >= 10:
|
||||
return {
|
||||
"restricted": True,
|
||||
"reason": "Battery critically low",
|
||||
"power_source": "battery",
|
||||
"ups_available": True,
|
||||
"battery_percentage": battery_pct,
|
||||
"allow_firmware_flash": False,
|
||||
"allow_bootloader_flash": False,
|
||||
"allow_intensive_operations": False,
|
||||
"message": "Critical battery level. Read-only mode active."
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"restricted": True,
|
||||
"reason": "Battery emergency level",
|
||||
"power_source": "battery",
|
||||
"ups_available": True,
|
||||
"battery_percentage": battery_pct,
|
||||
"allow_firmware_flash": False,
|
||||
"allow_bootloader_flash": False,
|
||||
"allow_intensive_operations": False,
|
||||
"message": "Emergency battery level. System will shutdown soon.",
|
||||
"shutdown_imminent": True
|
||||
}
|
||||
|
||||
async def shutdown_device(self, delay: int = 30):
|
||||
"""Initiate safe shutdown of the device.
|
||||
|
||||
Args:
|
||||
delay: Delay in seconds before shutdown (default: 30)
|
||||
"""
|
||||
if self._shutdown_initiated:
|
||||
return
|
||||
|
||||
self._shutdown_initiated = True
|
||||
|
||||
# Notify all registered callbacks
|
||||
await self._trigger_event("shutdown_initiated", {
|
||||
"delay": delay,
|
||||
"battery_percentage": self._status.battery_percentage
|
||||
})
|
||||
|
||||
# Wait for the delay
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
# Execute shutdown command
|
||||
try:
|
||||
subprocess.run(["sudo", "shutdown", "-h", "now"], check=True)
|
||||
except Exception as e:
|
||||
print(f"Failed to shutdown: {e}")
|
||||
self._shutdown_initiated = False
|
||||
|
||||
def register_event_callback(self, callback):
|
||||
"""Register a callback for UPS events.
|
||||
|
||||
Args:
|
||||
callback: Async function to call on events
|
||||
"""
|
||||
self._event_callbacks.append(callback)
|
||||
|
||||
async def _update_battery_status(self):
|
||||
"""Update battery status from UPS hardware."""
|
||||
if not self._status.is_available:
|
||||
return
|
||||
|
||||
try:
|
||||
# Read data from driver
|
||||
data = await self._driver.read_data()
|
||||
|
||||
# Check for misconfigured UPS (all values are zero) - only notify once
|
||||
if data.percentage == 0.0 and data.voltage == 0.0 and data.current == 0.0:
|
||||
if not self._config_warning_sent:
|
||||
self._config_warning_sent = True
|
||||
model_name = self._driver.get_model_name() if self._driver else "Unknown"
|
||||
await self._trigger_event("ups_config_required", {
|
||||
"model": model_name,
|
||||
"message": f"UPS '{model_name}' detected but returning no data. May need reconfiguration.",
|
||||
"hint": "Run 'sudo dpkg-reconfigure pisugar-server' to select the correct PiSugar model."
|
||||
})
|
||||
else:
|
||||
# Reset flag when we get valid data
|
||||
self._config_warning_sent = False
|
||||
|
||||
# Update status with new data
|
||||
self._status.battery_percentage = data.percentage
|
||||
self._status.voltage = data.voltage
|
||||
self._status.current = data.current
|
||||
self._status.temperature = data.temperature
|
||||
|
||||
# Calculate time_remaining if not provided by driver
|
||||
if data.time_remaining is not None:
|
||||
self._status.time_remaining = data.time_remaining
|
||||
elif data.current < 0 and data.percentage > 0:
|
||||
# Discharging - estimate time remaining
|
||||
# current is in Amps (negative when discharging)
|
||||
draw_ma = abs(data.current * 1000)
|
||||
if draw_ma > 10: # Only calculate if meaningful draw
|
||||
remaining_mah = self._battery_capacity_mah * (data.percentage / 100)
|
||||
hours_remaining = remaining_mah / draw_ma
|
||||
self._status.time_remaining = int(hours_remaining * 60) # Minutes
|
||||
else:
|
||||
self._status.time_remaining = None
|
||||
else:
|
||||
self._status.time_remaining = None
|
||||
|
||||
# Determine power source and battery status from driver data
|
||||
if data.is_charging:
|
||||
self._status.power_source = PowerSource.AC
|
||||
if data.percentage >= 99.0:
|
||||
self._status.battery_status = BatteryStatus.FULL
|
||||
else:
|
||||
self._status.battery_status = BatteryStatus.CHARGING
|
||||
else:
|
||||
self._status.power_source = PowerSource.BATTERY
|
||||
if data.percentage < self._critical_threshold:
|
||||
self._status.battery_status = BatteryStatus.CRITICAL
|
||||
else:
|
||||
self._status.battery_status = BatteryStatus.DISCHARGING
|
||||
|
||||
self._status.last_updated = datetime.utcnow().isoformat()
|
||||
self._status.error_message = None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error reading battery data: {e}")
|
||||
self._status.error_message = str(e)
|
||||
|
||||
|
||||
async def _check_battery_thresholds(self):
|
||||
"""Check battery levels and trigger actions."""
|
||||
percentage = self._status.battery_percentage
|
||||
power_source = self._status.power_source
|
||||
|
||||
# Only check thresholds when on battery power
|
||||
if power_source != PowerSource.BATTERY:
|
||||
self._shutdown_initiated = False
|
||||
return
|
||||
|
||||
# Critical threshold - initiate shutdown
|
||||
if percentage <= self._shutdown_threshold and not self._shutdown_initiated:
|
||||
await self._trigger_event("battery_critical", {
|
||||
"percentage": percentage,
|
||||
"action": "shutdown_initiated"
|
||||
})
|
||||
await self.shutdown_device(delay=60) # 60 second warning
|
||||
|
||||
# Warning threshold
|
||||
elif percentage <= self._warning_threshold:
|
||||
await self._trigger_event("battery_warning", {
|
||||
"percentage": percentage,
|
||||
"threshold": self._warning_threshold
|
||||
})
|
||||
|
||||
# Critical threshold (but not shutdown yet)
|
||||
elif percentage <= self._critical_threshold:
|
||||
await self._trigger_event("battery_low", {
|
||||
"percentage": percentage,
|
||||
"threshold": self._critical_threshold
|
||||
})
|
||||
|
||||
async def _trigger_event(self, event_type: str, data: Dict[str, Any]):
|
||||
"""Trigger event callbacks.
|
||||
|
||||
Args:
|
||||
event_type: Type of event (e.g., "battery_warning")
|
||||
data: Event data
|
||||
"""
|
||||
event = {
|
||||
"type": event_type,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"data": data
|
||||
}
|
||||
|
||||
# Call all registered callbacks
|
||||
for callback in self._event_callbacks:
|
||||
try:
|
||||
await callback(event)
|
||||
except Exception as e:
|
||||
print(f"Error in event callback: {e}")
|
||||
|
||||
def set_shutdown_threshold(self, threshold: float):
|
||||
"""Set battery percentage threshold for automatic shutdown.
|
||||
|
||||
Args:
|
||||
threshold: Battery percentage (0-100)
|
||||
"""
|
||||
if 0 <= threshold <= 100:
|
||||
self._shutdown_threshold = threshold
|
||||
|
||||
def set_warning_threshold(self, threshold: float):
|
||||
"""Set battery percentage threshold for warnings.
|
||||
|
||||
Args:
|
||||
threshold: Battery percentage (0-100)
|
||||
"""
|
||||
if 0 <= threshold <= 100:
|
||||
self._warning_threshold = threshold
|
||||
|
||||
def close(self):
|
||||
"""Close UPS driver connection."""
|
||||
if self._driver:
|
||||
self._driver.close()
|
||||
|
||||
|
||||
# Global UPS manager instance
|
||||
_ups_manager: Optional[UPSManager] = None
|
||||
|
||||
|
||||
def get_ups_manager() -> UPSManager:
|
||||
"""Get the global UPS manager instance."""
|
||||
global _ups_manager
|
||||
if _ups_manager is None:
|
||||
_ups_manager = UPSManager()
|
||||
return _ups_manager
|
||||
@@ -0,0 +1,893 @@
|
||||
"""WiFi manager for network configuration and mode switching."""
|
||||
import asyncio
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Dict
|
||||
from pathlib import Path
|
||||
|
||||
from .. import config
|
||||
|
||||
|
||||
class WiFiMode(str, Enum):
|
||||
"""WiFi operation modes."""
|
||||
AP = "ap" # Access Point only
|
||||
CLIENT = "client" # Client mode only
|
||||
DUAL = "dual" # AP + Client (requires USB WiFi)
|
||||
AUTO = "auto" # Automatically choose best mode
|
||||
OFF = "off" # WiFi disabled
|
||||
|
||||
|
||||
@dataclass
|
||||
class WiFiInterface:
|
||||
"""WiFi interface information."""
|
||||
name: str
|
||||
mac: str
|
||||
is_usb: bool
|
||||
is_up: bool
|
||||
connected: bool
|
||||
ssid: Optional[str] = None
|
||||
ip_address: Optional[str] = None
|
||||
mode: str = "managed" # "AP" or "managed" (client)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WiFiNetwork:
|
||||
"""Available WiFi network."""
|
||||
ssid: str
|
||||
bssid: str
|
||||
signal_strength: int
|
||||
frequency: int
|
||||
encrypted: bool
|
||||
in_use: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class WiFiStatus:
|
||||
"""Current WiFi status."""
|
||||
mode: WiFiMode
|
||||
interfaces: List[WiFiInterface]
|
||||
current_ssid: Optional[str]
|
||||
current_ip: Optional[str]
|
||||
ap_ssid: Optional[str]
|
||||
ap_ip: Optional[str]
|
||||
supports_dual: bool
|
||||
|
||||
|
||||
class WiFiManager:
|
||||
"""Manages WiFi configuration and mode switching."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize WiFi manager."""
|
||||
self._current_mode = WiFiMode.AUTO
|
||||
self._interfaces: List[WiFiInterface] = []
|
||||
self._supports_dual = False
|
||||
|
||||
async def detect_interfaces(self) -> List[WiFiInterface]:
|
||||
"""Detect available WiFi interfaces.
|
||||
|
||||
Returns:
|
||||
List of WiFi interfaces found
|
||||
"""
|
||||
interfaces = []
|
||||
|
||||
try:
|
||||
# Get all wireless interfaces using iw
|
||||
result = await self._run_command("iw dev")
|
||||
|
||||
if not result:
|
||||
return interfaces
|
||||
|
||||
# Parse iw dev output
|
||||
# Example output:
|
||||
# phy#0
|
||||
# Interface wlan0
|
||||
# ifindex 2
|
||||
# wdev 0x1
|
||||
# addr 2c:cf:67:87:db:13
|
||||
# ssid Dangerous-Pi
|
||||
# type AP
|
||||
# channel 6 (2437 MHz), width: 20 MHz
|
||||
current_interface = None
|
||||
for line in result.split('\n'):
|
||||
line = line.strip()
|
||||
|
||||
if line.startswith('Interface '):
|
||||
if current_interface:
|
||||
interfaces.append(current_interface)
|
||||
|
||||
iface_name = line.split()[1]
|
||||
current_interface = {
|
||||
'name': iface_name,
|
||||
'mac': '',
|
||||
'is_usb': self._is_usb_interface(iface_name),
|
||||
'is_up': False,
|
||||
'connected': False,
|
||||
'ssid': None,
|
||||
'ip_address': None,
|
||||
'mode': 'managed' # Default to managed (client) mode
|
||||
}
|
||||
|
||||
elif current_interface and line.startswith('addr '):
|
||||
current_interface['mac'] = line.split()[1]
|
||||
|
||||
elif current_interface and line.startswith('ssid '):
|
||||
current_interface['ssid'] = ' '.join(line.split()[1:])
|
||||
|
||||
elif current_interface and line.startswith('type '):
|
||||
# Parse interface type: "AP" or "managed"
|
||||
iface_type = line.split()[1]
|
||||
current_interface['mode'] = iface_type
|
||||
# Only mark as "connected" if in managed (client) mode with SSID
|
||||
if iface_type == 'managed' and current_interface.get('ssid'):
|
||||
current_interface['connected'] = True
|
||||
|
||||
if current_interface:
|
||||
interfaces.append(current_interface)
|
||||
|
||||
# Get interface status and IP addresses
|
||||
for iface_dict in interfaces:
|
||||
# Check if interface is up
|
||||
iface_dict['is_up'] = await self._is_interface_up(iface_dict['name'])
|
||||
|
||||
# Get IP address if interface is up
|
||||
if iface_dict['is_up']:
|
||||
iface_dict['ip_address'] = await self._get_interface_ip(iface_dict['name'])
|
||||
|
||||
# For AP mode, mark connected if we have IP (AP is "active")
|
||||
if iface_dict['mode'] == 'AP' and iface_dict['ip_address']:
|
||||
iface_dict['connected'] = True
|
||||
|
||||
# Convert dicts to WiFiInterface objects
|
||||
self._interfaces = [WiFiInterface(**iface) for iface in interfaces]
|
||||
|
||||
# Check if dual mode is supported (requires at least 2 interfaces)
|
||||
self._supports_dual = len(self._interfaces) >= 2
|
||||
|
||||
return self._interfaces
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error detecting WiFi interfaces: {e}")
|
||||
return []
|
||||
|
||||
def _is_usb_interface(self, iface_name: str) -> bool:
|
||||
"""Check if interface is USB-based.
|
||||
|
||||
Args:
|
||||
iface_name: Interface name (e.g., wlan0)
|
||||
|
||||
Returns:
|
||||
True if USB interface, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Check if device is USB by looking at sysfs
|
||||
device_path = Path(f"/sys/class/net/{iface_name}/device")
|
||||
if not device_path.exists():
|
||||
return False
|
||||
|
||||
# Read uevent to check for USB
|
||||
uevent_path = device_path / "uevent"
|
||||
if uevent_path.exists():
|
||||
content = uevent_path.read_text()
|
||||
return "usb" in content.lower()
|
||||
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _is_interface_up(self, iface_name: str) -> bool:
|
||||
"""Check if interface is up.
|
||||
|
||||
Args:
|
||||
iface_name: Interface name
|
||||
|
||||
Returns:
|
||||
True if interface is up
|
||||
"""
|
||||
try:
|
||||
result = await self._run_command(f"ip link show {iface_name}")
|
||||
return "UP" in result
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _get_interface_ip(self, iface_name: str) -> Optional[str]:
|
||||
"""Get IP address of interface.
|
||||
|
||||
Args:
|
||||
iface_name: Interface name
|
||||
|
||||
Returns:
|
||||
IP address or None
|
||||
"""
|
||||
try:
|
||||
result = await self._run_command(f"ip -4 addr show {iface_name}")
|
||||
match = re.search(r'inet (\d+\.\d+\.\d+\.\d+)', result)
|
||||
return match.group(1) if match else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def get_status(self) -> WiFiStatus:
|
||||
"""Get current WiFi status.
|
||||
|
||||
Returns:
|
||||
WiFiStatus object with current state
|
||||
"""
|
||||
await self.detect_interfaces()
|
||||
|
||||
# Determine current mode
|
||||
current_mode = await self._detect_current_mode()
|
||||
|
||||
# Find client and AP interfaces
|
||||
client_ssid = None
|
||||
client_ip = None
|
||||
ap_ssid = None
|
||||
ap_ip = None
|
||||
|
||||
for iface in self._interfaces:
|
||||
# Check for AP mode interface
|
||||
if iface.mode == 'AP':
|
||||
ap_ip = iface.ip_address
|
||||
ap_ssid = iface.ssid # SSID from iw dev output
|
||||
# If no SSID from iw, try hostapd config
|
||||
if not ap_ssid:
|
||||
ap_ssid = await self._get_ap_ssid()
|
||||
|
||||
# Check for client mode interface that's connected
|
||||
elif iface.mode == 'managed' and iface.connected and iface.ssid:
|
||||
client_ssid = iface.ssid
|
||||
client_ip = iface.ip_address
|
||||
|
||||
# Fallback: try to get AP SSID from hostapd if we detected AP mode
|
||||
if current_mode == WiFiMode.AP and not ap_ssid:
|
||||
ap_ssid = await self._get_ap_ssid()
|
||||
|
||||
return WiFiStatus(
|
||||
mode=current_mode,
|
||||
interfaces=self._interfaces,
|
||||
current_ssid=client_ssid,
|
||||
current_ip=client_ip,
|
||||
ap_ssid=ap_ssid or "Dangerous-Pi", # Default
|
||||
ap_ip=ap_ip,
|
||||
supports_dual=self._supports_dual
|
||||
)
|
||||
|
||||
async def _detect_current_mode(self) -> WiFiMode:
|
||||
"""Detect current WiFi mode based on interface types.
|
||||
|
||||
Returns:
|
||||
Current WiFi mode
|
||||
"""
|
||||
if not self._interfaces:
|
||||
return WiFiMode.OFF
|
||||
|
||||
# Check interface modes from iw dev output
|
||||
has_ap = any(iface.mode == 'AP' for iface in self._interfaces)
|
||||
has_client = any(
|
||||
iface.mode == 'managed' and iface.connected
|
||||
for iface in self._interfaces
|
||||
)
|
||||
|
||||
# Also check if hostapd is running as backup detection
|
||||
if not has_ap:
|
||||
has_ap = await self._is_hostapd_running()
|
||||
|
||||
if has_ap and has_client:
|
||||
return WiFiMode.DUAL
|
||||
elif has_ap:
|
||||
return WiFiMode.AP
|
||||
elif has_client:
|
||||
return WiFiMode.CLIENT
|
||||
elif any(iface.is_up for iface in self._interfaces):
|
||||
return WiFiMode.AUTO # Interface up but not in AP or connected
|
||||
else:
|
||||
return WiFiMode.OFF
|
||||
|
||||
async def _is_hostapd_running(self) -> bool:
|
||||
"""Check if hostapd service is running.
|
||||
|
||||
Returns:
|
||||
True if hostapd is active
|
||||
"""
|
||||
try:
|
||||
result = await self._run_command(
|
||||
"systemctl is-active hostapd",
|
||||
check=False
|
||||
)
|
||||
return result.strip() == "active"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _get_ap_ssid(self) -> Optional[str]:
|
||||
"""Get AP SSID from hostapd config.
|
||||
|
||||
Returns:
|
||||
AP SSID or None
|
||||
"""
|
||||
try:
|
||||
config_path = Path("/etc/hostapd/hostapd.conf")
|
||||
if config_path.exists():
|
||||
content = config_path.read_text()
|
||||
match = re.search(r'ssid=(.+)', content)
|
||||
return match.group(1) if match else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def scan_networks(self, interface: Optional[str] = None) -> List[WiFiNetwork]:
|
||||
"""Scan for available WiFi networks.
|
||||
|
||||
Args:
|
||||
interface: Interface to scan with (default: first available)
|
||||
|
||||
Returns:
|
||||
List of available networks
|
||||
"""
|
||||
# Ensure interfaces are detected
|
||||
if not self._interfaces:
|
||||
await self.detect_interfaces()
|
||||
|
||||
if not interface:
|
||||
# Use first non-USB interface, or first available
|
||||
for iface in self._interfaces:
|
||||
if not iface.is_usb:
|
||||
interface = iface.name
|
||||
break
|
||||
|
||||
if not interface and self._interfaces:
|
||||
interface = self._interfaces[0].name
|
||||
|
||||
if not interface:
|
||||
return []
|
||||
|
||||
try:
|
||||
# Request scan
|
||||
# Note: Requires CAP_NET_ADMIN capability (set via systemd service)
|
||||
await self._run_command(f"iw dev {interface} scan trigger", check=False)
|
||||
|
||||
# Wait for scan to complete
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Get scan results
|
||||
result = await self._run_command(f"iw dev {interface} scan")
|
||||
|
||||
networks = []
|
||||
current_network = None
|
||||
|
||||
for line in result.split('\n'):
|
||||
line = line.strip()
|
||||
|
||||
if line.startswith('BSS ') and line.split()[1].count(':') >= 5:
|
||||
# Match BSS lines with MAC addresses (xx:xx:xx:xx:xx:xx), not "BSS Load:" etc.
|
||||
if current_network:
|
||||
networks.append(current_network)
|
||||
|
||||
# Handle format: BSS aa:bb:cc:dd:ee:ff(on wlan0)
|
||||
bssid = line.split()[1].split('(')[0]
|
||||
current_network = {
|
||||
'ssid': '',
|
||||
'bssid': bssid,
|
||||
'signal_strength': 0,
|
||||
'frequency': 0,
|
||||
'encrypted': False,
|
||||
'in_use': False
|
||||
}
|
||||
|
||||
elif current_network:
|
||||
if line.startswith('SSID: '):
|
||||
current_network['ssid'] = line[6:]
|
||||
elif line.startswith('freq: '):
|
||||
# freq can be "2437" or "2437.0" depending on iw version
|
||||
current_network['frequency'] = int(float(line[6:]))
|
||||
elif line.startswith('signal: '):
|
||||
# Parse signal strength (e.g., "-50.00 dBm")
|
||||
signal = line[8:].split()[0]
|
||||
current_network['signal_strength'] = int(float(signal))
|
||||
elif 'RSN:' in line or 'WPA:' in line:
|
||||
current_network['encrypted'] = True
|
||||
|
||||
if current_network:
|
||||
networks.append(current_network)
|
||||
|
||||
# Convert to WiFiNetwork objects and filter out empty SSIDs
|
||||
return [
|
||||
WiFiNetwork(**net)
|
||||
for net in networks
|
||||
if net['ssid']
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error scanning networks: {e}")
|
||||
return []
|
||||
|
||||
async def set_mode(self, mode: WiFiMode) -> bool:
|
||||
"""Set WiFi mode.
|
||||
|
||||
Args:
|
||||
mode: Desired WiFi mode
|
||||
|
||||
Returns:
|
||||
True if mode was set successfully
|
||||
"""
|
||||
if mode == WiFiMode.DUAL and not self._supports_dual:
|
||||
raise ValueError("Dual mode requires USB WiFi adapter")
|
||||
|
||||
try:
|
||||
if mode == WiFiMode.AP:
|
||||
await self._enable_ap_mode()
|
||||
elif mode == WiFiMode.CLIENT:
|
||||
await self._enable_client_mode()
|
||||
elif mode == WiFiMode.DUAL:
|
||||
await self._enable_dual_mode()
|
||||
elif mode == WiFiMode.OFF:
|
||||
await self._disable_wifi()
|
||||
elif mode == WiFiMode.AUTO:
|
||||
await self._enable_auto_mode()
|
||||
|
||||
self._current_mode = mode
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error setting WiFi mode: {e}")
|
||||
return False
|
||||
|
||||
async def _systemctl(self, action: str, service: str):
|
||||
"""Control systemd service via dbus (no sudo required with polkit rule).
|
||||
|
||||
Args:
|
||||
action: "start", "stop", or "restart"
|
||||
service: Service name (e.g., "hostapd.service")
|
||||
"""
|
||||
if not service.endswith(".service"):
|
||||
service = f"{service}.service"
|
||||
|
||||
method = {
|
||||
"start": "StartUnit",
|
||||
"stop": "StopUnit",
|
||||
"restart": "RestartUnit"
|
||||
}.get(action, "StartUnit")
|
||||
|
||||
cmd = (
|
||||
f'busctl call org.freedesktop.systemd1 '
|
||||
f'/org/freedesktop/systemd1 org.freedesktop.systemd1.Manager '
|
||||
f'{method} ss "{service}" "replace"'
|
||||
)
|
||||
await self._run_command(cmd, check=False)
|
||||
|
||||
async def _enable_nm_management(self):
|
||||
"""Enable NetworkManager to manage wlan0 for client mode.
|
||||
|
||||
Moves the unmanaged-devices config to allow NM to manage wlan0.
|
||||
Requires dt user to own /etc/NetworkManager/conf.d/99-dangerous-pi-ap.conf
|
||||
"""
|
||||
nm_unmanaged_conf = Path("/etc/NetworkManager/conf.d/99-dangerous-pi-ap.conf")
|
||||
# Use data dir for backup (accessible to service with ReadWritePaths)
|
||||
nm_unmanaged_backup = Path("/opt/dangerous-pi/data/99-dangerous-pi-ap.conf.backup")
|
||||
|
||||
if nm_unmanaged_conf.exists():
|
||||
# Move config to allow NM to manage wlan0
|
||||
await self._run_command(
|
||||
f"mv {nm_unmanaged_conf} {nm_unmanaged_backup}",
|
||||
check=False
|
||||
)
|
||||
# Reload NM config without full restart (faster, doesn't drop connections)
|
||||
await self._run_command("nmcli general reload", check=False)
|
||||
# Give NM time to re-scan for wlan0
|
||||
await asyncio.sleep(2)
|
||||
|
||||
async def _disable_nm_management(self):
|
||||
"""Disable NetworkManager management of wlan0 for AP mode.
|
||||
|
||||
Restores the unmanaged-devices config.
|
||||
"""
|
||||
nm_unmanaged_conf = Path("/etc/NetworkManager/conf.d/99-dangerous-pi-ap.conf")
|
||||
# Use data dir for backup (accessible to service with ReadWritePaths)
|
||||
nm_unmanaged_backup = Path("/opt/dangerous-pi/data/99-dangerous-pi-ap.conf.backup")
|
||||
|
||||
if nm_unmanaged_backup.exists() and not nm_unmanaged_conf.exists():
|
||||
# Restore config to prevent NM from managing wlan0
|
||||
await self._run_command(
|
||||
f"mv {nm_unmanaged_backup} {nm_unmanaged_conf}",
|
||||
check=False
|
||||
)
|
||||
# Reload NM config without full restart
|
||||
await self._run_command("nmcli general reload", check=False)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def _enable_ap_mode(self):
|
||||
"""Enable Access Point mode."""
|
||||
# Ensure NM doesn't manage wlan0
|
||||
await self._disable_nm_management()
|
||||
# Start hostapd and dnsmasq for AP
|
||||
await self._systemctl("start", "hostapd")
|
||||
await self._systemctl("start", "dnsmasq")
|
||||
# Set static IP for AP
|
||||
await self._run_command("ip addr add 192.168.4.1/24 dev wlan0", check=False)
|
||||
print("AP mode enabled")
|
||||
|
||||
async def _enable_client_mode(self):
|
||||
"""Enable Client mode using NetworkManager."""
|
||||
# Stop AP services
|
||||
await self._systemctl("stop", "hostapd")
|
||||
await self._systemctl("stop", "dnsmasq")
|
||||
# Enable NetworkManager to manage wlan0
|
||||
await self._enable_nm_management()
|
||||
print("Client mode enabled")
|
||||
|
||||
async def _enable_dual_mode(self):
|
||||
"""Enable Dual mode (AP + Client)."""
|
||||
# Enable both AP and client
|
||||
await self._systemctl("start", "hostapd")
|
||||
await self._systemctl("start", "dnsmasq")
|
||||
await self._systemctl("start", "wpa_supplicant")
|
||||
print("Dual mode enabled")
|
||||
|
||||
async def _disable_wifi(self):
|
||||
"""Disable all WiFi."""
|
||||
await self._systemctl("stop", "hostapd")
|
||||
await self._systemctl("stop", "dnsmasq")
|
||||
await self._systemctl("stop", "wpa_supplicant")
|
||||
print("WiFi disabled")
|
||||
|
||||
async def _enable_auto_mode(self):
|
||||
"""Enable Auto mode."""
|
||||
# Auto-detect best mode based on saved networks and hardware
|
||||
if self._supports_dual:
|
||||
await self._enable_dual_mode()
|
||||
else:
|
||||
# Check if we have saved networks
|
||||
saved_networks = await self.get_saved_networks()
|
||||
if saved_networks:
|
||||
await self._enable_client_mode()
|
||||
else:
|
||||
await self._enable_ap_mode()
|
||||
print("Auto mode enabled")
|
||||
|
||||
async def connect_to_network(
|
||||
self,
|
||||
ssid: str,
|
||||
password: Optional[str] = None,
|
||||
interface: Optional[str] = None,
|
||||
hidden: bool = False,
|
||||
fallback_to_ap: bool = True
|
||||
) -> bool:
|
||||
"""Connect to a WiFi network using NetworkManager.
|
||||
|
||||
Args:
|
||||
ssid: Network SSID
|
||||
password: Network password (if encrypted)
|
||||
interface: Interface to use (default: first available)
|
||||
hidden: Whether network is hidden
|
||||
fallback_to_ap: If True, revert to AP mode on connection failure
|
||||
|
||||
Returns:
|
||||
True if connection successful
|
||||
"""
|
||||
# Ensure interfaces are detected before trying to connect
|
||||
if not self._interfaces:
|
||||
await self.detect_interfaces()
|
||||
|
||||
if not interface:
|
||||
# Use first non-USB interface, or first available
|
||||
for iface in self._interfaces:
|
||||
if not iface.is_usb:
|
||||
interface = iface.name
|
||||
break
|
||||
if not interface and self._interfaces:
|
||||
interface = self._interfaces[0].name
|
||||
|
||||
if not interface:
|
||||
print("WiFi connect failed: no interface found")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Switch to client mode (stops hostapd, enables NM management)
|
||||
await self._enable_client_mode()
|
||||
|
||||
# Wait for NM to be ready and detect wlan0
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Delete any existing saved connection for this SSID
|
||||
# (handles corrupted connections with missing key-mgmt property)
|
||||
ssid_escaped = ssid.replace("'", "'\\''")
|
||||
await self._run_command(
|
||||
f"nmcli connection delete '{ssid_escaped}'",
|
||||
check=False
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Build nmcli connect command
|
||||
cmd = f"nmcli device wifi connect '{ssid_escaped}'"
|
||||
|
||||
if password:
|
||||
password_escaped = password.replace("'", "'\\''")
|
||||
cmd += f" password '{password_escaped}'"
|
||||
|
||||
if hidden:
|
||||
cmd += " hidden yes"
|
||||
|
||||
# Attempt connection
|
||||
result = await self._run_command(cmd, check=False)
|
||||
|
||||
# Check if connection was successful
|
||||
if "successfully activated" in result.lower():
|
||||
print(f"Successfully connected to {ssid}")
|
||||
|
||||
# Verify we have an IP address
|
||||
await asyncio.sleep(2)
|
||||
ip_result = await self._run_command(f"ip addr show {interface}")
|
||||
if "inet " in ip_result and "192.168.4." not in ip_result:
|
||||
return True
|
||||
|
||||
# Connection failed
|
||||
print(f"Connection to {ssid} failed: {result}")
|
||||
|
||||
if fallback_to_ap:
|
||||
print("Falling back to AP mode...")
|
||||
await self._enable_ap_mode()
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error connecting to network: {e}")
|
||||
return False
|
||||
|
||||
async def _add_network_via_wpa_cli(self, ssid: str, password: Optional[str], hidden: bool):
|
||||
"""Add network using wpa_cli commands."""
|
||||
try:
|
||||
# Add network
|
||||
result = await self._run_command("sudo wpa_cli add_network")
|
||||
network_id = result.strip().split('\n')[-1]
|
||||
|
||||
# Set SSID
|
||||
await self._run_command(f'sudo wpa_cli set_network {network_id} ssid \\"{ssid}\\"')
|
||||
|
||||
# Set password or open network
|
||||
if password:
|
||||
await self._run_command(f'sudo wpa_cli set_network {network_id} psk \\"{password}\\"')
|
||||
else:
|
||||
await self._run_command(f'sudo wpa_cli set_network {network_id} key_mgmt NONE')
|
||||
|
||||
# Set scan_ssid for hidden networks
|
||||
if hidden:
|
||||
await self._run_command(f'sudo wpa_cli set_network {network_id} scan_ssid 1')
|
||||
|
||||
# Enable network
|
||||
await self._run_command(f'sudo wpa_cli enable_network {network_id}')
|
||||
|
||||
# Save configuration
|
||||
await self._run_command('sudo wpa_cli save_config')
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error adding network via wpa_cli: {e}")
|
||||
return False
|
||||
|
||||
async def disconnect_from_network(self, interface: Optional[str] = None) -> bool:
|
||||
"""Disconnect from current network and return to AP mode.
|
||||
|
||||
Args:
|
||||
interface: Interface to disconnect (default: first connected)
|
||||
|
||||
Returns:
|
||||
True if disconnection successful
|
||||
"""
|
||||
if not interface:
|
||||
for iface in self._interfaces:
|
||||
if iface.connected:
|
||||
interface = iface.name
|
||||
break
|
||||
|
||||
if not interface:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Disconnect using nmcli
|
||||
await self._run_command(f"nmcli device disconnect {interface}", check=False)
|
||||
# Return to AP mode
|
||||
await self._enable_ap_mode()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error disconnecting: {e}")
|
||||
return False
|
||||
|
||||
async def get_saved_networks(self) -> List[Dict[str, str]]:
|
||||
"""Get list of saved WiFi networks from NetworkManager.
|
||||
|
||||
Returns:
|
||||
List of saved networks with SSID and other info
|
||||
"""
|
||||
try:
|
||||
result = await self._run_command(
|
||||
"nmcli -t -f NAME,TYPE connection show",
|
||||
check=False
|
||||
)
|
||||
networks = []
|
||||
|
||||
for line in result.split('\n'):
|
||||
if line.strip():
|
||||
parts = line.split(':')
|
||||
if len(parts) >= 2 and parts[1] == '802-11-wireless':
|
||||
networks.append({
|
||||
'ssid': parts[0],
|
||||
'type': 'wifi',
|
||||
'enabled': True
|
||||
})
|
||||
|
||||
return networks
|
||||
except Exception as e:
|
||||
print(f"Error getting saved networks: {e}")
|
||||
return []
|
||||
|
||||
async def forget_network(self, ssid: str) -> bool:
|
||||
"""Forget a saved network.
|
||||
|
||||
Args:
|
||||
ssid: SSID of network to forget
|
||||
|
||||
Returns:
|
||||
True if network was forgotten
|
||||
"""
|
||||
try:
|
||||
# Delete connection using nmcli
|
||||
ssid_escaped = ssid.replace("'", "'\\''")
|
||||
result = await self._run_command(
|
||||
f"nmcli connection delete '{ssid_escaped}'",
|
||||
check=False
|
||||
)
|
||||
|
||||
return "successfully deleted" in result.lower()
|
||||
except Exception as e:
|
||||
print(f"Error forgetting network: {e}")
|
||||
return False
|
||||
|
||||
async def set_static_ip(
|
||||
self,
|
||||
interface: str,
|
||||
ip_address: str,
|
||||
netmask: str = "255.255.255.0",
|
||||
gateway: Optional[str] = None,
|
||||
dns: Optional[List[str]] = None
|
||||
) -> bool:
|
||||
"""Set static IP for interface.
|
||||
|
||||
Args:
|
||||
interface: Interface name
|
||||
ip_address: Static IP address
|
||||
netmask: Network mask
|
||||
gateway: Gateway IP (optional)
|
||||
dns: DNS servers (optional)
|
||||
|
||||
Returns:
|
||||
True if configuration successful
|
||||
"""
|
||||
try:
|
||||
# Configure static IP using dhcpcd
|
||||
config_path = Path("/etc/dhcpcd.conf")
|
||||
|
||||
# Read existing config
|
||||
if config_path.exists():
|
||||
content = config_path.read_text()
|
||||
else:
|
||||
content = ""
|
||||
|
||||
# Remove existing config for this interface
|
||||
lines = content.split('\n')
|
||||
new_lines = []
|
||||
skip_interface = False
|
||||
|
||||
for line in lines:
|
||||
if line.startswith(f'interface {interface}'):
|
||||
skip_interface = True
|
||||
continue
|
||||
if skip_interface and (line.startswith('interface ') or line.strip() == ''):
|
||||
skip_interface = False
|
||||
if not skip_interface:
|
||||
new_lines.append(line)
|
||||
|
||||
# Add new configuration
|
||||
new_config = f"\ninterface {interface}\n"
|
||||
new_config += f"static ip_address={ip_address}/{self._netmask_to_cidr(netmask)}\n"
|
||||
|
||||
if gateway:
|
||||
new_config += f"static routers={gateway}\n"
|
||||
|
||||
if dns:
|
||||
new_config += f"static domain_name_servers={' '.join(dns)}\n"
|
||||
|
||||
new_content = '\n'.join(new_lines) + new_config
|
||||
|
||||
# Write configuration (would need sudo)
|
||||
# In production, this should use a proper mechanism
|
||||
print(f"Would write to {config_path}:\n{new_config}")
|
||||
|
||||
# Restart interface
|
||||
await self._run_command(f"sudo ip link set {interface} down")
|
||||
await self._run_command(f"sudo ip link set {interface} up")
|
||||
await self._run_command("sudo systemctl restart dhcpcd")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error setting static IP: {e}")
|
||||
return False
|
||||
|
||||
def _netmask_to_cidr(self, netmask: str) -> int:
|
||||
"""Convert netmask to CIDR notation.
|
||||
|
||||
Args:
|
||||
netmask: Netmask (e.g., 255.255.255.0)
|
||||
|
||||
Returns:
|
||||
CIDR prefix length (e.g., 24)
|
||||
"""
|
||||
return sum([bin(int(x)).count('1') for x in netmask.split('.')])
|
||||
|
||||
async def enable_dhcp(self, interface: str) -> bool:
|
||||
"""Enable DHCP for interface.
|
||||
|
||||
Args:
|
||||
interface: Interface name
|
||||
|
||||
Returns:
|
||||
True if DHCP enabled
|
||||
"""
|
||||
try:
|
||||
# Remove static IP configuration
|
||||
config_path = Path("/etc/dhcpcd.conf")
|
||||
|
||||
if config_path.exists():
|
||||
content = config_path.read_text()
|
||||
lines = content.split('\n')
|
||||
new_lines = []
|
||||
skip_interface = False
|
||||
|
||||
for line in lines:
|
||||
if line.startswith(f'interface {interface}'):
|
||||
skip_interface = True
|
||||
continue
|
||||
if skip_interface and (line.startswith('interface ') or line.strip() == ''):
|
||||
skip_interface = False
|
||||
if not skip_interface:
|
||||
new_lines.append(line)
|
||||
|
||||
# Write back
|
||||
print(f"Would update {config_path} to enable DHCP")
|
||||
|
||||
# Restart interface
|
||||
await self._run_command(f"sudo ip link set {interface} down")
|
||||
await self._run_command(f"sudo ip link set {interface} up")
|
||||
await self._run_command("sudo systemctl restart dhcpcd")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error enabling DHCP: {e}")
|
||||
return False
|
||||
|
||||
async def _run_command(self, command: str, check: bool = True) -> str:
|
||||
"""Run a shell command asynchronously.
|
||||
|
||||
Args:
|
||||
command: Command to run
|
||||
check: Raise exception on non-zero exit code
|
||||
|
||||
Returns:
|
||||
Command output
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: If command fails and check=True
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _execute():
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=check
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
return await loop.run_in_executor(None, _execute)
|
||||
|
||||
|
||||
# Global instance
|
||||
wifi_manager = WiFiManager()
|
||||
@@ -0,0 +1 @@
|
||||
"""Database models."""
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Database models and initialization."""
|
||||
import aiosqlite
|
||||
from pathlib import Path
|
||||
from .. import config
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Initialize the SQLite database."""
|
||||
# Ensure data directory exists
|
||||
config.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async with aiosqlite.connect(config.DATABASE_PATH) as db:
|
||||
# Devices table (NEW for multi-device support)
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
device_path TEXT NOT NULL,
|
||||
serial_number TEXT,
|
||||
friendly_name TEXT,
|
||||
usb_vid TEXT,
|
||||
usb_pid TEXT,
|
||||
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
firmware_version TEXT,
|
||||
bootrom_version TEXT,
|
||||
metadata TEXT,
|
||||
version_mismatch_ignored BOOLEAN DEFAULT FALSE,
|
||||
ignored_at TIMESTAMP,
|
||||
ignored_version TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Sessions table (UPDATED for multi-device support)
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT UNIQUE NOT NULL,
|
||||
device_id TEXT,
|
||||
device_path TEXT,
|
||||
client_ip TEXT NOT NULL,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_activity TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
released_at TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(device_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Config table
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Crash reports table
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS crash_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
error_type TEXT NOT NULL,
|
||||
error_message TEXT NOT NULL,
|
||||
traceback TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Command history table
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS command_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
response TEXT,
|
||||
success BOOLEAN,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Firmware flash log table (NEW for firmware update tracking)
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS firmware_flash_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL,
|
||||
device_path TEXT NOT NULL,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
old_version TEXT,
|
||||
new_version TEXT,
|
||||
flash_bootrom BOOLEAN DEFAULT FALSE,
|
||||
success BOOLEAN,
|
||||
error_message TEXT,
|
||||
duration_seconds INTEGER,
|
||||
user_ip TEXT,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(device_id)
|
||||
)
|
||||
""")
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_db():
|
||||
"""Get database connection."""
|
||||
db = await aiosqlite.connect(config.DATABASE_PATH)
|
||||
db.row_factory = aiosqlite.Row
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
await db.close()
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Service layer for Dangerous Pi.
|
||||
|
||||
The service layer provides reusable business logic that can be consumed by
|
||||
multiple interfaces (REST API, BLE GATT, plugins, etc.).
|
||||
|
||||
Services handle:
|
||||
- Business logic and validation
|
||||
- Session management
|
||||
- Error handling and formatting
|
||||
- Cross-cutting concerns
|
||||
|
||||
This pattern prevents code duplication across interfaces.
|
||||
"""
|
||||
|
||||
from .pm3_service import PM3Service, PM3ServiceResult, PM3ServiceError
|
||||
from .system_service import SystemService
|
||||
from .wifi_service import WiFiService
|
||||
from .update_service import UpdateService
|
||||
from .container import ServiceContainer, container
|
||||
|
||||
__all__ = [
|
||||
# PM3 Service
|
||||
"PM3Service",
|
||||
"PM3ServiceResult",
|
||||
"PM3ServiceError",
|
||||
|
||||
# Other Services
|
||||
"SystemService",
|
||||
"WiFiService",
|
||||
"UpdateService",
|
||||
|
||||
# Service Container
|
||||
"ServiceContainer",
|
||||
"container",
|
||||
]
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Service Container for Dependency Injection.
|
||||
|
||||
This module provides a singleton container that manages service instances
|
||||
and their dependencies, ensuring consistent state across all interfaces
|
||||
(REST API, BLE GATT, plugins, etc.).
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from ..workers.pm3_worker import PM3Worker, SubprocessPM3Worker
|
||||
from ..managers.session_manager import SessionManager
|
||||
from ..managers.wifi_manager import WiFiManager
|
||||
from ..managers.update_manager import get_update_manager
|
||||
from ..managers.pm3_device_manager import PM3DeviceManager
|
||||
|
||||
from .pm3_service import PM3Service
|
||||
from .system_service import SystemService
|
||||
from .wifi_service import WiFiService
|
||||
from .update_service import UpdateService
|
||||
|
||||
|
||||
class ServiceContainer:
|
||||
"""Dependency injection container for services.
|
||||
|
||||
This container:
|
||||
- Creates and manages singleton instances of services
|
||||
- Injects shared dependencies (workers, managers)
|
||||
- Ensures consistent state across all interfaces
|
||||
- Provides centralized access to services
|
||||
|
||||
Usage:
|
||||
# In REST API
|
||||
from app.backend.services.container import container
|
||||
result = await container.pm3_service.execute_command(...)
|
||||
|
||||
# In BLE GATT
|
||||
from app.backend.services.container import container
|
||||
result = await container.pm3_service.execute_command(...)
|
||||
|
||||
# Both use the same service instance!
|
||||
"""
|
||||
|
||||
_instance: Optional['ServiceContainer'] = None
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize service container with shared dependencies."""
|
||||
if ServiceContainer._instance is not None:
|
||||
raise RuntimeError(
|
||||
"ServiceContainer is a singleton. Use container.get_instance() "
|
||||
"or import the global 'container' instance."
|
||||
)
|
||||
|
||||
# Create shared worker/manager instances
|
||||
# These are the low-level components that services will use
|
||||
# Use PM3Worker with SWIG bindings for better performance
|
||||
# Falls back to SubprocessPM3Worker if SWIG import fails
|
||||
try:
|
||||
self._pm3_worker = PM3Worker() # Try SWIG bindings first
|
||||
except Exception:
|
||||
self._pm3_worker = SubprocessPM3Worker() # Fallback to subprocess
|
||||
self._pm3_device_manager = PM3DeviceManager() # NEW: Multi-device manager
|
||||
self._session_manager = SessionManager()
|
||||
self._wifi_manager = WiFiManager()
|
||||
self._update_manager = get_update_manager()
|
||||
|
||||
# Create service instances with injected dependencies
|
||||
self._pm3_service = PM3Service(
|
||||
pm3_worker=self._pm3_worker,
|
||||
session_manager=self._session_manager,
|
||||
device_manager=self._pm3_device_manager # NEW: Multi-device support
|
||||
)
|
||||
|
||||
self._system_service = SystemService()
|
||||
|
||||
self._wifi_service = WiFiService(
|
||||
wifi_manager=self._wifi_manager
|
||||
)
|
||||
|
||||
self._update_service = UpdateService(
|
||||
update_manager=self._update_manager
|
||||
)
|
||||
|
||||
# Note: WorkflowService will be added in Phase 5
|
||||
# self._workflow_service = WorkflowService(
|
||||
# pm3_service=self._pm3_service
|
||||
# )
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> 'ServiceContainer':
|
||||
"""Get the singleton service container instance.
|
||||
|
||||
Returns:
|
||||
ServiceContainer instance
|
||||
"""
|
||||
if cls._instance is None:
|
||||
cls._instance = ServiceContainer()
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def reset_instance(cls):
|
||||
"""Reset the singleton instance.
|
||||
|
||||
This is primarily used for testing purposes.
|
||||
"""
|
||||
cls._instance = None
|
||||
|
||||
# Service properties (read-only access)
|
||||
|
||||
@property
|
||||
def pm3_service(self) -> PM3Service:
|
||||
"""Get PM3 service instance.
|
||||
|
||||
Returns:
|
||||
Shared PM3Service instance
|
||||
"""
|
||||
return self._pm3_service
|
||||
|
||||
@property
|
||||
def system_service(self) -> SystemService:
|
||||
"""Get system service instance.
|
||||
|
||||
Returns:
|
||||
Shared SystemService instance
|
||||
"""
|
||||
return self._system_service
|
||||
|
||||
@property
|
||||
def wifi_service(self) -> WiFiService:
|
||||
"""Get WiFi service instance.
|
||||
|
||||
Returns:
|
||||
Shared WiFiService instance
|
||||
"""
|
||||
return self._wifi_service
|
||||
|
||||
@property
|
||||
def update_service(self) -> UpdateService:
|
||||
"""Get update service instance.
|
||||
|
||||
Returns:
|
||||
Shared UpdateService instance
|
||||
"""
|
||||
return self._update_service
|
||||
|
||||
# Manager/Worker access (for cases where direct access is needed)
|
||||
|
||||
@property
|
||||
def pm3_worker(self) -> PM3Worker:
|
||||
"""Get PM3 worker instance.
|
||||
|
||||
Returns:
|
||||
Shared PM3Worker instance
|
||||
"""
|
||||
return self._pm3_worker
|
||||
|
||||
@property
|
||||
def session_manager(self) -> SessionManager:
|
||||
"""Get session manager instance.
|
||||
|
||||
Returns:
|
||||
Shared SessionManager instance
|
||||
"""
|
||||
return self._session_manager
|
||||
|
||||
@property
|
||||
def wifi_manager(self) -> WiFiManager:
|
||||
"""Get WiFi manager instance.
|
||||
|
||||
Returns:
|
||||
Shared WiFiManager instance
|
||||
"""
|
||||
return self._wifi_manager
|
||||
|
||||
@property
|
||||
def pm3_device_manager(self) -> PM3DeviceManager:
|
||||
"""Get PM3 device manager instance.
|
||||
|
||||
Returns:
|
||||
Shared PM3DeviceManager instance for multi-device support
|
||||
"""
|
||||
return self._pm3_device_manager
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
# Import this throughout the codebase for consistent service access
|
||||
container = ServiceContainer.get_instance()
|
||||
|
||||
|
||||
# Convenience exports for common services
|
||||
__all__ = [
|
||||
'ServiceContainer',
|
||||
'container',
|
||||
]
|
||||
@@ -0,0 +1,585 @@
|
||||
"""PM3 Service Layer.
|
||||
|
||||
This service encapsulates all PM3-related business logic and is used by
|
||||
both REST API and BLE GATT handlers to avoid code duplication.
|
||||
"""
|
||||
from typing import Optional, Dict, Any, List
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..workers.pm3_worker import PM3Worker, PM3CommandResult
|
||||
from ..managers.session_manager import SessionManager
|
||||
from ..managers.pm3_device_manager import PM3DeviceManager, PM3Device, DeviceStatus
|
||||
|
||||
|
||||
@dataclass
|
||||
class PM3ServiceError:
|
||||
"""Error response from PM3 service."""
|
||||
code: str # "session_locked", "pm3_not_connected", "command_failed"
|
||||
message: str
|
||||
details: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PM3ServiceResult:
|
||||
"""Result from PM3 service operations."""
|
||||
success: bool
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
error: Optional[PM3ServiceError] = None
|
||||
|
||||
|
||||
class PM3Service:
|
||||
"""PM3 service layer for command execution and status queries.
|
||||
|
||||
This service can be used by multiple interfaces:
|
||||
- REST API (FastAPI endpoints)
|
||||
- BLE GATT (Bluetooth handlers)
|
||||
- Plugin system (future)
|
||||
|
||||
It encapsulates:
|
||||
- Session validation
|
||||
- PM3 command execution
|
||||
- Session management
|
||||
- Response formatting
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pm3_worker: Optional[PM3Worker] = None,
|
||||
session_manager: Optional[SessionManager] = None,
|
||||
device_manager: Optional[PM3DeviceManager] = None
|
||||
):
|
||||
"""Initialize PM3 service.
|
||||
|
||||
Args:
|
||||
pm3_worker: PM3 worker instance (legacy, kept for backward compatibility)
|
||||
session_manager: Session manager instance (creates new if not provided)
|
||||
device_manager: PM3 device manager for multi-device support (optional)
|
||||
"""
|
||||
self.pm3_worker = pm3_worker or PM3Worker() # Legacy single-device support
|
||||
self.session_manager = session_manager or SessionManager()
|
||||
self.device_manager = device_manager # Multi-device support (optional)
|
||||
|
||||
async def execute_command(
|
||||
self,
|
||||
command: str,
|
||||
session_id: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
device_id: Optional[str] = None
|
||||
) -> PM3ServiceResult:
|
||||
"""Execute a PM3 command with session validation.
|
||||
|
||||
This method handles:
|
||||
1. Session validation
|
||||
2. Command execution
|
||||
3. Session activity updates
|
||||
4. Error handling
|
||||
|
||||
Args:
|
||||
command: PM3 command to execute
|
||||
session_id: Optional session ID for multi-user support
|
||||
timeout: Optional command timeout in seconds
|
||||
device_id: Optional device ID for multi-device support (uses legacy worker if not provided)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with success status and data/error
|
||||
"""
|
||||
# Determine which worker to use
|
||||
worker = None
|
||||
device_path = None
|
||||
|
||||
if device_id and self.device_manager:
|
||||
# Multi-device mode: get device from device manager
|
||||
device = await self.device_manager.get_device(device_id)
|
||||
if not device:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="device_not_found",
|
||||
message=f"Device {device_id} not found",
|
||||
details="Device may have been disconnected"
|
||||
)
|
||||
)
|
||||
|
||||
if not device.worker:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="device_no_worker",
|
||||
message=f"Device {device_id} has no worker instance",
|
||||
details="Device may not be fully initialized"
|
||||
)
|
||||
)
|
||||
|
||||
worker = device.worker
|
||||
device_path = device.device_path
|
||||
else:
|
||||
# Legacy single-device mode
|
||||
worker = self.pm3_worker
|
||||
device_path = self.pm3_worker.device_path
|
||||
|
||||
# 1. Validate session (per-device)
|
||||
if not self.session_manager.can_execute(session_id, device_id):
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="session_locked",
|
||||
message=f"Another session is active for device {device_id or 'default'}",
|
||||
details="Please take over the session or wait for it to expire"
|
||||
)
|
||||
)
|
||||
|
||||
# 2. Check PM3 connection
|
||||
if not await worker.is_connected():
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="pm3_not_connected",
|
||||
message="Proxmark3 not connected",
|
||||
details=f"Device path: {device_path}"
|
||||
)
|
||||
)
|
||||
|
||||
# 3. Execute command
|
||||
try:
|
||||
result = await worker.execute_command(command)
|
||||
|
||||
# 4. Update session activity
|
||||
if session_id:
|
||||
self.session_manager.update_activity(session_id, device_id)
|
||||
|
||||
# 5. Return result
|
||||
if result.success:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"output": result.output,
|
||||
"command": command,
|
||||
"session_id": session_id,
|
||||
"device_id": device_id
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Include both error message and output for better diagnostics
|
||||
# PM3 CLI often outputs error details to stdout
|
||||
error_details = result.error or ""
|
||||
if result.output:
|
||||
error_details = f"{error_details}\nOutput: {result.output}" if error_details else result.output
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="command_failed",
|
||||
message="PM3 command failed",
|
||||
details=error_details
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="execution_error",
|
||||
message="Command execution error",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def get_status(self, device_id: Optional[str] = None) -> PM3ServiceResult:
|
||||
"""Get PM3 device status.
|
||||
|
||||
Args:
|
||||
device_id: Optional device ID. If None and device_manager exists, returns all devices.
|
||||
If None and no device_manager, returns legacy single device status.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with connection status, version, etc.
|
||||
"""
|
||||
try:
|
||||
# Multi-device mode: return all devices or specific device
|
||||
if device_id is None and self.device_manager:
|
||||
# Return status for all devices
|
||||
devices = await self.device_manager.get_all_devices()
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"devices": [
|
||||
{
|
||||
"device_id": d.device_id,
|
||||
"device_path": d.device_path,
|
||||
"friendly_name": d.friendly_name or d.device_path.split('/')[-1],
|
||||
"serial_number": d.serial_number,
|
||||
"connected": d.status == DeviceStatus.CONNECTED,
|
||||
"in_use": d.status == DeviceStatus.IN_USE,
|
||||
"status": d.status.value,
|
||||
"firmware_info": {
|
||||
"bootrom_version": d.firmware_info.bootrom_version,
|
||||
"os_version": d.firmware_info.os_version,
|
||||
"compatible": d.firmware_info.compatible,
|
||||
},
|
||||
"last_seen": d.last_seen.isoformat(),
|
||||
}
|
||||
for d in devices
|
||||
]
|
||||
}
|
||||
)
|
||||
elif device_id and self.device_manager:
|
||||
# Return status for specific device
|
||||
device = await self.device_manager.get_device(device_id)
|
||||
if not device:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="device_not_found",
|
||||
message=f"Device {device_id} not found"
|
||||
)
|
||||
)
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"device_id": device.device_id,
|
||||
"device_path": device.device_path,
|
||||
"friendly_name": device.friendly_name or device.device_path.split('/')[-1],
|
||||
"serial_number": device.serial_number,
|
||||
"connected": device.status == DeviceStatus.CONNECTED,
|
||||
"in_use": device.status == DeviceStatus.IN_USE,
|
||||
"status": device.status.value,
|
||||
"firmware_info": {
|
||||
"bootrom_version": device.firmware_info.bootrom_version,
|
||||
"os_version": device.firmware_info.os_version,
|
||||
"compatible": device.firmware_info.compatible,
|
||||
},
|
||||
"last_seen": device.last_seen.isoformat(),
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Legacy single-device mode
|
||||
is_connected = await self.pm3_worker.is_connected()
|
||||
version = None
|
||||
|
||||
if is_connected:
|
||||
# Try to get version info
|
||||
result = await self.pm3_worker.execute_command("hw version")
|
||||
if result.success:
|
||||
version = result.output.split("\n")[0] if result.output else None
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"connected": is_connected,
|
||||
"device_path": self.pm3_worker.device_path,
|
||||
"version": version,
|
||||
"session_active": self.session_manager.has_active_session()
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="status_error",
|
||||
message="Failed to get PM3 status",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def connect(self) -> PM3ServiceResult:
|
||||
"""Connect to PM3 device.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success/failure
|
||||
"""
|
||||
try:
|
||||
await self.pm3_worker.connect()
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={"message": "Connected to Proxmark3"}
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="connection_error",
|
||||
message="Failed to connect to PM3",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def disconnect(self) -> PM3ServiceResult:
|
||||
"""Disconnect from PM3 device.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success/failure
|
||||
"""
|
||||
try:
|
||||
await self.pm3_worker.disconnect()
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={"message": "Disconnected from Proxmark3"}
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="disconnection_error",
|
||||
message="Failed to disconnect from PM3",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
# Session management methods (for both REST and BLE)
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
client_ip: str = "unknown",
|
||||
user_agent: Optional[str] = None,
|
||||
force_takeover: bool = False,
|
||||
device_id: Optional[str] = None
|
||||
) -> PM3ServiceResult:
|
||||
"""Create a new session for a device.
|
||||
|
||||
Args:
|
||||
client_ip: Client IP address
|
||||
user_agent: Client user agent string
|
||||
force_takeover: Whether to forcefully take over existing session
|
||||
device_id: Device ID to create session for (None for legacy mode)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with session_id
|
||||
"""
|
||||
success, session_id, error_msg = await self.session_manager.create_session(
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
force_takeover=force_takeover,
|
||||
device_id=device_id
|
||||
)
|
||||
|
||||
if success and session_id:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={"session_id": session_id, "device_id": device_id}
|
||||
)
|
||||
else:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="session_exists",
|
||||
message=error_msg or "Session already exists",
|
||||
details="Set force_takeover=true to take over"
|
||||
)
|
||||
)
|
||||
|
||||
async def release_session(
|
||||
self,
|
||||
session_id: str,
|
||||
device_id: Optional[str] = None
|
||||
) -> PM3ServiceResult:
|
||||
"""Release a session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to release
|
||||
device_id: Device ID (if known)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success
|
||||
"""
|
||||
released = await self.session_manager.release_session(session_id, device_id)
|
||||
if released:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={"message": "Session released"}
|
||||
)
|
||||
else:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="session_not_found",
|
||||
message="Session not found or already released"
|
||||
)
|
||||
)
|
||||
|
||||
def get_session_info(self, device_id: Optional[str] = None) -> PM3ServiceResult:
|
||||
"""Get session information for a device.
|
||||
|
||||
Args:
|
||||
device_id: Device ID to get session for (None for any active session)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with session details
|
||||
"""
|
||||
session = self.session_manager.get_active_session(device_id)
|
||||
|
||||
if session:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"session_id": session.session_id,
|
||||
"device_id": session.device_id,
|
||||
"client_ip": session.client_ip,
|
||||
"created_at": session.created_at,
|
||||
"last_activity": session.last_activity,
|
||||
}
|
||||
)
|
||||
else:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="session_not_found",
|
||||
message=f"No active session for device {device_id or 'default'}"
|
||||
)
|
||||
)
|
||||
|
||||
def get_all_sessions(self) -> PM3ServiceResult:
|
||||
"""Get all active sessions.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with all session details
|
||||
"""
|
||||
sessions = self.session_manager.get_all_active_sessions()
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"sessions": [
|
||||
{
|
||||
"session_id": s.session_id,
|
||||
"device_id": s.device_id,
|
||||
"client_ip": s.client_ip,
|
||||
"created_at": s.created_at,
|
||||
"last_activity": s.last_activity,
|
||||
}
|
||||
for s in sessions.values()
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
# Multi-device management methods
|
||||
|
||||
async def list_devices(self) -> PM3ServiceResult:
|
||||
"""List all discovered PM3 devices.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with list of all devices
|
||||
"""
|
||||
if not self.device_manager:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="device_manager_not_available",
|
||||
message="Device manager not initialized",
|
||||
details="Multi-device support is not enabled"
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
devices = await self.device_manager.get_all_devices()
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"devices": [device.to_dict() for device in devices]
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="list_devices_error",
|
||||
message="Failed to list devices",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def get_available_devices(self) -> PM3ServiceResult:
|
||||
"""Get devices without active sessions (available for use).
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with list of available devices
|
||||
"""
|
||||
if not self.device_manager:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="device_manager_not_available",
|
||||
message="Device manager not initialized",
|
||||
details="Multi-device support is not enabled"
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
all_devices = await self.device_manager.get_all_devices()
|
||||
|
||||
# Filter for devices that are CONNECTED (not IN_USE or other states)
|
||||
available_devices = [
|
||||
device for device in all_devices
|
||||
if device.status == DeviceStatus.CONNECTED
|
||||
]
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"devices": [device.to_dict() for device in available_devices]
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="get_available_devices_error",
|
||||
message="Failed to get available devices",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def identify_device(
|
||||
self,
|
||||
device_id: str,
|
||||
duration_ms: int = 2000
|
||||
) -> PM3ServiceResult:
|
||||
"""Blink LEDs on a device for physical identification.
|
||||
|
||||
Args:
|
||||
device_id: Device ID to identify
|
||||
duration_ms: Duration to blink LEDs in milliseconds (default: 2000)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success
|
||||
"""
|
||||
if not self.device_manager:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="device_manager_not_available",
|
||||
message="Device manager not initialized",
|
||||
details="Multi-device support is not enabled"
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
# Check if device exists
|
||||
device = await self.device_manager.get_device(device_id)
|
||||
if not device:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="device_not_found",
|
||||
message=f"Device {device_id} not found"
|
||||
)
|
||||
)
|
||||
|
||||
# Trigger LED identification
|
||||
await self.device_manager.identify_device(device_id, duration_ms)
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": f"Device {device_id} identified",
|
||||
"device_path": device.device_path,
|
||||
"duration_ms": duration_ms
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="identify_device_error",
|
||||
message="Failed to identify device",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,789 @@
|
||||
"""System Service Layer.
|
||||
|
||||
This service provides system operations (shutdown, restart, info) that can be
|
||||
consumed by multiple interfaces (REST API, BLE GATT, etc.).
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import platform
|
||||
import psutil
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict, Any
|
||||
from pathlib import Path
|
||||
|
||||
from .pm3_service import PM3ServiceError, PM3ServiceResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class CPUCoreInfo:
|
||||
"""Per-core CPU information."""
|
||||
core_id: int
|
||||
percent: float
|
||||
online: bool = True # Whether this core is currently online
|
||||
|
||||
|
||||
@dataclass
|
||||
class PiModelInfo:
|
||||
"""Raspberry Pi model information."""
|
||||
model: str # Full model string (e.g., "Raspberry Pi Zero 2 W Rev 1.0")
|
||||
model_short: str # Short name (e.g., "Pi Zero 2 W")
|
||||
total_cores: int # Total physical cores
|
||||
default_active_cores: int # Recommended default active cores
|
||||
min_cores: int # Minimum cores (always 1)
|
||||
max_cores: int # Maximum configurable cores
|
||||
|
||||
|
||||
@dataclass
|
||||
class CPUCoresConfig:
|
||||
"""CPU cores configuration."""
|
||||
total_cores: int
|
||||
online_cores: int
|
||||
configurable_cores: list # List of core IDs that can be toggled (usually all except 0)
|
||||
pi_model: Optional[PiModelInfo] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemInfo:
|
||||
"""System information data."""
|
||||
hostname: str
|
||||
platform: str
|
||||
platform_version: str
|
||||
cpu_count: int
|
||||
cpu_percent: float
|
||||
cpu_per_core: list # List of CPUCoreInfo
|
||||
memory_total: int
|
||||
memory_used: int
|
||||
memory_percent: float
|
||||
disk_total: int
|
||||
disk_used: int
|
||||
disk_percent: float
|
||||
uptime: float
|
||||
temperature: Optional[float] = None
|
||||
load_average: Optional[tuple] = None # 1, 5, 15 min load averages
|
||||
|
||||
|
||||
class SystemService:
|
||||
"""System operations service.
|
||||
|
||||
Provides reusable business logic for:
|
||||
- System information queries
|
||||
- Shutdown/restart operations
|
||||
- Service log retrieval
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize system service."""
|
||||
pass
|
||||
|
||||
async def get_info(self) -> PM3ServiceResult:
|
||||
"""Get system information.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with system info (CPU, memory, disk, etc.)
|
||||
"""
|
||||
try:
|
||||
# Get CPU usage (blocking call, run in executor)
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Get per-core CPU percentages (requires percpu=True)
|
||||
# First call to initialize, then wait briefly for measurement
|
||||
await loop.run_in_executor(None, lambda: psutil.cpu_percent(percpu=True))
|
||||
await asyncio.sleep(0.1) # Brief pause for accurate measurement
|
||||
cpu_per_core_raw = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: psutil.cpu_percent(percpu=True)
|
||||
)
|
||||
|
||||
# Check which cores are online
|
||||
total_cores = psutil.cpu_count(logical=True) or 1
|
||||
core_online_status = {}
|
||||
for i in range(total_cores):
|
||||
online_file = Path(f"/sys/devices/system/cpu/cpu{i}/online")
|
||||
if i == 0:
|
||||
# Core 0 is always online
|
||||
core_online_status[i] = True
|
||||
elif online_file.exists():
|
||||
core_online_status[i] = online_file.read_text().strip() == "1"
|
||||
else:
|
||||
# File doesn't exist, core is always on
|
||||
core_online_status[i] = True
|
||||
|
||||
# Build per-core info with online status
|
||||
cpu_per_core = [
|
||||
CPUCoreInfo(
|
||||
core_id=i,
|
||||
percent=pct if core_online_status.get(i, True) else 0.0,
|
||||
online=core_online_status.get(i, True)
|
||||
)
|
||||
for i, pct in enumerate(cpu_per_core_raw)
|
||||
]
|
||||
|
||||
# Overall CPU percentage
|
||||
cpu_percent = sum(cpu_per_core_raw) / len(cpu_per_core_raw) if cpu_per_core_raw else 0.0
|
||||
|
||||
# Get load average (Unix only)
|
||||
try:
|
||||
load_average = os.getloadavg()
|
||||
except (OSError, AttributeError):
|
||||
load_average = None
|
||||
|
||||
# Get memory info
|
||||
memory = psutil.virtual_memory()
|
||||
|
||||
# Get disk info for root partition
|
||||
disk = psutil.disk_usage('/')
|
||||
|
||||
# Get system uptime
|
||||
boot_time = psutil.boot_time()
|
||||
uptime = psutil.time.time() - boot_time
|
||||
|
||||
# Try to get CPU temperature (Raspberry Pi specific)
|
||||
temperature = await self._get_cpu_temperature()
|
||||
|
||||
system_info = SystemInfo(
|
||||
hostname=platform.node(),
|
||||
platform=platform.system(),
|
||||
platform_version=platform.release(),
|
||||
cpu_count=psutil.cpu_count(),
|
||||
cpu_percent=cpu_percent,
|
||||
cpu_per_core=cpu_per_core,
|
||||
memory_total=memory.total,
|
||||
memory_used=memory.used,
|
||||
memory_percent=memory.percent,
|
||||
disk_total=disk.total,
|
||||
disk_used=disk.used,
|
||||
disk_percent=disk.percent,
|
||||
uptime=uptime,
|
||||
temperature=temperature,
|
||||
load_average=load_average
|
||||
)
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"hostname": system_info.hostname,
|
||||
"platform": system_info.platform,
|
||||
"platform_version": system_info.platform_version,
|
||||
"cpu": {
|
||||
"count": system_info.cpu_count,
|
||||
"percent": system_info.cpu_percent,
|
||||
"temperature": system_info.temperature,
|
||||
"per_core": [
|
||||
{"core_id": c.core_id, "percent": c.percent, "online": c.online}
|
||||
for c in system_info.cpu_per_core
|
||||
],
|
||||
"load_average": list(system_info.load_average) if system_info.load_average else None
|
||||
},
|
||||
"memory": {
|
||||
"total": system_info.memory_total,
|
||||
"used": system_info.memory_used,
|
||||
"percent": system_info.memory_percent
|
||||
},
|
||||
"disk": {
|
||||
"total": system_info.disk_total,
|
||||
"used": system_info.disk_used,
|
||||
"percent": system_info.disk_percent
|
||||
},
|
||||
"uptime": system_info.uptime
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="system_info_error",
|
||||
message="Failed to get system information",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def _get_cpu_temperature(self) -> Optional[float]:
|
||||
"""Get CPU temperature (Raspberry Pi specific).
|
||||
|
||||
Returns:
|
||||
Temperature in Celsius or None if unavailable
|
||||
"""
|
||||
try:
|
||||
temp_file = Path("/sys/class/thermal/thermal_zone0/temp")
|
||||
if temp_file.exists():
|
||||
temp_str = temp_file.read_text().strip()
|
||||
# Temperature is in millidegrees
|
||||
return float(temp_str) / 1000.0
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
async def shutdown(self, delay: int = 0) -> PM3ServiceResult:
|
||||
"""Initiate system shutdown.
|
||||
|
||||
Args:
|
||||
delay: Delay in seconds before shutdown (default: 0)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success/failure
|
||||
"""
|
||||
try:
|
||||
if delay > 0:
|
||||
# Schedule shutdown
|
||||
await self._run_command(f"sudo shutdown -h +{delay // 60}")
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": f"Shutdown scheduled in {delay} seconds",
|
||||
"delay": delay
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Immediate shutdown
|
||||
await self._run_command("sudo shutdown -h now")
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={"message": "Shutdown initiated"}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="shutdown_error",
|
||||
message="Failed to initiate shutdown",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def restart(self, delay: int = 0) -> PM3ServiceResult:
|
||||
"""Initiate system restart.
|
||||
|
||||
Args:
|
||||
delay: Delay in seconds before restart (default: 0)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success/failure
|
||||
"""
|
||||
try:
|
||||
if delay > 0:
|
||||
# Schedule restart
|
||||
await self._run_command(f"sudo shutdown -r +{delay // 60}")
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": f"Restart scheduled in {delay} seconds",
|
||||
"delay": delay
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Immediate restart
|
||||
await self._run_command("sudo shutdown -r now")
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={"message": "Restart initiated"}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="restart_error",
|
||||
message="Failed to initiate restart",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def cancel_shutdown(self) -> PM3ServiceResult:
|
||||
"""Cancel a scheduled shutdown or restart.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success/failure
|
||||
"""
|
||||
try:
|
||||
await self._run_command("sudo shutdown -c")
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={"message": "Shutdown/restart cancelled"}
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="cancel_shutdown_error",
|
||||
message="Failed to cancel shutdown",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def get_logs(
|
||||
self,
|
||||
service: str = "dangerous-pi",
|
||||
lines: int = 100
|
||||
) -> PM3ServiceResult:
|
||||
"""Get service logs using journalctl.
|
||||
|
||||
Args:
|
||||
service: Service name to get logs for
|
||||
lines: Number of lines to retrieve (default: 100)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with log content
|
||||
"""
|
||||
try:
|
||||
output = await self._run_command(
|
||||
f"sudo journalctl -u {service} -n {lines} --no-pager"
|
||||
)
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"service": service,
|
||||
"lines": lines,
|
||||
"logs": output
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="logs_error",
|
||||
message=f"Failed to get logs for service '{service}'",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def get_service_status(self, service: str) -> PM3ServiceResult:
|
||||
"""Get systemd service status.
|
||||
|
||||
Args:
|
||||
service: Service name
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with service status
|
||||
"""
|
||||
try:
|
||||
output = await self._run_command(
|
||||
f"sudo systemctl status {service}",
|
||||
check=False # Don't raise on non-zero exit
|
||||
)
|
||||
|
||||
# Parse status
|
||||
is_active = "active (running)" in output.lower()
|
||||
is_enabled = await self._is_service_enabled(service)
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"service": service,
|
||||
"active": is_active,
|
||||
"enabled": is_enabled,
|
||||
"status": output
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="service_status_error",
|
||||
message=f"Failed to get status for service '{service}'",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def _is_service_enabled(self, service: str) -> bool:
|
||||
"""Check if a systemd service is enabled.
|
||||
|
||||
Args:
|
||||
service: Service name
|
||||
|
||||
Returns:
|
||||
True if service is enabled
|
||||
"""
|
||||
try:
|
||||
output = await self._run_command(
|
||||
f"sudo systemctl is-enabled {service}",
|
||||
check=False
|
||||
)
|
||||
return output.strip() == "enabled"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _run_command(
|
||||
self,
|
||||
command: str,
|
||||
check: bool = True
|
||||
) -> str:
|
||||
"""Run a shell command asynchronously.
|
||||
|
||||
Args:
|
||||
command: Command to run
|
||||
check: Raise exception on non-zero exit code
|
||||
|
||||
Returns:
|
||||
Command output
|
||||
|
||||
Raises:
|
||||
RuntimeError: If command fails and check=True
|
||||
"""
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if check and process.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"Command failed with exit code {process.returncode}: "
|
||||
f"{stderr.decode()}"
|
||||
)
|
||||
|
||||
return stdout.decode()
|
||||
|
||||
# Pi model defaults: maps model substring to (short_name, default_cores)
|
||||
# Pi Zero 2 W has 4 cores but limited thermal/power, default to 2
|
||||
PI_MODEL_DEFAULTS = {
|
||||
"Pi Zero 2": ("Pi Zero 2 W", 2),
|
||||
"Pi Zero W": ("Pi Zero W", 1), # Single core
|
||||
"Pi Zero": ("Pi Zero", 1), # Single core
|
||||
"Pi 4": ("Pi 4", 4),
|
||||
"Pi 3": ("Pi 3", 4),
|
||||
"Pi 2": ("Pi 2", 4),
|
||||
"Pi 5": ("Pi 5", 4),
|
||||
}
|
||||
|
||||
async def get_pi_model(self) -> PM3ServiceResult:
|
||||
"""Detect Raspberry Pi model.
|
||||
|
||||
Reads from /proc/device-tree/model or /proc/cpuinfo.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with PiModelInfo data
|
||||
"""
|
||||
try:
|
||||
model_str = "Unknown"
|
||||
model_short = "Unknown"
|
||||
total_cores = psutil.cpu_count(logical=True) or 1
|
||||
default_cores = total_cores
|
||||
|
||||
# Try device tree first (most reliable)
|
||||
model_file = Path("/proc/device-tree/model")
|
||||
if model_file.exists():
|
||||
model_str = model_file.read_text().strip().rstrip('\x00')
|
||||
else:
|
||||
# Fallback to cpuinfo
|
||||
cpuinfo = Path("/proc/cpuinfo")
|
||||
if cpuinfo.exists():
|
||||
for line in cpuinfo.read_text().split('\n'):
|
||||
if line.startswith("Model"):
|
||||
model_str = line.split(':')[1].strip()
|
||||
break
|
||||
|
||||
# Determine short name and default cores based on model
|
||||
for pattern, (short_name, def_cores) in self.PI_MODEL_DEFAULTS.items():
|
||||
if pattern in model_str:
|
||||
model_short = short_name
|
||||
default_cores = min(def_cores, total_cores)
|
||||
break
|
||||
else:
|
||||
# Not a known Pi model, use total cores
|
||||
if "Raspberry" in model_str:
|
||||
model_short = "Raspberry Pi"
|
||||
else:
|
||||
model_short = model_str[:20] if len(model_str) > 20 else model_str
|
||||
|
||||
pi_model = PiModelInfo(
|
||||
model=model_str,
|
||||
model_short=model_short,
|
||||
total_cores=total_cores,
|
||||
default_active_cores=default_cores,
|
||||
min_cores=1,
|
||||
max_cores=total_cores
|
||||
)
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"model": pi_model.model,
|
||||
"model_short": pi_model.model_short,
|
||||
"total_cores": pi_model.total_cores,
|
||||
"default_active_cores": pi_model.default_active_cores,
|
||||
"min_cores": pi_model.min_cores,
|
||||
"max_cores": pi_model.max_cores
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="pi_model_error",
|
||||
message="Failed to detect Pi model",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def get_cpu_cores_config(self) -> PM3ServiceResult:
|
||||
"""Get CPU cores configuration.
|
||||
|
||||
Returns info about total cores, online cores, configured cores (from cmdline.txt),
|
||||
and Pi model with defaults.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with CPUCoresConfig data
|
||||
"""
|
||||
try:
|
||||
total_cores = psutil.cpu_count(logical=True) or 1
|
||||
|
||||
# Get currently online cores (runtime state)
|
||||
# On Pi, we can use nproc or count from /proc/cpuinfo
|
||||
try:
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
["nproc"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
online_cores = int(result.stdout.strip()) if result.returncode == 0 else total_cores
|
||||
except Exception:
|
||||
online_cores = total_cores
|
||||
|
||||
# Get configured cores from cmdline.txt (boot-time setting)
|
||||
configured_cores = self._get_configured_maxcpus()
|
||||
|
||||
# Configurable cores are all cores except 0 (which is always on)
|
||||
configurable_cores = list(range(1, total_cores))
|
||||
|
||||
# Get Pi model info
|
||||
model_result = await self.get_pi_model()
|
||||
pi_model_data = model_result.data if model_result.success else None
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"total_cores": total_cores,
|
||||
"online_cores": online_cores,
|
||||
"configured_cores": configured_cores, # From cmdline.txt
|
||||
"configurable_cores": configurable_cores,
|
||||
"pi_model": pi_model_data
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="cpu_cores_error",
|
||||
message="Failed to get CPU cores config",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
# Possible locations for cmdline.txt (varies by Pi OS version)
|
||||
CMDLINE_PATHS = [
|
||||
Path("/boot/firmware/cmdline.txt"), # Newer Pi OS (Bookworm+)
|
||||
Path("/boot/cmdline.txt"), # Older Pi OS
|
||||
]
|
||||
|
||||
def _find_cmdline_path(self) -> Optional[Path]:
|
||||
"""Find the cmdline.txt file location.
|
||||
|
||||
Returns:
|
||||
Path to cmdline.txt or None if not found
|
||||
"""
|
||||
for path in self.CMDLINE_PATHS:
|
||||
if path.exists():
|
||||
return path
|
||||
return None
|
||||
|
||||
def _get_configured_maxcpus(self) -> Optional[int]:
|
||||
"""Read the maxcpus value from cmdline.txt.
|
||||
|
||||
Returns:
|
||||
Configured maxcpus value, or None if not set
|
||||
"""
|
||||
cmdline_path = self._find_cmdline_path()
|
||||
if not cmdline_path:
|
||||
return None
|
||||
|
||||
try:
|
||||
content = cmdline_path.read_text().strip()
|
||||
# Parse cmdline parameters
|
||||
for param in content.split():
|
||||
if param.startswith("maxcpus="):
|
||||
return int(param.split("=")[1])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
async def set_cpu_cores(
|
||||
self,
|
||||
num_cores: int,
|
||||
persist: bool = True,
|
||||
reboot: bool = True
|
||||
) -> PM3ServiceResult:
|
||||
"""Set the number of active CPU cores via kernel parameter.
|
||||
|
||||
On Pi Zero 2 W (and other ARM systems), CPU hotplug via sysfs doesn't work.
|
||||
Instead, we modify the maxcpus=N kernel parameter in /boot/cmdline.txt.
|
||||
This requires a reboot to take effect.
|
||||
|
||||
Args:
|
||||
num_cores: Number of cores to use (1 to total_cores)
|
||||
persist: Must be True (always persists to cmdline.txt)
|
||||
reboot: If True, reboot the system immediately
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success/failure
|
||||
"""
|
||||
try:
|
||||
total_cores = psutil.cpu_count(logical=True) or 1
|
||||
|
||||
# Validate input
|
||||
if num_cores < 1:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="invalid_cores",
|
||||
message="Must have at least 1 core active",
|
||||
details=f"Requested: {num_cores}"
|
||||
)
|
||||
)
|
||||
|
||||
if num_cores > total_cores:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="invalid_cores",
|
||||
message=f"Cannot exceed {total_cores} cores",
|
||||
details=f"Requested: {num_cores}, Available: {total_cores}"
|
||||
)
|
||||
)
|
||||
|
||||
# Find cmdline.txt
|
||||
cmdline_path = self._find_cmdline_path()
|
||||
if not cmdline_path:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="cmdline_not_found",
|
||||
message="Could not find /boot/cmdline.txt or /boot/firmware/cmdline.txt",
|
||||
details="This feature requires a Raspberry Pi with standard boot configuration"
|
||||
)
|
||||
)
|
||||
|
||||
# Update cmdline.txt with maxcpus parameter
|
||||
update_result = await self._update_cmdline_maxcpus(cmdline_path, num_cores)
|
||||
if not update_result.success:
|
||||
return update_result
|
||||
|
||||
# Reboot if requested
|
||||
if reboot:
|
||||
await self._run_command("sudo shutdown -r +0", check=False)
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": f"CPU cores set to {num_cores}. Rebooting...",
|
||||
"requested": num_cores,
|
||||
"rebooting": True
|
||||
}
|
||||
)
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": f"CPU cores set to {num_cores}. Reboot required to apply.",
|
||||
"requested": num_cores,
|
||||
"rebooting": False,
|
||||
"reboot_required": True
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="set_cores_error",
|
||||
message="Failed to set CPU cores",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def _update_cmdline_maxcpus(
|
||||
self,
|
||||
cmdline_path: Path,
|
||||
num_cores: int
|
||||
) -> PM3ServiceResult:
|
||||
"""Update the maxcpus parameter in cmdline.txt.
|
||||
|
||||
Args:
|
||||
cmdline_path: Path to cmdline.txt
|
||||
num_cores: Number of cores to set
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success/failure
|
||||
"""
|
||||
try:
|
||||
# Read current cmdline
|
||||
content = cmdline_path.read_text().strip()
|
||||
|
||||
# Parse and update parameters
|
||||
params = content.split()
|
||||
new_params = []
|
||||
maxcpus_found = False
|
||||
|
||||
for param in params:
|
||||
if param.startswith("maxcpus="):
|
||||
# Replace existing maxcpus
|
||||
new_params.append(f"maxcpus={num_cores}")
|
||||
maxcpus_found = True
|
||||
else:
|
||||
new_params.append(param)
|
||||
|
||||
# Add maxcpus if not present
|
||||
if not maxcpus_found:
|
||||
new_params.append(f"maxcpus={num_cores}")
|
||||
|
||||
new_content = " ".join(new_params)
|
||||
|
||||
# Backup original
|
||||
await self._run_command(
|
||||
f"sudo cp {cmdline_path} {cmdline_path}.bak",
|
||||
check=True
|
||||
)
|
||||
|
||||
# Write new cmdline.txt
|
||||
# Use a temp file and move to avoid corruption
|
||||
await self._run_command(
|
||||
f"echo '{new_content}' | sudo tee {cmdline_path}.new > /dev/null",
|
||||
check=True
|
||||
)
|
||||
await self._run_command(
|
||||
f"sudo mv {cmdline_path}.new {cmdline_path}",
|
||||
check=True
|
||||
)
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={"message": f"Updated {cmdline_path} with maxcpus={num_cores}"}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="cmdline_update_error",
|
||||
message="Failed to update cmdline.txt",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
def get_configured_cpu_cores(self) -> Optional[int]:
|
||||
"""Get the configured CPU cores from cmdline.txt.
|
||||
|
||||
Returns:
|
||||
Configured maxcpus value, or None if not explicitly set
|
||||
"""
|
||||
return self._get_configured_maxcpus()
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Update Service Layer.
|
||||
|
||||
This service provides software update operations that can be consumed by
|
||||
multiple interfaces (REST API, BLE GATT, etc.).
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from ..managers.update_manager import UpdateManager, UpdateProgress, UpdateStatus
|
||||
from .pm3_service import PM3ServiceError, PM3ServiceResult
|
||||
|
||||
|
||||
class UpdateService:
|
||||
"""Update service layer for software update management.
|
||||
|
||||
This service can be used by multiple interfaces:
|
||||
- REST API (FastAPI endpoints)
|
||||
- BLE GATT (Bluetooth handlers)
|
||||
- Plugin system (future)
|
||||
|
||||
It encapsulates:
|
||||
- Update checking
|
||||
- Update downloading
|
||||
- Update installation
|
||||
- Progress monitoring
|
||||
"""
|
||||
|
||||
def __init__(self, update_manager: Optional[UpdateManager] = None):
|
||||
"""Initialize update service.
|
||||
|
||||
Args:
|
||||
update_manager: Update manager instance (creates new if not provided)
|
||||
"""
|
||||
from ..managers.update_manager import get_update_manager
|
||||
self.update_manager = update_manager or get_update_manager()
|
||||
|
||||
async def check_for_updates(self) -> PM3ServiceResult:
|
||||
"""Check for available updates.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with update availability and information
|
||||
"""
|
||||
try:
|
||||
result = await self.update_manager.check_for_updates()
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data=result
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="update_check_error",
|
||||
message="Failed to check for updates",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def download_update(self) -> PM3ServiceResult:
|
||||
"""Download the latest available update.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating download success/failure
|
||||
"""
|
||||
try:
|
||||
success = await self.update_manager.download_update()
|
||||
|
||||
if success:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": "Update downloaded successfully",
|
||||
"ready_to_install": True
|
||||
}
|
||||
)
|
||||
else:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="download_failed",
|
||||
message="Update download failed"
|
||||
)
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
# No update available
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="no_update_available",
|
||||
message=str(e)
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="update_download_error",
|
||||
message="Error downloading update",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def install_update(self) -> PM3ServiceResult:
|
||||
"""Install the downloaded update.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating installation success/failure
|
||||
"""
|
||||
try:
|
||||
success = await self.update_manager.install_update()
|
||||
|
||||
if success:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": "Update installed successfully",
|
||||
"requires_restart": True
|
||||
}
|
||||
)
|
||||
else:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="installation_failed",
|
||||
message="Update installation failed"
|
||||
)
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
# No update downloaded
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="no_update_downloaded",
|
||||
message=str(e)
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="update_install_error",
|
||||
message="Error installing update",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def get_progress(self) -> PM3ServiceResult:
|
||||
"""Get current update progress.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with progress information
|
||||
"""
|
||||
try:
|
||||
progress = await self.update_manager.get_progress()
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"status": progress.status.value,
|
||||
"current_version": progress.current_version,
|
||||
"available_version": progress.available_version,
|
||||
"download_progress": progress.download_progress,
|
||||
"error_message": progress.error_message,
|
||||
"last_check": progress.last_check
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="progress_error",
|
||||
message="Failed to get update progress",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def get_release_notes(
|
||||
self,
|
||||
version: Optional[str] = None
|
||||
) -> PM3ServiceResult:
|
||||
"""Get release notes for a specific version or latest.
|
||||
|
||||
Args:
|
||||
version: Version to get notes for (latest if None)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with release notes
|
||||
"""
|
||||
try:
|
||||
notes = await self.update_manager.get_release_notes(version)
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"version": version or "latest",
|
||||
"release_notes": notes
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="release_notes_error",
|
||||
message="Failed to get release notes",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def check_and_download_update(self) -> PM3ServiceResult:
|
||||
"""Combined operation: check for updates and download if available.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with check and download status
|
||||
"""
|
||||
try:
|
||||
# First check for updates
|
||||
check_result = await self.check_for_updates()
|
||||
|
||||
if not check_result.success:
|
||||
return check_result
|
||||
|
||||
# If update available, download it
|
||||
if check_result.data.get("update_available"):
|
||||
download_result = await self.download_update()
|
||||
|
||||
if download_result.success:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": "Update checked and downloaded",
|
||||
"update_info": check_result.data,
|
||||
"ready_to_install": True
|
||||
}
|
||||
)
|
||||
else:
|
||||
return download_result
|
||||
else:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": "System is up to date",
|
||||
"update_available": False,
|
||||
"current_version": check_result.data.get("current_version")
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="check_download_error",
|
||||
message="Error checking and downloading update",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def full_update(self) -> PM3ServiceResult:
|
||||
"""Full update workflow: check, download, and install.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with complete update status
|
||||
"""
|
||||
try:
|
||||
# Check for updates
|
||||
check_result = await self.check_for_updates()
|
||||
if not check_result.success:
|
||||
return check_result
|
||||
|
||||
if not check_result.data.get("update_available"):
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": "System is already up to date",
|
||||
"update_performed": False
|
||||
}
|
||||
)
|
||||
|
||||
# Download update
|
||||
download_result = await self.download_update()
|
||||
if not download_result.success:
|
||||
return download_result
|
||||
|
||||
# Install update
|
||||
install_result = await self.install_update()
|
||||
if not install_result.success:
|
||||
return install_result
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": "Update completed successfully",
|
||||
"update_performed": True,
|
||||
"previous_version": check_result.data.get("current_version"),
|
||||
"new_version": check_result.data.get("latest_version"),
|
||||
"requires_restart": True
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="full_update_error",
|
||||
message="Error performing full update",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,351 @@
|
||||
"""WiFi Service Layer.
|
||||
|
||||
This service provides WiFi management operations that can be consumed by
|
||||
multiple interfaces (REST API, BLE GATT, etc.).
|
||||
"""
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from ..managers.wifi_manager import WiFiManager, WiFiMode, WiFiStatus, WiFiNetwork
|
||||
from .pm3_service import PM3ServiceError, PM3ServiceResult
|
||||
|
||||
|
||||
class WiFiService:
|
||||
"""WiFi service layer for network management.
|
||||
|
||||
This service can be used by multiple interfaces:
|
||||
- REST API (FastAPI endpoints)
|
||||
- BLE GATT (Bluetooth handlers)
|
||||
- Plugin system (future)
|
||||
|
||||
It encapsulates:
|
||||
- Network scanning
|
||||
- Connection management
|
||||
- WiFi mode switching
|
||||
- Status queries
|
||||
"""
|
||||
|
||||
def __init__(self, wifi_manager: Optional[WiFiManager] = None):
|
||||
"""Initialize WiFi service.
|
||||
|
||||
Args:
|
||||
wifi_manager: WiFi manager instance (creates new if not provided)
|
||||
"""
|
||||
self.wifi_manager = wifi_manager or WiFiManager()
|
||||
|
||||
async def get_status(self) -> PM3ServiceResult:
|
||||
"""Get current WiFi status.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with WiFi status (mode, interfaces, connections)
|
||||
"""
|
||||
try:
|
||||
status = await self.wifi_manager.get_status()
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"mode": status.mode.value,
|
||||
"interfaces": [
|
||||
{
|
||||
"name": iface.name,
|
||||
"mac": iface.mac,
|
||||
"is_usb": iface.is_usb,
|
||||
"is_up": iface.is_up,
|
||||
"connected": iface.connected,
|
||||
"ssid": iface.ssid,
|
||||
"ip_address": iface.ip_address,
|
||||
"mode": iface.mode # "AP" or "managed"
|
||||
}
|
||||
for iface in status.interfaces
|
||||
],
|
||||
"client": {
|
||||
"ssid": status.current_ssid,
|
||||
"ip": status.current_ip
|
||||
} if status.current_ssid else None,
|
||||
"access_point": {
|
||||
"ssid": status.ap_ssid,
|
||||
"ip": status.ap_ip
|
||||
},
|
||||
"supports_dual": status.supports_dual
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="wifi_status_error",
|
||||
message="Failed to get WiFi status",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def scan_networks(
|
||||
self,
|
||||
interface: Optional[str] = None
|
||||
) -> PM3ServiceResult:
|
||||
"""Scan for available WiFi networks.
|
||||
|
||||
Args:
|
||||
interface: Interface to scan with (default: auto-select)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with list of available networks
|
||||
"""
|
||||
try:
|
||||
networks = await self.wifi_manager.scan_networks(interface)
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"networks": [
|
||||
{
|
||||
"ssid": net.ssid,
|
||||
"bssid": net.bssid,
|
||||
"signal_strength": net.signal_strength,
|
||||
"frequency": net.frequency,
|
||||
"encrypted": net.encrypted,
|
||||
"in_use": net.in_use
|
||||
}
|
||||
for net in networks
|
||||
],
|
||||
"count": len(networks)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="wifi_scan_error",
|
||||
message="Failed to scan for networks",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def connect(
|
||||
self,
|
||||
ssid: str,
|
||||
password: Optional[str] = None,
|
||||
interface: Optional[str] = None,
|
||||
hidden: bool = False
|
||||
) -> PM3ServiceResult:
|
||||
"""Connect to a WiFi network.
|
||||
|
||||
Args:
|
||||
ssid: Network SSID
|
||||
password: Network password (if encrypted)
|
||||
interface: Interface to use (default: auto-select)
|
||||
hidden: Whether network is hidden
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating connection success/failure
|
||||
"""
|
||||
try:
|
||||
success = await self.wifi_manager.connect_to_network(
|
||||
ssid=ssid,
|
||||
password=password,
|
||||
interface=interface,
|
||||
hidden=hidden
|
||||
)
|
||||
|
||||
if success:
|
||||
# Get updated status to include new connection info
|
||||
status = await self.wifi_manager.get_status()
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": f"Successfully connected to {ssid}",
|
||||
"ssid": ssid,
|
||||
"ip": status.current_ip
|
||||
}
|
||||
)
|
||||
else:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="connection_failed",
|
||||
message=f"Failed to connect to {ssid}",
|
||||
details="Connection attempt failed or timed out"
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="wifi_connect_error",
|
||||
message=f"Error connecting to {ssid}",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def disconnect(
|
||||
self,
|
||||
interface: Optional[str] = None
|
||||
) -> PM3ServiceResult:
|
||||
"""Disconnect from current network.
|
||||
|
||||
Args:
|
||||
interface: Interface to disconnect (default: first connected)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success/failure
|
||||
"""
|
||||
try:
|
||||
success = await self.wifi_manager.disconnect_from_network(interface)
|
||||
|
||||
if success:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={"message": "Disconnected from network"}
|
||||
)
|
||||
else:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="disconnect_failed",
|
||||
message="Failed to disconnect",
|
||||
details="No active connection found or disconnect failed"
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="wifi_disconnect_error",
|
||||
message="Error disconnecting from network",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def set_mode(self, mode: str) -> PM3ServiceResult:
|
||||
"""Set WiFi operation mode.
|
||||
|
||||
Args:
|
||||
mode: WiFi mode ("ap", "client", "dual", "auto", "off")
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success/failure
|
||||
"""
|
||||
try:
|
||||
# Validate and convert mode string to enum
|
||||
try:
|
||||
wifi_mode = WiFiMode(mode)
|
||||
except ValueError:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="invalid_mode",
|
||||
message=f"Invalid WiFi mode: {mode}",
|
||||
details=f"Valid modes: ap, client, dual, auto, off"
|
||||
)
|
||||
)
|
||||
|
||||
success = await self.wifi_manager.set_mode(wifi_mode)
|
||||
|
||||
if success:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": f"WiFi mode set to {mode}",
|
||||
"mode": mode
|
||||
}
|
||||
)
|
||||
else:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="mode_change_failed",
|
||||
message=f"Failed to set WiFi mode to {mode}",
|
||||
details="Mode change operation failed"
|
||||
)
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
# Handle dual mode without USB adapter
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="mode_not_supported",
|
||||
message=str(e),
|
||||
details="Dual mode requires USB WiFi adapter"
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="wifi_mode_error",
|
||||
message="Error setting WiFi mode",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def get_saved_networks(self) -> PM3ServiceResult:
|
||||
"""Get list of saved networks.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with list of saved networks
|
||||
"""
|
||||
try:
|
||||
networks = await self.wifi_manager.get_saved_networks()
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"networks": networks,
|
||||
"count": len(networks)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="saved_networks_error",
|
||||
message="Failed to get saved networks",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def forget_network(self, ssid: str) -> PM3ServiceResult:
|
||||
"""Forget a saved network.
|
||||
|
||||
Args:
|
||||
ssid: SSID of network to forget
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating success/failure
|
||||
"""
|
||||
try:
|
||||
success = await self.wifi_manager.forget_network(ssid)
|
||||
|
||||
if success:
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": f"Network '{ssid}' forgotten",
|
||||
"ssid": ssid
|
||||
}
|
||||
)
|
||||
else:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="network_not_found",
|
||||
message=f"Network '{ssid}' not found in saved networks"
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="forget_network_error",
|
||||
message=f"Error forgetting network '{ssid}'",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Server-Sent Events (SSE) endpoints."""
|
||||
199
stageDangerousPi/03-dangerous-pi/files/app/backend/sse/events.py
Normal file
199
stageDangerousPi/03-dangerous-pi/files/app/backend/sse/events.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""SSE event endpoints for real-time notifications."""
|
||||
import asyncio
|
||||
import json
|
||||
from typing import AsyncGenerator
|
||||
from fastapi import APIRouter
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Global event queue for broadcasting events
|
||||
event_queues: list[asyncio.Queue] = []
|
||||
|
||||
|
||||
class EventBroadcaster:
|
||||
"""Broadcasts events to all connected SSE clients."""
|
||||
|
||||
@staticmethod
|
||||
async def broadcast(event_type: str, data: dict):
|
||||
"""Broadcast an event to all connected clients.
|
||||
|
||||
Args:
|
||||
event_type: Type of event (e.g., "update_available", "backup_complete")
|
||||
data: Event data to send
|
||||
"""
|
||||
message = {
|
||||
"type": event_type,
|
||||
"data": data
|
||||
}
|
||||
|
||||
# Send to all connected clients
|
||||
dead_queues = []
|
||||
for queue in event_queues:
|
||||
try:
|
||||
await queue.put(message)
|
||||
except Exception:
|
||||
# Queue is dead, mark for removal
|
||||
dead_queues.append(queue)
|
||||
|
||||
# Remove dead queues
|
||||
for dead_queue in dead_queues:
|
||||
event_queues.remove(dead_queue)
|
||||
|
||||
|
||||
broadcaster = EventBroadcaster()
|
||||
|
||||
|
||||
async def event_generator() -> AsyncGenerator[dict, None]:
|
||||
"""Generate SSE events for a client connection."""
|
||||
# Create a new queue for this client
|
||||
queue = asyncio.Queue()
|
||||
event_queues.append(queue)
|
||||
|
||||
try:
|
||||
# Send initial connection event
|
||||
yield {
|
||||
"event": "connected",
|
||||
"data": json.dumps({"message": "Connected to Dangerous Pi event stream"})
|
||||
}
|
||||
|
||||
# Keep connection alive and send events
|
||||
while True:
|
||||
try:
|
||||
# Wait for events with timeout to send keep-alive
|
||||
message = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||
|
||||
yield {
|
||||
"event": message["type"],
|
||||
"data": json.dumps(message["data"])
|
||||
}
|
||||
except asyncio.TimeoutError:
|
||||
# Send keep-alive comment
|
||||
yield {
|
||||
"comment": "keep-alive"
|
||||
}
|
||||
except asyncio.CancelledError:
|
||||
# Client disconnected
|
||||
pass
|
||||
finally:
|
||||
# Remove queue when client disconnects
|
||||
if queue in event_queues:
|
||||
event_queues.remove(queue)
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
async def stream_events():
|
||||
"""SSE endpoint for streaming events to clients.
|
||||
|
||||
Events include:
|
||||
- update_available: New version available on GitHub
|
||||
- update_downloading: Update download in progress
|
||||
- update_complete: Update downloaded and ready to install
|
||||
- backup_complete: Backup operation completed
|
||||
- ups_low_battery: UPS battery below threshold
|
||||
- pm3_rebuild_required: PM3 client rebuild needed
|
||||
- command_complete: Long-running command completed
|
||||
"""
|
||||
return EventSourceResponse(event_generator())
|
||||
|
||||
|
||||
# Helper functions for sending specific event types
|
||||
|
||||
async def notify_update_available(version: str, url: str):
|
||||
"""Notify clients that an update is available."""
|
||||
await broadcaster.broadcast("update_available", {
|
||||
"version": version,
|
||||
"url": url
|
||||
})
|
||||
|
||||
|
||||
async def notify_update_progress(progress: float, status: str):
|
||||
"""Notify clients of update download progress."""
|
||||
await broadcaster.broadcast("update_downloading", {
|
||||
"progress": progress,
|
||||
"status": status
|
||||
})
|
||||
|
||||
|
||||
async def notify_backup_complete(backup_path: str, size_bytes: int):
|
||||
"""Notify clients that backup is complete."""
|
||||
await broadcaster.broadcast("backup_complete", {
|
||||
"path": backup_path,
|
||||
"size": size_bytes
|
||||
})
|
||||
|
||||
|
||||
async def notify_ups_battery(percentage: float, voltage: float):
|
||||
"""Notify clients of UPS battery status."""
|
||||
await broadcaster.broadcast("ups_battery", {
|
||||
"percentage": percentage,
|
||||
"voltage": voltage
|
||||
})
|
||||
|
||||
|
||||
async def notify_ups_warning(percentage: float, threshold: float):
|
||||
"""Notify clients of low battery warning."""
|
||||
await broadcaster.broadcast("ups_warning", {
|
||||
"percentage": percentage,
|
||||
"threshold": threshold,
|
||||
"message": f"Battery low: {percentage}%"
|
||||
})
|
||||
|
||||
|
||||
async def notify_ups_critical(percentage: float):
|
||||
"""Notify clients of critical battery level."""
|
||||
await broadcaster.broadcast("ups_critical", {
|
||||
"percentage": percentage,
|
||||
"message": f"Battery critical: {percentage}%"
|
||||
})
|
||||
|
||||
|
||||
async def notify_ups_shutdown(delay: int, percentage: float):
|
||||
"""Notify clients that shutdown has been initiated."""
|
||||
await broadcaster.broadcast("ups_shutdown", {
|
||||
"delay": delay,
|
||||
"percentage": percentage,
|
||||
"message": f"Shutdown initiated due to low battery ({percentage}%)"
|
||||
})
|
||||
|
||||
|
||||
async def notify_pm3_status(connected: bool, message: str):
|
||||
"""Notify clients of PM3 status changes."""
|
||||
await broadcaster.broadcast("pm3_status", {
|
||||
"connected": connected,
|
||||
"message": message
|
||||
})
|
||||
|
||||
|
||||
async def notify_pm3_devices(devices: list):
|
||||
"""Notify clients of PM3 device list changes."""
|
||||
await broadcaster.broadcast("pm3_devices", {
|
||||
"devices": devices,
|
||||
"count": len(devices)
|
||||
})
|
||||
|
||||
|
||||
async def notify_system_stats(
|
||||
cpu_percent: float,
|
||||
cpu_per_core: list,
|
||||
load_average: list,
|
||||
memory_percent: float,
|
||||
temperature: float | None
|
||||
):
|
||||
"""Notify clients of system stats update."""
|
||||
await broadcaster.broadcast("system_stats", {
|
||||
"cpu_percent": cpu_percent,
|
||||
"cpu_per_core": cpu_per_core,
|
||||
"load_average": load_average,
|
||||
"memory_percent": memory_percent,
|
||||
"temperature": temperature
|
||||
})
|
||||
|
||||
|
||||
async def notify_ups_config_required(model: str, message: str, hint: str):
|
||||
"""Notify clients that UPS configuration is required."""
|
||||
await broadcaster.broadcast("ups_config_required", {
|
||||
"model": model,
|
||||
"message": message,
|
||||
"hint": hint
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
"""Background workers."""
|
||||
@@ -0,0 +1,352 @@
|
||||
"""Proxmark3 worker for executing commands."""
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import os
|
||||
|
||||
from .. import config
|
||||
|
||||
|
||||
@dataclass
|
||||
class PM3CommandResult:
|
||||
"""Result from a PM3 command execution."""
|
||||
success: bool
|
||||
output: str
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class PM3Worker:
|
||||
"""Worker for executing Proxmark3 commands using the built-in pm3 module."""
|
||||
|
||||
def __init__(self, device_path: str = None):
|
||||
"""Initialize the PM3 worker.
|
||||
|
||||
Args:
|
||||
device_path: Path to PM3 device (default: from config)
|
||||
"""
|
||||
self.device_path = device_path or config.PM3_DEVICE
|
||||
self._device = None
|
||||
self._connected = False
|
||||
self._lock = asyncio.Lock()
|
||||
self._pm3_module = None
|
||||
|
||||
async def _import_pm3(self):
|
||||
"""Import the pm3 module (lazy loading)."""
|
||||
if self._pm3_module is None:
|
||||
try:
|
||||
# Setup Python path for pm3 module
|
||||
# The pm3 module is in proxmark3/client/pyscripts
|
||||
# The _pm3.so is in proxmark3/client/experimental_lib/example_py
|
||||
|
||||
# Try multiple possible locations for pyscripts
|
||||
# The pm3 module is in proxmark3/client/pyscripts
|
||||
possible_pyscripts = [
|
||||
os.path.expanduser("~/.pm3/proxmark3/client/pyscripts"), # Pi install location
|
||||
"/home/dt/.pm3/proxmark3/client/pyscripts", # Pi install (explicit path)
|
||||
os.path.expanduser("~/.pm3/client/pyscripts"), # Alternative structure
|
||||
"/usr/local/share/proxmark3/pyscripts", # System install
|
||||
"/usr/share/proxmark3/pyscripts", # System install alt
|
||||
os.path.join(os.path.dirname(__file__), "../../../.pm3-test/proxmark3/client/pyscripts"), # Dev build
|
||||
]
|
||||
|
||||
pm3_pyscripts = None
|
||||
for path in possible_pyscripts:
|
||||
pm3_py = os.path.join(path, "pm3.py")
|
||||
if os.path.exists(pm3_py):
|
||||
pm3_pyscripts = path
|
||||
break
|
||||
|
||||
if not pm3_pyscripts:
|
||||
raise ImportError(f"Could not find pm3.py in any of: {possible_pyscripts}")
|
||||
|
||||
# experimental_lib is at the same level as pyscripts (client/experimental_lib)
|
||||
pm3_lib = os.path.join(os.path.dirname(pm3_pyscripts), "experimental_lib/example_py")
|
||||
|
||||
# Add to Python path if not already there
|
||||
if pm3_pyscripts not in sys.path:
|
||||
sys.path.insert(0, pm3_pyscripts)
|
||||
if pm3_lib not in sys.path:
|
||||
sys.path.insert(0, pm3_lib)
|
||||
|
||||
# Import pm3 module
|
||||
import pm3
|
||||
self._pm3_module = pm3
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"pm3 module not found. Ensure Proxmark3 client is installed with Python support. "
|
||||
f"Error: {e}"
|
||||
)
|
||||
return self._pm3_module
|
||||
|
||||
async def _connect_internal(self):
|
||||
"""Internal connect without locking (caller must hold lock)."""
|
||||
if self._connected:
|
||||
return
|
||||
|
||||
try:
|
||||
pm3 = await self._import_pm3()
|
||||
|
||||
# Run blocking pm3.pm3() constructor in executor
|
||||
loop = asyncio.get_event_loop()
|
||||
self._device = await loop.run_in_executor(
|
||||
None,
|
||||
pm3.pm3,
|
||||
self.device_path
|
||||
)
|
||||
self._connected = True
|
||||
except Exception as e:
|
||||
raise ConnectionError(f"Failed to connect to PM3 at {self.device_path}: {e}")
|
||||
|
||||
async def connect(self):
|
||||
"""Connect to the Proxmark3 device."""
|
||||
async with self._lock:
|
||||
await self._connect_internal()
|
||||
|
||||
async def disconnect(self):
|
||||
"""Disconnect from the Proxmark3 device."""
|
||||
async with self._lock:
|
||||
if self._device:
|
||||
try:
|
||||
# Close the device connection
|
||||
# Note: pm3 module may not have explicit close, connection closes when object is deleted
|
||||
self._device = None
|
||||
self._connected = False
|
||||
except Exception as e:
|
||||
print(f"Error disconnecting from PM3: {e}")
|
||||
|
||||
async def is_connected(self) -> bool:
|
||||
"""Check if connected to PM3 device."""
|
||||
if not self._connected:
|
||||
# Try to check if device exists
|
||||
return Path(self.device_path).exists()
|
||||
return self._connected
|
||||
|
||||
async def execute_command(self, command: str) -> PM3CommandResult:
|
||||
"""Execute a Proxmark3 command.
|
||||
|
||||
Args:
|
||||
command: PM3 command to execute (e.g., "hw version")
|
||||
|
||||
Returns:
|
||||
PM3CommandResult with success status, output, and optional error
|
||||
"""
|
||||
async with self._lock:
|
||||
try:
|
||||
# Ensure we're connected (use internal method since we hold the lock)
|
||||
if not self._connected:
|
||||
await self._connect_internal()
|
||||
|
||||
if not self._device:
|
||||
return PM3CommandResult(
|
||||
success=False,
|
||||
output="",
|
||||
error="Not connected to PM3 device"
|
||||
)
|
||||
|
||||
# Execute command in executor (blocking call)
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
try:
|
||||
# Helper function to run command and get output in same executor call
|
||||
def run_command():
|
||||
try:
|
||||
self._device.console(command)
|
||||
output = self._device.grabbed_output
|
||||
return output
|
||||
except Exception as e:
|
||||
raise Exception(f"Error in run_command: {e}, device type: {type(self._device)}")
|
||||
|
||||
# Execute command and get output
|
||||
output = await loop.run_in_executor(None, run_command)
|
||||
|
||||
return PM3CommandResult(
|
||||
success=True,
|
||||
output=str(output) if output else "",
|
||||
error=None
|
||||
)
|
||||
except Exception as cmd_error:
|
||||
return PM3CommandResult(
|
||||
success=False,
|
||||
output="",
|
||||
error=f"Command execution failed: {cmd_error}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3CommandResult(
|
||||
success=False,
|
||||
output="",
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
|
||||
# Mock PM3 Worker for testing without actual hardware
|
||||
class MockPM3Worker(PM3Worker):
|
||||
"""Mock PM3 worker for testing without hardware."""
|
||||
|
||||
def __init__(self, device_path: str = None):
|
||||
super().__init__(device_path)
|
||||
self._mock_responses = {
|
||||
"hw version": "Proxmark3 RFID instrument\n client: RRG/Iceman/master/v4.14831",
|
||||
"hw status": "Device: PM3 GENERIC\nBootrom: master/v4.14831\nOS: master/v4.14831",
|
||||
"hw tune": "Measuring antenna characteristics, please wait...\n# LF antenna: 50.00 V @ 125.00 kHz",
|
||||
"hw led": "[+] LED control command executed",
|
||||
}
|
||||
|
||||
async def connect(self):
|
||||
"""Mock connect."""
|
||||
self._connected = True
|
||||
|
||||
async def disconnect(self):
|
||||
"""Mock disconnect."""
|
||||
self._connected = False
|
||||
|
||||
async def is_connected(self) -> bool:
|
||||
"""Mock connection check."""
|
||||
return True
|
||||
|
||||
async def execute_command(self, command: str) -> PM3CommandResult:
|
||||
"""Mock command execution."""
|
||||
await asyncio.sleep(0.1) # Simulate command delay
|
||||
|
||||
# Return mock response if available
|
||||
for cmd_prefix, response in self._mock_responses.items():
|
||||
if command.startswith(cmd_prefix):
|
||||
return PM3CommandResult(
|
||||
success=True,
|
||||
output=response,
|
||||
error=None
|
||||
)
|
||||
|
||||
# Default response for unknown commands
|
||||
return PM3CommandResult(
|
||||
success=True,
|
||||
output=f"Mock response for: {command}",
|
||||
error=None
|
||||
)
|
||||
|
||||
|
||||
class SubprocessPM3Worker(PM3Worker):
|
||||
"""PM3 worker using subprocess to call pm3 CLI directly.
|
||||
|
||||
This is a fallback for when the Python SWIG bindings aren't working.
|
||||
It executes pm3 CLI commands via subprocess which is more robust.
|
||||
"""
|
||||
|
||||
def __init__(self, device_path: str = None):
|
||||
super().__init__(device_path)
|
||||
self._pm3_path = None
|
||||
self._find_pm3_executable()
|
||||
|
||||
def _find_pm3_executable(self):
|
||||
"""Find the pm3 executable."""
|
||||
possible_paths = [
|
||||
"/usr/local/bin/pm3",
|
||||
os.path.expanduser("~/.pm3/proxmark3/client/proxmark3"),
|
||||
"/home/dt/.pm3/proxmark3/client/proxmark3",
|
||||
"/usr/bin/pm3",
|
||||
]
|
||||
|
||||
for path in possible_paths:
|
||||
if os.path.exists(path) and os.access(path, os.X_OK):
|
||||
self._pm3_path = path
|
||||
break
|
||||
|
||||
if not self._pm3_path:
|
||||
# Try to find in PATH
|
||||
import shutil
|
||||
pm3_in_path = shutil.which("pm3") or shutil.which("proxmark3")
|
||||
if pm3_in_path:
|
||||
self._pm3_path = pm3_in_path
|
||||
|
||||
async def connect(self):
|
||||
"""Check that pm3 executable exists and device is available."""
|
||||
async with self._lock:
|
||||
if self._connected:
|
||||
return
|
||||
|
||||
if not self._pm3_path:
|
||||
raise ConnectionError("pm3 executable not found")
|
||||
|
||||
if not Path(self.device_path).exists():
|
||||
raise ConnectionError(f"PM3 device not found at {self.device_path}")
|
||||
|
||||
self._connected = True
|
||||
|
||||
async def disconnect(self):
|
||||
"""Mark as disconnected."""
|
||||
async with self._lock:
|
||||
self._connected = False
|
||||
|
||||
async def is_connected(self) -> bool:
|
||||
"""Check if device exists."""
|
||||
return Path(self.device_path).exists() if self.device_path else False
|
||||
|
||||
async def execute_command(self, command: str) -> PM3CommandResult:
|
||||
"""Execute a PM3 command via subprocess.
|
||||
|
||||
Args:
|
||||
command: PM3 command to execute (e.g., "hw version")
|
||||
|
||||
Returns:
|
||||
PM3CommandResult with success status, output, and optional error
|
||||
"""
|
||||
async with self._lock:
|
||||
try:
|
||||
if not self._pm3_path:
|
||||
return PM3CommandResult(
|
||||
success=False,
|
||||
output="",
|
||||
error="pm3 executable not found"
|
||||
)
|
||||
|
||||
# Build command: pm3 -p /dev/ttyACM0 -c "command"
|
||||
cmd = [
|
||||
self._pm3_path,
|
||||
"-p", self.device_path,
|
||||
"-c", command
|
||||
]
|
||||
|
||||
# Run subprocess
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=30.0 # 30 second timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
return PM3CommandResult(
|
||||
success=False,
|
||||
output="",
|
||||
error="Command timed out after 30 seconds"
|
||||
)
|
||||
|
||||
output = stdout.decode("utf-8", errors="replace")
|
||||
error_output = stderr.decode("utf-8", errors="replace")
|
||||
|
||||
if process.returncode == 0:
|
||||
return PM3CommandResult(
|
||||
success=True,
|
||||
output=output,
|
||||
error=None
|
||||
)
|
||||
else:
|
||||
return PM3CommandResult(
|
||||
success=False,
|
||||
output=output,
|
||||
error=error_output or f"Command failed with exit code {process.returncode}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return PM3CommandResult(
|
||||
success=False,
|
||||
output="",
|
||||
error=str(e)
|
||||
)
|
||||
223
stageDangerousPi/03-dangerous-pi/files/app/frontend/README.md
Normal file
223
stageDangerousPi/03-dangerous-pi/files/app/frontend/README.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# Dangerous Pi Frontend
|
||||
|
||||
Modern, lightweight web interface for Proxmark3 management built with Remix.js.
|
||||
|
||||
## Features
|
||||
|
||||
- **Cyberpunk Theme** - Dark mode default with light mode support
|
||||
- **Responsive Design** - Mobile-first, works on all screen sizes
|
||||
- **Real-time Updates** - SSE integration for live status
|
||||
- **Lightweight** - Optimized for Pi Zero 2 W (< 150KB bundle)
|
||||
- **Progressive Enhancement** - Works without JavaScript
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Remix v2 (React Router)
|
||||
- **Styling**: Vanilla CSS (no framework, ~15KB)
|
||||
- **Build**: Vite
|
||||
- **TypeScript**: Full type safety
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm or yarn
|
||||
- Backend running on http://localhost:8000
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Start Development Server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Frontend will be available at http://localhost:3000
|
||||
|
||||
The dev server proxies API requests to the backend (http://localhost:8000).
|
||||
|
||||
### Build for Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Start Production Server
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
app/
|
||||
├── routes/ # Page routes
|
||||
│ ├── _index.tsx # Dashboard (/)
|
||||
│ ├── commands.tsx # PM3 Commands (/commands)
|
||||
│ ├── settings.tsx # Settings (/settings)
|
||||
│ └── logs.tsx # Command Logs (/logs)
|
||||
├── root.tsx # Root layout
|
||||
├── styles.css # Global styles
|
||||
└── entry.*.tsx # Client/Server entry points
|
||||
```
|
||||
|
||||
## Pages
|
||||
|
||||
### Dashboard (`/`)
|
||||
- System status (CPU, memory, disk)
|
||||
- PM3 connection status
|
||||
- Quick actions
|
||||
- Real-time SSE updates
|
||||
|
||||
### Commands (`/commands`)
|
||||
- Execute PM3 commands
|
||||
- Quick command buttons
|
||||
- Terminal-style output
|
||||
- Command history with ↑/↓ navigation
|
||||
|
||||
### Settings (`/settings`)
|
||||
- General settings (auth, HTTPS)
|
||||
- Wi-Fi configuration
|
||||
- Advanced options (PM3 device, BLE)
|
||||
- System actions (restart, shutdown)
|
||||
|
||||
### Logs (`/logs`)
|
||||
- Command history table
|
||||
- Export functionality (planned)
|
||||
- System logs (planned)
|
||||
|
||||
## Design System
|
||||
|
||||
### Colors (Cyberpunk)
|
||||
- **Primary**: Cyan (`#00ffff`)
|
||||
- **Secondary**: Magenta (`#ff00ff`)
|
||||
- **Accent**: Green (`#00ff88`)
|
||||
- **Background**: Dark Blue (`#0a0e1a`)
|
||||
|
||||
### Typography
|
||||
- **Sans**: System font stack (zero download)
|
||||
- **Mono**: SF Mono, Monaco, Cascadia Code, etc.
|
||||
|
||||
### Components
|
||||
- Buttons (primary, secondary, danger)
|
||||
- Cards with hover effects
|
||||
- Status badges with pulse animation
|
||||
- Terminal-style code blocks
|
||||
- Toast notifications
|
||||
- Form inputs with focus states
|
||||
|
||||
## Theme System
|
||||
|
||||
Supports three modes:
|
||||
- **dark** - Cyberpunk dark theme (default)
|
||||
- **light** - Clean light theme
|
||||
- **auto** - Follow system preference
|
||||
|
||||
Theme persisted in localStorage, toggled via header button.
|
||||
|
||||
## API Integration
|
||||
|
||||
### REST Endpoints
|
||||
```typescript
|
||||
// System
|
||||
GET /api/health
|
||||
GET /api/system/info
|
||||
POST /api/system/session/create
|
||||
GET /api/system/config
|
||||
|
||||
// PM3
|
||||
GET /api/pm3/status
|
||||
POST /api/pm3/command
|
||||
GET /api/pm3/commands/history
|
||||
```
|
||||
|
||||
### SSE Events
|
||||
```typescript
|
||||
GET /sse/events
|
||||
|
||||
// Events:
|
||||
- connected
|
||||
- pm3_status
|
||||
- update_available
|
||||
- backup_complete
|
||||
- ups_battery
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Targets
|
||||
- First Contentful Paint: < 1.5s
|
||||
- Time to Interactive: < 3s
|
||||
- Bundle Size: < 150KB gzipped
|
||||
|
||||
### Techniques
|
||||
- Server-side rendering (SSR)
|
||||
- CSS-only animations
|
||||
- System fonts (no web fonts)
|
||||
- Code splitting by route
|
||||
- Minimal dependencies
|
||||
|
||||
## Accessibility
|
||||
|
||||
- WCAG 2.1 AA compliant
|
||||
- Keyboard navigation
|
||||
- Focus indicators
|
||||
- ARIA labels
|
||||
- Screen reader tested
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Works without JavaScript
|
||||
- [ ] Mobile responsive (320px+)
|
||||
- [ ] Dark/light mode toggle
|
||||
- [ ] SSE connection
|
||||
- [ ] Command execution
|
||||
- [ ] Session management
|
||||
- [ ] Keyboard shortcuts
|
||||
- [ ] Touch targets (44x44px)
|
||||
|
||||
## Browser Support
|
||||
|
||||
- Chrome/Edge 90+
|
||||
- Firefox 88+
|
||||
- Safari 14+
|
||||
- Mobile browsers (iOS 14+, Android 10+)
|
||||
|
||||
## Deployment
|
||||
|
||||
### With Backend
|
||||
The frontend should be built and served by the backend in production:
|
||||
|
||||
```bash
|
||||
# Build frontend
|
||||
cd app/frontend
|
||||
npm run build
|
||||
|
||||
# Backend serves from build/client/
|
||||
```
|
||||
|
||||
### Standalone (Development)
|
||||
For development, run separately:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Backend
|
||||
python -m app.backend.main
|
||||
|
||||
# Terminal 2: Frontend
|
||||
cd app/frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
See [UI_GUIDELINES.md](../../UI_GUIDELINES.md) for design principles and best practices.
|
||||
|
||||
## License
|
||||
|
||||
Part of the Dangerous Pi project. Built for [Dangerous Things](https://dangerousthings.com).
|
||||
@@ -0,0 +1,219 @@
|
||||
import { Form } from "@remix-run/react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ConnectDialogProps {
|
||||
network: {
|
||||
ssid: string;
|
||||
encrypted: boolean;
|
||||
signal_strength: number;
|
||||
_scannedPassword?: string; // Pre-filled from QR scan
|
||||
} | null;
|
||||
onClose: () => void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export default function ConnectDialog({ network, onClose, isSubmitting }: ConnectDialogProps) {
|
||||
// Initialize password from QR scan if provided
|
||||
const [password, setPassword] = useState(network?._scannedPassword || "");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
// Start in hidden mode if no network is provided (user wants to enter SSID manually)
|
||||
const [hidden, setHidden] = useState(!network);
|
||||
const [customSSID, setCustomSSID] = useState(network?.ssid || "");
|
||||
|
||||
if (!network && !hidden) return null;
|
||||
|
||||
const ssid = hidden ? customSSID : network?.ssid || "";
|
||||
const needsPassword = hidden || (network?.encrypted ?? false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: "rgba(10, 14, 26, 0.9)",
|
||||
backdropFilter: "blur(8px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 200,
|
||||
padding: "var(--space-4)",
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
maxWidth: "500px",
|
||||
width: "100%",
|
||||
margin: 0,
|
||||
animation: "toast-in 250ms ease",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="card-title" style={{ marginBottom: "var(--space-4)" }}>
|
||||
<span style={{ color: "var(--color-primary)" }}>📡</span>{" "}
|
||||
Connect to WiFi
|
||||
</h3>
|
||||
|
||||
<Form method="post">
|
||||
<input type="hidden" name="_action" value="connect_network" />
|
||||
|
||||
{/* SSID Input (for hidden networks) */}
|
||||
{!hidden ? (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label className="label">Network</label>
|
||||
<div
|
||||
style={{
|
||||
padding: "var(--space-3)",
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: "var(--radius)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{network?.ssid}</div>
|
||||
<div style={{ fontSize: "0.75rem", color: "var(--color-text-muted)" }}>
|
||||
{network?.encrypted ? "🔒 Secured" : "Unsecured"}
|
||||
{" • "}
|
||||
{network?.signal_strength} dBm
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="ssid" value={network?.ssid || ""} />
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: "center", marginBottom: "var(--space-4)" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: "0.75rem", padding: "var(--space-2) var(--space-3)" }}
|
||||
onClick={() => setHidden(true)}
|
||||
>
|
||||
Connect to hidden network instead
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="ssid" className="label">
|
||||
Network Name (SSID)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ssid"
|
||||
name="ssid"
|
||||
className="input"
|
||||
placeholder="Enter network name"
|
||||
value={customSSID}
|
||||
onChange={(e) => setCustomSSID(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: "center", marginBottom: "var(--space-4)" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: "0.75rem", padding: "var(--space-2) var(--space-3)" }}
|
||||
onClick={() => setHidden(false)}
|
||||
>
|
||||
← Back to scanned networks
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Password Input */}
|
||||
{needsPassword && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="password" className="label">
|
||||
Password
|
||||
</label>
|
||||
<div style={{ position: "relative" }}>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
id="password"
|
||||
name="password"
|
||||
className="input"
|
||||
placeholder="Enter network password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required={needsPassword}
|
||||
autoComplete="off"
|
||||
style={{ paddingRight: "3rem" }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: "var(--space-3)",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "var(--color-text-secondary)",
|
||||
cursor: "pointer",
|
||||
fontSize: "1.25rem",
|
||||
}}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? "👁" : "👁🗨"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hidden Network Checkbox */}
|
||||
<input type="hidden" name="hidden" value={hidden ? "true" : "false"} />
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-6)" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
disabled={isSubmitting || (!ssid || (needsPassword && !password))}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="spinner"></span>
|
||||
<span>Connecting...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>✓</span>
|
||||
<span>Connect</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{needsPassword && (
|
||||
<p className="text-muted" style={{ fontSize: "0.75rem", marginTop: "var(--space-4)", textAlign: "center" }}>
|
||||
Your password will be securely stored
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useWebSocketEvent } from "../hooks/useWebSocket";
|
||||
|
||||
interface FirmwareInfo {
|
||||
bootrom_version: string;
|
||||
os_version: string;
|
||||
client_version: string;
|
||||
compatible: boolean;
|
||||
needs_upgrade: boolean;
|
||||
needs_downgrade: boolean;
|
||||
}
|
||||
|
||||
interface Device {
|
||||
device_id: string;
|
||||
device_path: string;
|
||||
serial_number: string | null;
|
||||
friendly_name: string | null;
|
||||
usb_vid: string;
|
||||
usb_pid: string;
|
||||
status: "connected" | "disconnected" | "in_use" | "error" | "version_mismatch" | "flashing" | "disabled";
|
||||
firmware_info: FirmwareInfo | null;
|
||||
session_id: string | null;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
interface FlashProgress {
|
||||
device_id: string;
|
||||
status: "starting" | "bootrom" | "fullimage" | "verifying" | "complete" | "error";
|
||||
progress: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface DeviceSelectorProps {
|
||||
selectedDeviceId: string | null;
|
||||
onDeviceSelect: (deviceId: string) => void;
|
||||
showIdentifyButton?: boolean;
|
||||
autoSelectSingle?: boolean;
|
||||
}
|
||||
|
||||
export default function DeviceSelector({
|
||||
selectedDeviceId,
|
||||
onDeviceSelect,
|
||||
showIdentifyButton = true,
|
||||
autoSelectSingle = true,
|
||||
}: DeviceSelectorProps) {
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [identifying, setIdentifying] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Flash-related state
|
||||
const [flashProgress, setFlashProgress] = useState<FlashProgress | null>(null);
|
||||
const [showFlashConfirm, setShowFlashConfirm] = useState<string | null>(null);
|
||||
const [flashing, setFlashing] = useState<string | null>(null);
|
||||
const [flashError, setFlashError] = useState<string | null>(null);
|
||||
|
||||
// Subscribe to flash progress events
|
||||
useWebSocketEvent("pm3_flash_progress", (data) => {
|
||||
const progress = data as FlashProgress;
|
||||
setFlashProgress(progress);
|
||||
|
||||
// Clear flashing state on complete or error
|
||||
if (progress.status === "complete" || progress.status === "error") {
|
||||
setFlashing(null);
|
||||
if (progress.status === "error") {
|
||||
setFlashError(progress.message);
|
||||
}
|
||||
// Clear progress after a delay
|
||||
setTimeout(() => setFlashProgress(null), 5000);
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch devices from API
|
||||
const fetchDevices = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/pm3/devices");
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch devices: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// API returns {devices: [...]} directly (no success field)
|
||||
const deviceList = data.devices || [];
|
||||
setDevices(deviceList);
|
||||
|
||||
// Auto-select single device if enabled
|
||||
if (autoSelectSingle && deviceList.length === 1 && !selectedDeviceId) {
|
||||
const device = deviceList[0];
|
||||
if (device.status === "connected") {
|
||||
onDeviceSelect(device.device_id);
|
||||
}
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("Error fetching devices:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch devices");
|
||||
setDevices([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch devices on mount and set up polling
|
||||
useEffect(() => {
|
||||
fetchDevices();
|
||||
const interval = setInterval(fetchDevices, 5000); // Poll every 5 seconds
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Handle device identification (LED blinking)
|
||||
const handleIdentify = async (deviceId: string) => {
|
||||
setIdentifying(deviceId);
|
||||
try {
|
||||
const response = await fetch(`/api/pm3/devices/${deviceId}/identify`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ duration: 2000 }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to identify device");
|
||||
}
|
||||
|
||||
// Keep identifying state for duration of LED blink
|
||||
setTimeout(() => setIdentifying(null), 2100);
|
||||
} catch (err) {
|
||||
console.error("Error identifying device:", err);
|
||||
setIdentifying(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle firmware flash
|
||||
const handleFlash = async (deviceId: string) => {
|
||||
setShowFlashConfirm(null);
|
||||
setFlashing(deviceId);
|
||||
setFlashError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/pm3/devices/${deviceId}/flash`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: true }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || "Flash request failed");
|
||||
}
|
||||
|
||||
// Flash started - progress will come via WebSocket
|
||||
} catch (err) {
|
||||
console.error("Error flashing device:", err);
|
||||
setFlashing(null);
|
||||
setFlashError(err instanceof Error ? err.message : "Flash failed");
|
||||
}
|
||||
};
|
||||
|
||||
// Get status color
|
||||
const getStatusColor = (device: Device): string => {
|
||||
switch (device.status) {
|
||||
case "connected":
|
||||
return "var(--color-success)";
|
||||
case "in_use":
|
||||
return "var(--color-warning)";
|
||||
case "version_mismatch":
|
||||
case "disabled":
|
||||
return "var(--color-warning)";
|
||||
case "error":
|
||||
case "disconnected":
|
||||
return "var(--color-error)";
|
||||
case "flashing":
|
||||
return "var(--color-info)";
|
||||
default:
|
||||
return "var(--color-border)";
|
||||
}
|
||||
};
|
||||
|
||||
// Get status text
|
||||
const getStatusText = (device: Device): string => {
|
||||
switch (device.status) {
|
||||
case "connected":
|
||||
return "Connected";
|
||||
case "in_use":
|
||||
return "In Use";
|
||||
case "version_mismatch":
|
||||
return "Version Mismatch";
|
||||
case "disabled":
|
||||
return "Disabled";
|
||||
case "error":
|
||||
return "Error";
|
||||
case "disconnected":
|
||||
return "Disconnected";
|
||||
case "flashing":
|
||||
return "Flashing";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
};
|
||||
|
||||
// Check if device is selectable
|
||||
const isDeviceSelectable = (device: Device): boolean => {
|
||||
return (
|
||||
device.status === "connected" ||
|
||||
(device.status === "in_use" && device.device_id === selectedDeviceId)
|
||||
);
|
||||
};
|
||||
|
||||
// Get display name for device
|
||||
const getDeviceName = (device: Device): string => {
|
||||
return device.friendly_name || device.device_path;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 className="card-title">
|
||||
<span style={{ color: "var(--color-primary)" }}>📡</span> Proxmark3 Devices
|
||||
</h3>
|
||||
<div style={{ textAlign: "center", padding: "var(--space-6)" }}>
|
||||
<span className="spinner"></span>
|
||||
<p className="text-muted" style={{ marginTop: "var(--space-3)" }}>
|
||||
Detecting devices...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card" style={{ borderColor: "var(--color-error)" }}>
|
||||
<h3 className="card-title">
|
||||
<span style={{ color: "var(--color-error)" }}>⚠</span> Device Detection Error
|
||||
</h3>
|
||||
<p className="text-muted">{error}</p>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: "var(--space-3)" }}
|
||||
onClick={fetchDevices}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (devices.length === 0) {
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 className="card-title">
|
||||
<span style={{ color: "var(--color-primary)" }}>📡</span> Proxmark3 Devices
|
||||
</h3>
|
||||
<div style={{ textAlign: "center", padding: "var(--space-6)" }}>
|
||||
<div style={{ fontSize: "3rem", marginBottom: "var(--space-3)", opacity: 0.5 }}>
|
||||
🔌
|
||||
</div>
|
||||
<p className="text-secondary" style={{ marginBottom: "var(--space-2)" }}>
|
||||
No Proxmark3 devices detected
|
||||
</p>
|
||||
<p className="text-muted" style={{ fontSize: "0.875rem" }}>
|
||||
Connect a Proxmark3 device via USB to get started
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const confirmDevice = showFlashConfirm ? devices.find(d => d.device_id === showFlashConfirm) : null;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 className="card-title">
|
||||
<span style={{ color: "var(--color-primary)" }}>📡</span> Proxmark3 Devices ({devices.length})
|
||||
</h3>
|
||||
|
||||
{/* Flash Error Alert */}
|
||||
{flashError && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "var(--space-3)",
|
||||
padding: "var(--space-3)",
|
||||
background: "rgba(239, 68, 68, 0.1)",
|
||||
borderLeft: "3px solid var(--color-error)",
|
||||
borderRadius: "var(--radius)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span>
|
||||
<strong>Flash Error:</strong> {flashError}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: "var(--space-1) var(--space-2)", fontSize: "0.75rem" }}
|
||||
onClick={() => setFlashError(null)}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-3)" }}>
|
||||
{devices.map((device) => {
|
||||
const isSelected = selectedDeviceId === device.device_id;
|
||||
const isSelectable = isDeviceSelectable(device);
|
||||
const statusColor = getStatusColor(device);
|
||||
const isFlashing = device.status === "flashing" || flashing === device.device_id;
|
||||
const deviceFlashProgress = flashProgress?.device_id === device.device_id ? flashProgress : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={device.device_id}
|
||||
style={{
|
||||
padding: "var(--space-3)",
|
||||
border: "2px solid",
|
||||
borderColor: isSelected ? "var(--color-primary)" : statusColor,
|
||||
borderRadius: "var(--radius)",
|
||||
cursor: isSelectable && !isFlashing ? "pointer" : "not-allowed",
|
||||
opacity: isSelectable || isFlashing ? 1 : 0.6,
|
||||
transition: "all 150ms ease",
|
||||
background: isSelected ? "rgba(37, 99, 235, 0.05)" : "transparent",
|
||||
position: "relative",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isSelectable && !isSelected && !isFlashing) {
|
||||
onDeviceSelect(device.device_id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Flash Progress Overlay */}
|
||||
{isFlashing && deviceFlashProgress && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: "rgba(0, 0, 0, 0.8)",
|
||||
borderRadius: "var(--radius)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
zIndex: 10,
|
||||
padding: "var(--space-4)",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "var(--color-info)", fontSize: "2rem", marginBottom: "var(--space-2)" }}>
|
||||
{deviceFlashProgress.status === "complete" ? "✓" : deviceFlashProgress.status === "error" ? "✗" : "⚡"}
|
||||
</div>
|
||||
<div style={{ color: "white", fontWeight: 600, marginBottom: "var(--space-2)" }}>
|
||||
{deviceFlashProgress.status === "complete" ? "Flash Complete" :
|
||||
deviceFlashProgress.status === "error" ? "Flash Failed" : "Flashing Firmware..."}
|
||||
</div>
|
||||
<div style={{ width: "100%", marginBottom: "var(--space-2)" }}>
|
||||
<div
|
||||
style={{
|
||||
height: "8px",
|
||||
background: "rgba(255,255,255,0.2)",
|
||||
borderRadius: "4px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${deviceFlashProgress.progress}%`,
|
||||
background: deviceFlashProgress.status === "error" ? "var(--color-error)" :
|
||||
deviceFlashProgress.status === "complete" ? "var(--color-success)" : "var(--color-info)",
|
||||
transition: "width 300ms ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ color: "rgba(255,255,255,0.7)", fontSize: "0.875rem", textAlign: "center" }}>
|
||||
{deviceFlashProgress.message}
|
||||
</div>
|
||||
<div style={{ color: "rgba(255,255,255,0.5)", fontSize: "0.75rem", marginTop: "var(--space-1)" }}>
|
||||
{deviceFlashProgress.progress}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "var(--space-3)" }}>
|
||||
{/* Device Info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{/* Device Name */}
|
||||
<div style={{ fontWeight: 600, fontSize: "1.1rem", marginBottom: "var(--space-1)" }}>
|
||||
{isSelected && (
|
||||
<span style={{ color: "var(--color-primary)", marginRight: "var(--space-2)" }}>
|
||||
▸
|
||||
</span>
|
||||
)}
|
||||
{getDeviceName(device)}
|
||||
</div>
|
||||
|
||||
{/* Device Path & Serial */}
|
||||
<div style={{ fontSize: "0.875rem", color: "var(--color-text-muted)", marginBottom: "var(--space-2)" }}>
|
||||
<span style={{ fontFamily: "var(--font-mono)" }}>{device.device_path}</span>
|
||||
{device.serial_number && (
|
||||
<>
|
||||
{" • "}
|
||||
<span>SN: {device.serial_number}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status & Firmware Version */}
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-2)", alignItems: "center" }}>
|
||||
<span
|
||||
className={`badge ${
|
||||
device.status === "connected"
|
||||
? "badge-success"
|
||||
: device.status === "error" || device.status === "disconnected"
|
||||
? "badge-error"
|
||||
: device.status === "flashing"
|
||||
? "badge-info"
|
||||
: "badge-warning"
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true">●</span>
|
||||
{getStatusText(device)}
|
||||
</span>
|
||||
|
||||
{device.firmware_info && (
|
||||
<span className="text-muted" style={{ fontSize: "0.75rem" }}>
|
||||
{device.firmware_info.os_version}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{device.status === "in_use" && device.device_id !== selectedDeviceId && (
|
||||
<span className="text-muted" style={{ fontSize: "0.75rem" }}>
|
||||
🔒 Session Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Version Mismatch Warning with Flash Button */}
|
||||
{device.firmware_info && !device.firmware_info.compatible && device.status !== "flashing" && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "var(--space-2)",
|
||||
padding: "var(--space-2)",
|
||||
background: "rgba(217, 119, 6, 0.1)",
|
||||
borderLeft: "3px solid var(--color-warning)",
|
||||
borderRadius: "var(--radius)",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", flexWrap: "wrap", gap: "var(--space-2)" }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: "var(--space-1)" }}>
|
||||
⚠ Firmware Version Mismatch
|
||||
</div>
|
||||
<div className="text-muted">
|
||||
Device: {device.firmware_info.os_version} •
|
||||
Expected: {device.firmware_info.client_version}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-warning"
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
padding: "var(--space-1) var(--space-2)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowFlashConfirm(device.device_id);
|
||||
}}
|
||||
disabled={flashing !== null}
|
||||
>
|
||||
Flash Firmware
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Identify Button */}
|
||||
{showIdentifyButton && device.status === "connected" && !isFlashing && (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{
|
||||
fontSize: "0.875rem",
|
||||
padding: "var(--space-2) var(--space-3)",
|
||||
minWidth: "44px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleIdentify(device.device_id);
|
||||
}}
|
||||
disabled={identifying === device.device_id}
|
||||
aria-label="Identify device with LED blink"
|
||||
>
|
||||
{identifying === device.device_id ? (
|
||||
<>
|
||||
<span className="spinner"></span>
|
||||
<span>Blinking...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>💡</span>
|
||||
<span>Identify</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Selection Info */}
|
||||
{selectedDeviceId && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "var(--space-4)",
|
||||
padding: "var(--space-3)",
|
||||
background: "var(--color-bg)",
|
||||
borderRadius: "var(--radius)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--color-success)" }}>✓</span> Selected device will be used for all commands
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flash Confirmation Dialog */}
|
||||
{showFlashConfirm && confirmDevice && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: "rgba(0, 0, 0, 0.75)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={() => setShowFlashConfirm(null)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "var(--color-card)",
|
||||
borderRadius: "var(--radius)",
|
||||
padding: "var(--space-6)",
|
||||
maxWidth: "450px",
|
||||
width: "90%",
|
||||
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 style={{ marginTop: 0, marginBottom: "var(--space-4)" }}>
|
||||
<span style={{ color: "var(--color-warning)" }}>⚡</span> Flash Firmware
|
||||
</h3>
|
||||
|
||||
<p style={{ marginBottom: "var(--space-4)" }}>
|
||||
You are about to flash firmware to this device. This process will:
|
||||
</p>
|
||||
|
||||
<ul style={{ marginBottom: "var(--space-4)", paddingLeft: "var(--space-4)" }}>
|
||||
<li>Update bootrom and fullimage</li>
|
||||
<li>Make the device temporarily unavailable</li>
|
||||
<li>Take approximately 1-2 minutes</li>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
borderRadius: "var(--radius)",
|
||||
padding: "var(--space-3)",
|
||||
marginBottom: "var(--space-4)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: "var(--space-2)" }}>
|
||||
<strong>Device:</strong> {getDeviceName(confirmDevice)}
|
||||
</div>
|
||||
<div style={{ marginBottom: "var(--space-2)" }}>
|
||||
<strong>Path:</strong> {confirmDevice.device_path}
|
||||
</div>
|
||||
{confirmDevice.firmware_info && (
|
||||
<>
|
||||
<div style={{ marginBottom: "var(--space-2)" }}>
|
||||
<strong>Current:</strong> {confirmDevice.firmware_info.os_version}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Target:</strong> {confirmDevice.firmware_info.client_version}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(239, 68, 68, 0.1)",
|
||||
borderLeft: "3px solid var(--color-error)",
|
||||
borderRadius: "var(--radius)",
|
||||
padding: "var(--space-3)",
|
||||
marginBottom: "var(--space-4)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<strong>Warning:</strong> Do not disconnect the device during flashing.
|
||||
Interruption may require recovery mode.
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "var(--space-3)", justifyContent: "flex-end" }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setShowFlashConfirm(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-warning"
|
||||
onClick={() => handleFlash(showFlashConfirm)}
|
||||
>
|
||||
Flash Firmware
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* HeaderWidgets - Display status widgets in the header area
|
||||
*
|
||||
* Shows notifications from:
|
||||
* - System managers (UPS, PM3, Updates)
|
||||
* - Enabled plugins
|
||||
*
|
||||
* Supports:
|
||||
* - Multiple severity levels (info, warning, error, success)
|
||||
* - Dismissible widgets
|
||||
* - Action buttons
|
||||
* - Auto-expiry
|
||||
* - Real-time updates via WebSocket
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { useWebSocketEvent } from "~/hooks/useWebSocket";
|
||||
|
||||
interface HeaderWidget {
|
||||
id: string;
|
||||
source: string;
|
||||
severity: "info" | "warning" | "error" | "success";
|
||||
message: string;
|
||||
dismissible: boolean;
|
||||
icon?: string;
|
||||
action_label?: string;
|
||||
action_url?: string;
|
||||
created_at: string;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
interface HeaderWidgetsProps {
|
||||
apiBase?: string;
|
||||
}
|
||||
|
||||
export function HeaderWidgets({ apiBase = "/api" }: HeaderWidgetsProps) {
|
||||
const [widgets, setWidgets] = useState<HeaderWidget[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Fetch widgets from API
|
||||
const fetchWidgets = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/system/widgets`);
|
||||
if (response.ok) {
|
||||
const data: HeaderWidget[] = await response.json();
|
||||
setWidgets(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load widgets:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiBase]);
|
||||
|
||||
// WebSocket event subscriptions for real-time updates
|
||||
useWebSocketEvent("widget_added", () => {
|
||||
// Refresh widgets when a new one is added
|
||||
fetchWidgets();
|
||||
});
|
||||
|
||||
useWebSocketEvent("widget_removed", (data) => {
|
||||
// Remove widget from local state
|
||||
const widgetId = data.widget_id as string;
|
||||
setWidgets((prev) => prev.filter((w) => w.id !== widgetId));
|
||||
});
|
||||
|
||||
// Initial fetch and periodic refresh (30s fallback)
|
||||
useEffect(() => {
|
||||
fetchWidgets();
|
||||
const interval = setInterval(fetchWidgets, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchWidgets]);
|
||||
|
||||
// Dismiss a widget
|
||||
const dismissWidget = async (widgetId: string) => {
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/system/widgets/${encodeURIComponent(widgetId)}/dismiss`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setWidgets((prev) => prev.filter((w) => w.id !== widgetId));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to dismiss widget:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render anything if loading or no widgets
|
||||
if (loading || widgets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="header-widgets" role="region" aria-label="Notifications">
|
||||
{widgets.map((widget) => (
|
||||
<div
|
||||
key={widget.id}
|
||||
className={`widget widget-${widget.severity}`}
|
||||
role="alert"
|
||||
aria-live={widget.severity === "error" ? "assertive" : "polite"}
|
||||
>
|
||||
{widget.icon && (
|
||||
<span className="widget-icon" aria-hidden="true">
|
||||
{widget.icon}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="widget-message">{widget.message}</span>
|
||||
|
||||
{widget.action_url && widget.action_label && (
|
||||
<Link to={widget.action_url} className="widget-action">
|
||||
{widget.action_label}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{widget.dismissible && (
|
||||
<button
|
||||
onClick={() => dismissWidget(widget.id)}
|
||||
className="widget-dismiss"
|
||||
aria-label={`Dismiss: ${widget.message}`}
|
||||
title="Dismiss"
|
||||
>
|
||||
<DismissIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DismissIcon() {
|
||||
return (
|
||||
<svg
|
||||
className="dismiss-icon"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M2 2L10 10M10 2L2 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeaderWidgets;
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* PowerWidget - Header battery/power status indicator
|
||||
*
|
||||
* Displays UPS/battery status in the header with:
|
||||
* - Battery icon with fill level
|
||||
* - Percentage display
|
||||
* - Charging indicator
|
||||
* - Real-time updates via WebSocket
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useWebSocketEvent } from "~/hooks/useWebSocket";
|
||||
|
||||
interface UPSStatus {
|
||||
battery_percentage: number;
|
||||
voltage: number;
|
||||
current: number;
|
||||
power_source: "ac" | "battery" | "unknown";
|
||||
battery_status: "charging" | "discharging" | "full" | "critical" | "unknown";
|
||||
time_remaining: number | null;
|
||||
temperature: number | null;
|
||||
last_updated: string | null;
|
||||
is_available: boolean;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
interface PowerWidgetProps {
|
||||
apiBase?: string;
|
||||
}
|
||||
|
||||
export function PowerWidget({ apiBase = "/api" }: PowerWidgetProps) {
|
||||
const [status, setStatus] = useState<UPSStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch UPS status from API
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/system/ups/status`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const data: UPSStatus = await response.json();
|
||||
setStatus(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiBase]);
|
||||
|
||||
// WebSocket event subscriptions for real-time updates
|
||||
useWebSocketEvent("ups_battery", (data) => {
|
||||
setStatus((prev) => prev ? {
|
||||
...prev,
|
||||
battery_percentage: data.percentage as number,
|
||||
voltage: data.voltage as number,
|
||||
} : prev);
|
||||
});
|
||||
|
||||
useWebSocketEvent("ups_warning", () => {
|
||||
// Trigger a full refresh on warnings
|
||||
fetchStatus();
|
||||
});
|
||||
|
||||
useWebSocketEvent("ups_critical", () => {
|
||||
fetchStatus();
|
||||
});
|
||||
|
||||
// Initial fetch and fallback polling (120s since WebSocket is primary)
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
const pollInterval = setInterval(fetchStatus, 120000);
|
||||
return () => clearInterval(pollInterval);
|
||||
}, [fetchStatus]);
|
||||
|
||||
// Don't render if UPS is not available
|
||||
if (!loading && (!status || !status.is_available)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="power-widget power-widget--loading" title="Loading power status...">
|
||||
<div className="power-widget__icon">
|
||||
<BatteryIcon percentage={50} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const percentage = Math.round(status?.battery_percentage ?? 0);
|
||||
const isCharging = status?.power_source === "ac" || status?.battery_status === "charging";
|
||||
const isCritical = percentage <= 10;
|
||||
const isLow = percentage <= 20;
|
||||
const isFull = percentage >= 95 && isCharging;
|
||||
|
||||
// Determine status color
|
||||
let statusClass = "power-widget--normal";
|
||||
if (isCritical) {
|
||||
statusClass = "power-widget--critical";
|
||||
} else if (isLow) {
|
||||
statusClass = "power-widget--low";
|
||||
} else if (isFull) {
|
||||
statusClass = "power-widget--full";
|
||||
} else if (isCharging) {
|
||||
statusClass = "power-widget--charging";
|
||||
}
|
||||
|
||||
// Build tooltip
|
||||
const tooltipParts = [
|
||||
`Battery: ${percentage}%`,
|
||||
`Source: ${status?.power_source === "ac" ? "AC Power" : "Battery"}`,
|
||||
];
|
||||
if (status?.voltage && status.voltage > 0) {
|
||||
// Voltage comes in mV from API, convert to V for display
|
||||
tooltipParts.push(`Voltage: ${(status.voltage / 1000).toFixed(2)}V`);
|
||||
}
|
||||
if (status?.current !== undefined && status?.current !== null) {
|
||||
// Current in Amps - positive = charging, negative = discharging
|
||||
const absCurrentMa = Math.abs(status.current * 1000).toFixed(0);
|
||||
const direction = status.current >= 0 ? "charging" : "draw";
|
||||
tooltipParts.push(`Current: ${absCurrentMa}mA ${direction}`);
|
||||
}
|
||||
if (status?.time_remaining) {
|
||||
// Format time remaining nicely
|
||||
const mins = status.time_remaining;
|
||||
if (mins < 60) {
|
||||
tooltipParts.push(`Est. runtime: ${mins} min`);
|
||||
} else {
|
||||
const hours = Math.floor(mins / 60);
|
||||
const remainMins = mins % 60;
|
||||
tooltipParts.push(`Est. runtime: ${hours}h ${remainMins}m`);
|
||||
}
|
||||
}
|
||||
const tooltip = tooltipParts.join("\n");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`power-widget ${statusClass}`}
|
||||
title={tooltip}
|
||||
role="status"
|
||||
aria-label={`Battery at ${percentage}%${isCharging ? ", plugged in" : ", on battery"}`}
|
||||
>
|
||||
<div className="power-widget__icon">
|
||||
<BatteryIcon percentage={percentage} isCharging={isCharging} />
|
||||
</div>
|
||||
<span className="power-widget__percentage">{percentage}%</span>
|
||||
{isCharging && (
|
||||
<span className="power-widget__plug-indicator" aria-label="Plugged in" title="AC Power">
|
||||
<PlugIcon />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BatteryIconProps {
|
||||
percentage: number;
|
||||
isCharging?: boolean;
|
||||
}
|
||||
|
||||
function BatteryIcon({ percentage, isCharging = false }: BatteryIconProps) {
|
||||
// Calculate fill width (0-100%)
|
||||
const fillWidth = Math.max(0, Math.min(100, percentage));
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="battery-icon"
|
||||
viewBox="0 0 24 14"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Battery body outline */}
|
||||
<rect
|
||||
x="0.5"
|
||||
y="0.5"
|
||||
width="20"
|
||||
height="13"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
/>
|
||||
|
||||
{/* Battery terminal */}
|
||||
<rect
|
||||
x="21"
|
||||
y="4"
|
||||
width="3"
|
||||
height="6"
|
||||
rx="1"
|
||||
fill="currentColor"
|
||||
/>
|
||||
|
||||
{/* Battery fill */}
|
||||
<rect
|
||||
className="battery-icon__fill"
|
||||
x="2"
|
||||
y="2"
|
||||
width={Math.max(0, (fillWidth / 100) * 17)}
|
||||
height="10"
|
||||
rx="1"
|
||||
fill="currentColor"
|
||||
/>
|
||||
|
||||
{/* Charging bolt */}
|
||||
{isCharging && (
|
||||
<path
|
||||
className="battery-icon__bolt"
|
||||
d="M12 1L8 7H11L9 13L14 6H11L12 1Z"
|
||||
fill="var(--color-bg)"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PlugIcon() {
|
||||
return (
|
||||
<svg
|
||||
className="plug-icon"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Plug prongs */}
|
||||
<rect x="3" y="0" width="2" height="4" rx="0.5" fill="currentColor" />
|
||||
<rect x="7" y="0" width="2" height="4" rx="0.5" fill="currentColor" />
|
||||
{/* Plug body */}
|
||||
<rect x="2" y="3" width="8" height="5" rx="1" fill="currentColor" />
|
||||
{/* Cord */}
|
||||
<rect x="5" y="8" width="2" height="4" rx="0.5" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default PowerWidget;
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface WifiCredentials {
|
||||
ssid: string;
|
||||
password: string;
|
||||
security: string;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
interface QRScannerProps {
|
||||
onScan: (credentials: WifiCredentials) => void;
|
||||
onClose: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse WiFi QR code string
|
||||
* Format: WIFI:T:WPA;S:NetworkName;P:Password;H:true;;
|
||||
*/
|
||||
function parseWifiQR(text: string): WifiCredentials | null {
|
||||
if (!text.startsWith("WIFI:")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: WifiCredentials = {
|
||||
ssid: "",
|
||||
password: "",
|
||||
security: "WPA",
|
||||
hidden: false,
|
||||
};
|
||||
|
||||
// Remove WIFI: prefix and trailing ;;
|
||||
const data = text.slice(5).replace(/;;$/, "");
|
||||
|
||||
// Parse key:value pairs separated by ;
|
||||
const parts = data.split(";");
|
||||
for (const part of parts) {
|
||||
const colonIdx = part.indexOf(":");
|
||||
if (colonIdx === -1) continue;
|
||||
|
||||
const key = part.slice(0, colonIdx).toUpperCase();
|
||||
const value = part.slice(colonIdx + 1);
|
||||
|
||||
switch (key) {
|
||||
case "S":
|
||||
result.ssid = value;
|
||||
break;
|
||||
case "P":
|
||||
result.password = value;
|
||||
break;
|
||||
case "T":
|
||||
result.security = value.toUpperCase();
|
||||
break;
|
||||
case "H":
|
||||
result.hidden = value.toLowerCase() === "true";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Unescape special characters
|
||||
result.ssid = result.ssid.replace(/\\;/g, ";").replace(/\\:/g, ":").replace(/\\\\/g, "\\");
|
||||
result.password = result.password.replace(/\\;/g, ";").replace(/\\:/g, ":").replace(/\\\\/g, "\\");
|
||||
|
||||
return result.ssid ? result : null;
|
||||
}
|
||||
|
||||
export default function QRScanner({ onScan, onClose, onError }: QRScannerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cameraReady, setCameraReady] = useState(false);
|
||||
const scannerRef = useRef<any>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const isRunningRef = useRef(false);
|
||||
|
||||
// Safe stop function that checks scanner state
|
||||
const safeStopScanner = async () => {
|
||||
if (scannerRef.current && isRunningRef.current) {
|
||||
try {
|
||||
await scannerRef.current.stop();
|
||||
isRunningRef.current = false;
|
||||
} catch (err) {
|
||||
// Ignore stop errors - scanner may already be stopped
|
||||
console.debug("Scanner stop (already stopped):", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
let animationFrameId: number;
|
||||
|
||||
const startScanner = async () => {
|
||||
try {
|
||||
// Import html5-qrcode dynamically (client-side only)
|
||||
const { Html5Qrcode } = await import("html5-qrcode");
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
const scanner = new Html5Qrcode("qr-reader");
|
||||
scannerRef.current = scanner;
|
||||
|
||||
await scanner.start(
|
||||
{ facingMode: "environment" },
|
||||
{
|
||||
fps: 10,
|
||||
qrbox: { width: 250, height: 250 },
|
||||
},
|
||||
(decodedText) => {
|
||||
const credentials = parseWifiQR(decodedText);
|
||||
if (credentials) {
|
||||
isRunningRef.current = false;
|
||||
scanner.stop().catch(console.error);
|
||||
onScan(credentials);
|
||||
} else {
|
||||
setError("Not a valid WiFi QR code");
|
||||
setTimeout(() => setError(null), 2000);
|
||||
}
|
||||
},
|
||||
() => {} // Ignore scan failures
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
isRunningRef.current = true;
|
||||
setScanning(true);
|
||||
setCameraReady(true);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("QR Scanner error:", err);
|
||||
const errorMessage = err.message || "Failed to access camera";
|
||||
setError(errorMessage);
|
||||
onError?.(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
startScanner();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
safeStopScanner();
|
||||
};
|
||||
}, [onScan, onError]);
|
||||
|
||||
const handleClose = () => {
|
||||
safeStopScanner();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: "rgba(10, 14, 26, 0.95)",
|
||||
backdropFilter: "blur(8px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 250,
|
||||
padding: "var(--space-4)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "400px",
|
||||
width: "100%",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginBottom: "var(--space-4)", color: "var(--color-text)" }}>
|
||||
<span style={{ color: "var(--color-primary)" }}>📷</span> Scan WiFi QR Code
|
||||
</h3>
|
||||
|
||||
<p style={{
|
||||
fontSize: "0.875rem",
|
||||
color: "var(--color-text-muted)",
|
||||
marginBottom: "var(--space-4)"
|
||||
}}>
|
||||
Point camera at a WiFi QR code to connect automatically
|
||||
</p>
|
||||
|
||||
{/* QR Scanner Container */}
|
||||
<div
|
||||
id="qr-reader"
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "350px",
|
||||
margin: "0 auto var(--space-4)",
|
||||
borderRadius: "var(--radius)",
|
||||
overflow: "hidden",
|
||||
background: "#000",
|
||||
minHeight: "300px",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Status Messages */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "var(--space-3)",
|
||||
background: "rgba(255, 107, 107, 0.1)",
|
||||
border: "1px solid var(--color-error)",
|
||||
borderRadius: "var(--radius)",
|
||||
marginBottom: "var(--space-4)",
|
||||
color: "var(--color-error)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!cameraReady && !error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "var(--space-3)",
|
||||
marginBottom: "var(--space-4)",
|
||||
color: "var(--color-text-muted)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<span className="spinner" style={{ marginRight: "var(--space-2)" }}></span>
|
||||
Initializing camera...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hint */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--color-text-muted)",
|
||||
marginBottom: "var(--space-4)",
|
||||
padding: "var(--space-3)",
|
||||
background: "var(--color-bg)",
|
||||
borderRadius: "var(--radius)",
|
||||
}}
|
||||
>
|
||||
<strong>Tip:</strong> Most routers have a WiFi QR code on a sticker.
|
||||
You can also generate one at{" "}
|
||||
<a
|
||||
href="https://qifi.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
qifi.org
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={handleClose}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { RemixBrowser } from "@remix-run/react";
|
||||
import { startTransition, StrictMode } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<RemixBrowser />
|
||||
</StrictMode>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { AppLoadContext, EntryContext } from "@remix-run/node";
|
||||
import { RemixServer } from "@remix-run/react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext,
|
||||
loadContext: AppLoadContext
|
||||
) {
|
||||
let markup = renderToString(
|
||||
<RemixServer context={remixContext} url={request.url} />
|
||||
);
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
|
||||
return new Response("<!DOCTYPE html>" + markup, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* WebSocket hook for real-time event subscriptions.
|
||||
*
|
||||
* Features:
|
||||
* - Single shared connection per browser tab
|
||||
* - Exponential backoff reconnection (1s -> 2s -> 4s -> ... -> 30s max)
|
||||
* - Component-level event subscriptions
|
||||
* - Connection state management
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
// Connection states
|
||||
export type ConnectionState = "connecting" | "connected" | "disconnected";
|
||||
|
||||
// Event message format from backend
|
||||
interface WebSocketMessage {
|
||||
type: "event";
|
||||
event: string;
|
||||
data: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Event handler type
|
||||
type EventHandler = (data: Record<string, unknown>) => void;
|
||||
|
||||
// Singleton WebSocket manager
|
||||
class WebSocketManager {
|
||||
private static instance: WebSocketManager | null = null;
|
||||
private ws: WebSocket | null = null;
|
||||
private subscribers: Map<string, Set<EventHandler>> = new Map();
|
||||
private stateListeners: Set<(state: ConnectionState) => void> = new Set();
|
||||
private reconnectAttempts = 0;
|
||||
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private state: ConnectionState = "disconnected";
|
||||
|
||||
// Backoff configuration
|
||||
private readonly INITIAL_DELAY = 1000; // 1 second
|
||||
private readonly MAX_DELAY = 30000; // 30 seconds
|
||||
private readonly BACKOFF_MULTIPLIER = 2;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): WebSocketManager {
|
||||
if (!WebSocketManager.instance) {
|
||||
WebSocketManager.instance = new WebSocketManager();
|
||||
}
|
||||
return WebSocketManager.instance;
|
||||
}
|
||||
|
||||
private getWebSocketUrl(): string {
|
||||
if (typeof window === "undefined") {
|
||||
return "ws://localhost:8000/ws/events";
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const host = window.location.hostname;
|
||||
const port = window.location.port || (protocol === "wss:" ? "443" : "80");
|
||||
|
||||
return `${protocol}//${host}:${port}/ws/events`;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (
|
||||
this.ws?.readyState === WebSocket.OPEN ||
|
||||
this.ws?.readyState === WebSocket.CONNECTING
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState("connecting");
|
||||
const url = this.getWebSocketUrl();
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log("[WS] Connected to", url);
|
||||
this.reconnectAttempts = 0;
|
||||
this.setState("connected");
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const message: WebSocketMessage = JSON.parse(event.data);
|
||||
if (message.type === "event") {
|
||||
this.notifySubscribers(message.event, message.data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[WS] Failed to parse message:", e);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
console.log(`[WS] Disconnected (code: ${event.code})`);
|
||||
this.ws = null;
|
||||
this.setState("disconnected");
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error("[WS] Error:", error);
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("[WS] Connection failed:", e);
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
// Only reconnect if there are still subscribers
|
||||
if (this.getTotalSubscriberCount() === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
}
|
||||
|
||||
const delay = Math.min(
|
||||
this.INITIAL_DELAY *
|
||||
Math.pow(this.BACKOFF_MULTIPLIER, this.reconnectAttempts),
|
||||
this.MAX_DELAY
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[WS] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1})`
|
||||
);
|
||||
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
this.reconnectAttempts++;
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.setState("disconnected");
|
||||
}
|
||||
|
||||
subscribe(eventType: string, handler: EventHandler): () => void {
|
||||
if (!this.subscribers.has(eventType)) {
|
||||
this.subscribers.set(eventType, new Set());
|
||||
}
|
||||
this.subscribers.get(eventType)!.add(handler);
|
||||
|
||||
// Start connection if this is first subscriber
|
||||
if (this.getTotalSubscriberCount() === 1) {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const handlers = this.subscribers.get(eventType);
|
||||
if (handlers) {
|
||||
handlers.delete(handler);
|
||||
if (handlers.size === 0) {
|
||||
this.subscribers.delete(eventType);
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect if no subscribers left
|
||||
if (this.getTotalSubscriberCount() === 0) {
|
||||
this.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
subscribeToState(listener: (state: ConnectionState) => void): () => void {
|
||||
this.stateListeners.add(listener);
|
||||
// Immediately notify of current state
|
||||
listener(this.state);
|
||||
|
||||
return () => {
|
||||
this.stateListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private notifySubscribers(
|
||||
eventType: string,
|
||||
data: Record<string, unknown>
|
||||
): void {
|
||||
const handlers = this.subscribers.get(eventType);
|
||||
if (handlers) {
|
||||
handlers.forEach((handler) => {
|
||||
try {
|
||||
handler(data);
|
||||
} catch (e) {
|
||||
console.error(`[WS] Handler error for ${eventType}:`, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private setState(newState: ConnectionState): void {
|
||||
this.state = newState;
|
||||
this.stateListeners.forEach((listener) => listener(newState));
|
||||
}
|
||||
|
||||
private getTotalSubscriberCount(): number {
|
||||
let count = 0;
|
||||
this.subscribers.forEach((handlers) => {
|
||||
count += handlers.size;
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
getState(): ConnectionState {
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to subscribe to WebSocket events.
|
||||
*
|
||||
* @param eventType - The event type to subscribe to (e.g., "system_stats", "ups_battery")
|
||||
* @param handler - Callback function called when event is received
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* useWebSocketEvent("system_stats", (data) => {
|
||||
* console.log("System stats:", data);
|
||||
* });
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useWebSocketEvent(
|
||||
eventType: string,
|
||||
handler: EventHandler
|
||||
): void {
|
||||
const handlerRef = useRef(handler);
|
||||
handlerRef.current = handler;
|
||||
|
||||
useEffect(() => {
|
||||
const wsManager = WebSocketManager.getInstance();
|
||||
|
||||
const wrappedHandler: EventHandler = (data) => {
|
||||
handlerRef.current(data);
|
||||
};
|
||||
|
||||
const unsubscribe = wsManager.subscribe(eventType, wrappedHandler);
|
||||
|
||||
return unsubscribe;
|
||||
}, [eventType]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get current WebSocket connection state.
|
||||
*
|
||||
* @returns Current connection state: "connecting" | "connected" | "disconnected"
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function StatusIndicator() {
|
||||
* const connectionState = useWebSocketState();
|
||||
* return <span>{connectionState}</span>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useWebSocketState(): ConnectionState {
|
||||
const [state, setState] = useState<ConnectionState>("disconnected");
|
||||
|
||||
useEffect(() => {
|
||||
const wsManager = WebSocketManager.getInstance();
|
||||
const unsubscribe = wsManager.subscribeToState(setState);
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
158
stageDangerousPi/03-dangerous-pi/files/app/frontend/app/root.tsx
Normal file
158
stageDangerousPi/03-dangerous-pi/files/app/frontend/app/root.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { LinksFunction } from "@remix-run/node";
|
||||
import {
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
useLocation,
|
||||
} from "@remix-run/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import styles from "./styles.css?url";
|
||||
import { PowerWidget } from "./components/PowerWidget";
|
||||
import { HeaderWidgets } from "./components/HeaderWidgets";
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{ rel: "stylesheet", href: styles },
|
||||
];
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5" />
|
||||
<meta name="theme-color" content="#0a0e1a" />
|
||||
<meta name="description" content="Dangerous Pi - Proxmark3 Management Interface" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const location = useLocation();
|
||||
const [theme, setTheme] = useState<"auto" | "dark" | "light">("dark");
|
||||
|
||||
// Initialize theme (default to dark, honor system preference if available)
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem("theme") as "auto" | "dark" | "light" | null;
|
||||
|
||||
if (savedTheme) {
|
||||
setTheme(savedTheme);
|
||||
document.documentElement.setAttribute("data-theme", savedTheme);
|
||||
} else {
|
||||
// Default to dark for Dangerous Things cyberpunk aesthetic
|
||||
setTheme("dark");
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const themes: Array<"auto" | "dark" | "light"> = ["dark", "auto", "light"];
|
||||
const currentIndex = themes.indexOf(theme);
|
||||
const nextTheme = themes[(currentIndex + 1) % themes.length];
|
||||
|
||||
setTheme(nextTheme);
|
||||
localStorage.setItem("theme", nextTheme);
|
||||
document.documentElement.setAttribute("data-theme", nextTheme);
|
||||
};
|
||||
|
||||
const navLinks = [
|
||||
{ to: "/", label: "Dashboard", icon: "◈" },
|
||||
{ to: "/commands", label: "Commands", icon: "▶" },
|
||||
{ to: "/settings", label: "Settings", icon: "⚙" },
|
||||
{ to: "/updates", label: "Updates", icon: "🔄" },
|
||||
{ to: "/logs", label: "Logs", icon: "≡" },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="header">
|
||||
<div className="header-content">
|
||||
<a href="/" className="logo">
|
||||
<div className="logo-icon"></div>
|
||||
<span>Dangerous Pi</span>
|
||||
</a>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
<PowerWidget />
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="btn-secondary"
|
||||
style={{ minWidth: "44px", padding: "0.5rem" }}
|
||||
aria-label="Toggle theme"
|
||||
title={`Current theme: ${theme}`}
|
||||
>
|
||||
{theme === "dark" ? "◐" : theme === "light" ? "○" : "◑"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Header Widgets - Notifications from system and plugins */}
|
||||
<HeaderWidgets />
|
||||
|
||||
{/* Desktop Navigation - Hidden on mobile */}
|
||||
<nav className="nav" style={{ display: "none" }}>
|
||||
{navLinks.map((link) => (
|
||||
<a
|
||||
key={link.to}
|
||||
href={link.to}
|
||||
className={`nav-link ${location.pathname === link.to ? "active" : ""}`}
|
||||
>
|
||||
<span style={{ fontSize: "1.25rem" }}>{link.icon}</span>
|
||||
<span>{link.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<main className="main">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Mobile Bottom Navigation */}
|
||||
<nav className="nav">
|
||||
{navLinks.map((link) => (
|
||||
<a
|
||||
key={link.to}
|
||||
href={link.to}
|
||||
className={`nav-link ${location.pathname === link.to ? "active" : ""}`}
|
||||
>
|
||||
<span style={{ fontSize: "1.5rem" }}>{link.icon}</span>
|
||||
<span>{link.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<style>{`
|
||||
@media (min-width: 768px) {
|
||||
.nav:first-of-type {
|
||||
display: flex !important;
|
||||
position: static;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 1rem 0;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav:last-of-type {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import { json } from "@remix-run/node";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import { useState } from "react";
|
||||
import { useWebSocketEvent } from "~/hooks/useWebSocket";
|
||||
|
||||
export async function loader() {
|
||||
try {
|
||||
// Fetch system info from backend
|
||||
const [healthRes, devicesRes, systemRes] = await Promise.all([
|
||||
fetch("http://localhost:8000/api/health"),
|
||||
fetch("http://localhost:8000/api/pm3/devices"),
|
||||
fetch("http://localhost:8000/api/system/info"),
|
||||
]);
|
||||
|
||||
const health = await healthRes.json();
|
||||
const devicesData = await devicesRes.json();
|
||||
const rawSystemInfo = await systemRes.json();
|
||||
|
||||
// Extract devices array from response (API returns {devices: [...]} directly)
|
||||
const devices = devicesData.devices || [];
|
||||
|
||||
// Flatten system info for easier access in UI
|
||||
const systemInfo = {
|
||||
hostname: rawSystemInfo.hostname,
|
||||
cpu_temp: rawSystemInfo.cpu_temp,
|
||||
cpu_per_core: rawSystemInfo.cpu?.per_core || [],
|
||||
load_average: rawSystemInfo.cpu?.load_average || null,
|
||||
memory_used: rawSystemInfo.memory_used,
|
||||
memory_total: rawSystemInfo.memory_total,
|
||||
disk_used: rawSystemInfo.disk_used,
|
||||
disk_total: rawSystemInfo.disk_total,
|
||||
};
|
||||
|
||||
return json({ health, devices, systemInfo });
|
||||
} catch (error) {
|
||||
// Return mock data if backend is not available
|
||||
return json({
|
||||
health: { status: "unknown", version: "0.1.0" },
|
||||
devices: [],
|
||||
systemInfo: {
|
||||
hostname: "dangerous-pi",
|
||||
cpu_temp: null,
|
||||
cpu_per_core: [],
|
||||
load_average: null,
|
||||
memory_used: 0,
|
||||
memory_total: 0,
|
||||
disk_used: 0,
|
||||
disk_total: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface SystemStats {
|
||||
cpu_percent: number;
|
||||
cpu_per_core: { core_id: number; percent: number; online?: boolean }[];
|
||||
load_average: number[];
|
||||
memory_percent: number;
|
||||
temperature: number | null;
|
||||
}
|
||||
|
||||
interface PM3DeviceData {
|
||||
device_id: string;
|
||||
device_path: string;
|
||||
status: string;
|
||||
friendly_name: string;
|
||||
firmware_info?: { os_version?: string };
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const loaderData = useLoaderData<typeof loader>();
|
||||
const [eventMessage, setEventMessage] = useState<string | null>(null);
|
||||
|
||||
// Real-time state from WebSocket
|
||||
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
|
||||
const [pm3Devices, setPm3Devices] = useState<PM3DeviceData[] | null>(null);
|
||||
|
||||
// Merge real-time data with loader data
|
||||
const data = {
|
||||
...loaderData,
|
||||
systemInfo: {
|
||||
...loaderData.systemInfo,
|
||||
...(systemStats && {
|
||||
cpu_per_core: systemStats.cpu_per_core,
|
||||
load_average: systemStats.load_average,
|
||||
cpu_temp: systemStats.temperature ?? loaderData.systemInfo.cpu_temp,
|
||||
}),
|
||||
},
|
||||
devices: pm3Devices ?? loaderData.devices,
|
||||
};
|
||||
|
||||
// WebSocket event subscriptions for real-time updates
|
||||
useWebSocketEvent("connected", (data) => {
|
||||
console.log("WebSocket connected:", data);
|
||||
});
|
||||
|
||||
useWebSocketEvent("pm3_status", (data) => {
|
||||
setEventMessage(`PM3: ${data.message}`);
|
||||
setTimeout(() => setEventMessage(null), 5000);
|
||||
});
|
||||
|
||||
// PM3 devices - real-time on connect/disconnect
|
||||
useWebSocketEvent("pm3_devices", (data) => {
|
||||
setPm3Devices(data.devices as PM3DeviceData[]);
|
||||
});
|
||||
|
||||
// System stats - real-time every 5s from backend
|
||||
useWebSocketEvent("system_stats", (data) => {
|
||||
setSystemStats(data as SystemStats);
|
||||
});
|
||||
|
||||
useWebSocketEvent("update_available", (data) => {
|
||||
setEventMessage(`Update available: ${data.version}`);
|
||||
});
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const memoryPercent = data.systemInfo.memory_total > 0
|
||||
? ((data.systemInfo.memory_used / data.systemInfo.memory_total) * 100).toFixed(1)
|
||||
: "0";
|
||||
|
||||
const diskPercent = data.systemInfo.disk_total > 0
|
||||
? ((data.systemInfo.disk_used / data.systemInfo.disk_total) * 100).toFixed(1)
|
||||
: "0";
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1 style={{ marginBottom: "var(--space-6)" }}>
|
||||
<span style={{ color: "var(--color-primary)" }}>▸</span> Dashboard
|
||||
</h1>
|
||||
|
||||
{eventMessage && (
|
||||
<div className="toast">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)" }}>
|
||||
<span style={{ fontSize: "1.5rem" }}>●</span>
|
||||
<span>{eventMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card-grid" style={{ marginBottom: "var(--space-6)" }}>
|
||||
{/* System Status */}
|
||||
<div className="card">
|
||||
<h3 className="card-title">
|
||||
<span style={{ color: "var(--color-primary)" }}>◈</span> System Status
|
||||
</h3>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-3)" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span className="text-secondary">Backend</span>
|
||||
<span className={`badge ${data.health.status === "healthy" ? "badge-success" : "badge-error"}`}>
|
||||
<span aria-hidden="true">●</span>
|
||||
{data.health.status}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span className="text-secondary">Hostname</span>
|
||||
<span className="text-primary">{data.systemInfo.hostname || "unknown"}</span>
|
||||
</div>
|
||||
{data.systemInfo.cpu_temp && (
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span className="text-secondary">CPU Temp</span>
|
||||
<span className={data.systemInfo.cpu_temp > 70 ? "text-warning" : "text-primary"}>
|
||||
{data.systemInfo.cpu_temp.toFixed(1)}°C
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* CPU Load per Core */}
|
||||
{data.systemInfo.cpu_per_core && data.systemInfo.cpu_per_core.length > 0 && (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "var(--space-2)" }}>
|
||||
<span className="text-secondary">CPU Cores</span>
|
||||
{data.systemInfo.load_average && (
|
||||
<span className="text-muted" style={{ fontSize: "0.75rem" }}>
|
||||
Load: {data.systemInfo.load_average.map((l: number) => l.toFixed(2)).join(" / ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap" }}>
|
||||
{data.systemInfo.cpu_per_core.map((core: { core_id: number; percent: number; online?: boolean }) => {
|
||||
const isOnline = core.online !== false;
|
||||
return (
|
||||
<div
|
||||
key={core.core_id}
|
||||
className="cpu-core-bar"
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
minWidth: "50px",
|
||||
textAlign: "center",
|
||||
opacity: isOnline ? 1 : 0.4,
|
||||
}}
|
||||
title={isOnline ? `Core ${core.core_id}: ${core.percent.toFixed(0)}%` : `Core ${core.core_id}: Disabled`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "4px",
|
||||
background: "var(--color-border)",
|
||||
borderRadius: "2px",
|
||||
overflow: "hidden",
|
||||
marginBottom: "var(--space-1)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: isOnline ? `${Math.min(100, core.percent)}%` : "0%",
|
||||
background: !isOnline
|
||||
? "var(--color-text-muted)"
|
||||
: core.percent > 80
|
||||
? "var(--color-error)"
|
||||
: core.percent > 50
|
||||
? "var(--color-warning)"
|
||||
: "var(--color-accent)",
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: "0.7rem",
|
||||
fontFamily: "var(--font-mono)",
|
||||
color: isOnline ? "inherit" : "var(--color-text-muted)",
|
||||
textDecoration: isOnline ? "none" : "line-through",
|
||||
}}>
|
||||
C{core.core_id}: {isOnline ? `${core.percent.toFixed(0)}%` : "OFF"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span className="text-secondary">Memory</span>
|
||||
<span className="text-primary">
|
||||
{formatBytes(data.systemInfo.memory_used)} / {formatBytes(data.systemInfo.memory_total)} ({memoryPercent}%)
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span className="text-secondary">Disk</span>
|
||||
<span className="text-primary">
|
||||
{formatBytes(data.systemInfo.disk_used)} / {formatBytes(data.systemInfo.disk_total)} ({diskPercent}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PM3 Devices */}
|
||||
<div className="card">
|
||||
<h3 className="card-title">
|
||||
<span style={{ color: "var(--color-accent)" }}>▶</span> Proxmark3 Devices ({data.devices.length})
|
||||
</h3>
|
||||
|
||||
{data.devices.length === 0 ? (
|
||||
<div style={{ textAlign: "center", padding: "var(--space-4)" }}>
|
||||
<div style={{ fontSize: "2rem", marginBottom: "var(--space-2)", opacity: 0.5 }}>🔌</div>
|
||||
<p className="text-secondary" style={{ marginBottom: "var(--space-1)" }}>
|
||||
No devices detected
|
||||
</p>
|
||||
<p className="text-muted" style={{ fontSize: "0.875rem" }}>
|
||||
Connect a Proxmark3 via USB
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-3)" }}>
|
||||
{data.devices.map((device: any) => {
|
||||
const isConnected = device.status === "connected";
|
||||
const isInUse = device.status === "in_use";
|
||||
const deviceName = device.friendly_name || device.device_path;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={device.device_id}
|
||||
style={{
|
||||
padding: "var(--space-3)",
|
||||
background: "var(--color-bg)",
|
||||
borderRadius: "var(--radius)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "var(--space-2)" }}>
|
||||
<span style={{ fontWeight: 600, fontSize: "1rem" }}>{deviceName}</span>
|
||||
<span
|
||||
className={`badge ${
|
||||
isConnected
|
||||
? "badge-success"
|
||||
: isInUse
|
||||
? "badge-warning"
|
||||
: "badge-error"
|
||||
} badge-pulse`}
|
||||
>
|
||||
<span aria-hidden="true">●</span>
|
||||
{isConnected ? "Connected" : isInUse ? "In Use" : device.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: "0.875rem", color: "var(--color-text-muted)" }}>
|
||||
<div style={{ marginBottom: "var(--space-1)" }}>
|
||||
<span style={{ fontFamily: "var(--font-mono)" }}>{device.device_path}</span>
|
||||
</div>
|
||||
{device.firmware_info && (
|
||||
<div>
|
||||
{device.firmware_info.os_version}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: "var(--space-4)", display: "flex", gap: "var(--space-2)" }}>
|
||||
<a href="/commands" className="btn btn-primary" style={{ flex: 1 }}>
|
||||
{data.devices.length > 0 ? "Select Device & Start" : "Waiting for Device..."}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="card">
|
||||
<h3 className="card-title">
|
||||
<span style={{ color: "var(--color-secondary)" }}>⚡</span> Quick Actions
|
||||
</h3>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-2)" }}>
|
||||
<a href="/commands?cmd=hf+search" className="btn btn-secondary" style={{ justifyContent: "flex-start" }}>
|
||||
<span>🔍</span>
|
||||
<span>HF Search</span>
|
||||
</a>
|
||||
<a href="/commands?cmd=lf+search" className="btn btn-secondary" style={{ justifyContent: "flex-start" }}>
|
||||
<span>🔍</span>
|
||||
<span>LF Search</span>
|
||||
</a>
|
||||
<a href="/commands?cmd=hw+tune" className="btn btn-secondary" style={{ justifyContent: "flex-start" }}>
|
||||
<span>📡</span>
|
||||
<span>Tune Antenna</span>
|
||||
</a>
|
||||
<a href="/settings" className="btn btn-secondary" style={{ justifyContent: "flex-start" }}>
|
||||
<span>⚙</span>
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ textAlign: "center", padding: "var(--space-6) 0", color: "var(--color-text-muted)" }}>
|
||||
<p>
|
||||
<strong style={{ color: "var(--color-primary)" }}>Dangerous Pi</strong> v{data.health.version}
|
||||
</p>
|
||||
<p style={{ fontSize: "0.875rem", marginTop: "var(--space-2)" }}>
|
||||
Built for <a href="https://dangerousthings.com" target="_blank" rel="noopener noreferrer">Dangerous Things</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import { json, type ActionFunctionArgs } from "@remix-run/node";
|
||||
import { Form, useActionData, useNavigation, useSearchParams } from "@remix-run/react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import DeviceSelector from "../components/DeviceSelector";
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const formData = await request.formData();
|
||||
const command = formData.get("command") as string;
|
||||
const sessionId = formData.get("sessionId") as string;
|
||||
const deviceId = formData.get("deviceId") as string;
|
||||
|
||||
try {
|
||||
const response = await fetch("http://localhost:8000/api/pm3/command", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
command,
|
||||
session_id: sessionId,
|
||||
device_id: deviceId,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
return json(result);
|
||||
} catch (error) {
|
||||
return json({
|
||||
success: false,
|
||||
output: "",
|
||||
error: `Failed to execute command: ${error}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get/set session from localStorage
|
||||
const SESSION_STORAGE_KEY = "pm3_sessions";
|
||||
|
||||
function getStoredSession(deviceId: string): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const sessions = JSON.parse(localStorage.getItem(SESSION_STORAGE_KEY) || "{}");
|
||||
return sessions[deviceId] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function storeSession(deviceId: string, sessionId: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const sessions = JSON.parse(localStorage.getItem(SESSION_STORAGE_KEY) || "{}");
|
||||
sessions[deviceId] = sessionId;
|
||||
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(sessions));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
function clearStoredSession(deviceId: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const sessions = JSON.parse(localStorage.getItem(SESSION_STORAGE_KEY) || "{}");
|
||||
delete sessions[deviceId];
|
||||
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(sessions));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
export default function Commands() {
|
||||
const actionData = useActionData<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null);
|
||||
const [sessionError, setSessionError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const outputRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
|
||||
// Create or reuse session when device is selected
|
||||
useEffect(() => {
|
||||
if (!selectedDeviceId) {
|
||||
setSessionId(null);
|
||||
setSessionError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
async function ensureSession() {
|
||||
// First, check if we have a stored session for this device
|
||||
const storedSessionId = getStoredSession(selectedDeviceId);
|
||||
|
||||
if (storedSessionId) {
|
||||
// Try to verify the stored session is still valid by using it
|
||||
// We'll set it and if commands fail, we'll create a new one
|
||||
setSessionId(storedSessionId);
|
||||
setSessionError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// No stored session, create a new one
|
||||
try {
|
||||
const response = await fetch("/api/system/session/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
device_id: selectedDeviceId,
|
||||
force_takeover: true, // Take over any stale sessions
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setSessionId(data.session_id);
|
||||
storeSession(selectedDeviceId, data.session_id);
|
||||
setSessionError(null);
|
||||
} else {
|
||||
setSessionId(null);
|
||||
clearStoredSession(selectedDeviceId);
|
||||
setSessionError(data.error || "Failed to create session");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create session:", error);
|
||||
setSessionId(null);
|
||||
setSessionError("Network error while creating session");
|
||||
}
|
||||
}
|
||||
ensureSession();
|
||||
}, [selectedDeviceId]);
|
||||
|
||||
// Pre-fill command from URL param
|
||||
const prefilledCommand = searchParams.get("cmd")?.replace(/\+/g, " ") || "";
|
||||
|
||||
// Add command to history
|
||||
useEffect(() => {
|
||||
if (actionData && "output" in actionData) {
|
||||
const form = document.querySelector("form") as HTMLFormElement;
|
||||
const commandInput = form?.querySelector('input[name="command"]') as HTMLInputElement;
|
||||
if (commandInput?.value) {
|
||||
setCommandHistory((prev) => [commandInput.value, ...prev].slice(0, 50));
|
||||
setHistoryIndex(-1);
|
||||
}
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
// Auto-scroll output
|
||||
useEffect(() => {
|
||||
if (outputRef.current) {
|
||||
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (historyIndex < commandHistory.length - 1) {
|
||||
const newIndex = historyIndex + 1;
|
||||
setHistoryIndex(newIndex);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = commandHistory[newIndex];
|
||||
}
|
||||
}
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (historyIndex > 0) {
|
||||
const newIndex = historyIndex - 1;
|
||||
setHistoryIndex(newIndex);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = commandHistory[newIndex];
|
||||
}
|
||||
} else if (historyIndex === 0) {
|
||||
setHistoryIndex(-1);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const quickCommands = [
|
||||
{ label: "HW Status", cmd: "hw status", icon: "◈" },
|
||||
{ label: "HW Version", cmd: "hw version", icon: "ⓘ" },
|
||||
{ label: "HW Tune", cmd: "hw tune", icon: "📡" },
|
||||
{ label: "HF Search", cmd: "hf search", icon: "🔍" },
|
||||
{ label: "LF Search", cmd: "lf search", icon: "🔍" },
|
||||
{ label: "HF List", cmd: "hf list", icon: "≡" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1 style={{ marginBottom: "var(--space-6)" }}>
|
||||
<span style={{ color: "var(--color-accent)" }}>▶</span> PM3 Commands
|
||||
</h1>
|
||||
|
||||
{/* Device Selector */}
|
||||
<div style={{ marginBottom: "var(--space-4)" }}>
|
||||
<DeviceSelector
|
||||
selectedDeviceId={selectedDeviceId}
|
||||
onDeviceSelect={setSelectedDeviceId}
|
||||
showIdentifyButton={true}
|
||||
autoSelectSingle={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Session Error/Warning */}
|
||||
{selectedDeviceId && sessionError && (
|
||||
<div className="card" style={{ marginBottom: "var(--space-4)", borderColor: "var(--color-error)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)" }}>
|
||||
<span style={{ fontSize: "1.5rem" }}>✗</span>
|
||||
<div>
|
||||
<strong>Session Error</strong>
|
||||
<p className="text-secondary" style={{ marginTop: "var(--space-1)", marginBottom: 0 }}>
|
||||
{sessionError}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedDeviceId && !sessionId && !sessionError && (
|
||||
<div className="card" style={{ marginBottom: "var(--space-4)", borderColor: "var(--color-warning)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)" }}>
|
||||
<span style={{ fontSize: "1.5rem" }}>⚠</span>
|
||||
<div>
|
||||
<strong>Creating Session...</strong>
|
||||
<p className="text-secondary" style={{ marginTop: "var(--space-1)", marginBottom: 0 }}>
|
||||
Establishing connection to device...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Commands */}
|
||||
<div className="card" style={{ marginBottom: "var(--space-4)" }}>
|
||||
<h3 className="card-title">Quick Commands</h3>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", gap: "var(--space-2)" }}>
|
||||
{quickCommands.map((qc) => (
|
||||
<button
|
||||
key={qc.cmd}
|
||||
onClick={() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = qc.cmd;
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}}
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: "0.75rem", padding: "var(--space-2) var(--space-3)" }}
|
||||
>
|
||||
<span>{qc.icon}</span>
|
||||
<span>{qc.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Command Input */}
|
||||
<div className="card" style={{ marginBottom: "var(--space-4)" }}>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="sessionId" value={sessionId || ""} />
|
||||
<input type="hidden" name="deviceId" value={selectedDeviceId || ""} />
|
||||
<div className="form-group">
|
||||
<label htmlFor="command" className="label">
|
||||
Command
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
id="command"
|
||||
name="command"
|
||||
className="input"
|
||||
placeholder={
|
||||
!selectedDeviceId
|
||||
? "Select a device first..."
|
||||
: !sessionId
|
||||
? "Creating session..."
|
||||
: "Enter PM3 command (e.g., hw status)"
|
||||
}
|
||||
defaultValue={prefilledCommand}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={!selectedDeviceId || !sessionId || isSubmitting}
|
||||
autoComplete="off"
|
||||
autoFocus={!!sessionId}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={!selectedDeviceId || !sessionId || isSubmitting}
|
||||
style={{ minWidth: "100px" }}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="spinner"></span>
|
||||
<span>Running...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>▶</span>
|
||||
<span>Execute</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: "0.75rem", marginTop: "var(--space-2)", marginBottom: 0 }}>
|
||||
{selectedDeviceId && sessionId
|
||||
? "Use ↑/↓ arrow keys to navigate command history"
|
||||
: !selectedDeviceId
|
||||
? "Select a Proxmark3 device above to start executing commands"
|
||||
: "Waiting for session..."}
|
||||
</p>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* Output Terminal */}
|
||||
<div className="card">
|
||||
<h3 className="card-title">Output</h3>
|
||||
{actionData ? (
|
||||
<div ref={outputRef} className="terminal">
|
||||
{actionData.success ? (
|
||||
<>
|
||||
<div style={{ color: "var(--color-primary)", marginBottom: "var(--space-2)" }}>
|
||||
▸ Command executed successfully
|
||||
</div>
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-wrap", wordWrap: "break-word" }}>
|
||||
{actionData.output || "(No output)"}
|
||||
</pre>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ color: "var(--color-error)", marginBottom: "var(--space-2)" }}>
|
||||
✗ Command failed
|
||||
</div>
|
||||
<pre style={{ margin: 0, color: "var(--color-error)", whiteSpace: "pre-wrap", wordWrap: "break-word" }}>
|
||||
{actionData.error || "Unknown error"}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="terminal" style={{ color: "var(--color-text-muted)", fontStyle: "italic" }}>
|
||||
Ready. Enter a command above and press Execute.
|
||||
<br /><br />
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>Examples:</span>
|
||||
<br />
|
||||
• hw status — Check hardware status
|
||||
<br />
|
||||
• hw version — Show firmware version
|
||||
<br />
|
||||
• hf search — Search for HF tags
|
||||
<br />
|
||||
• lf search — Search for LF tags
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Command History */}
|
||||
{commandHistory.length > 0 && (
|
||||
<div className="card" style={{ marginTop: "var(--space-4)" }}>
|
||||
<h3 className="card-title">Recent Commands</h3>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-2)" }}>
|
||||
{commandHistory.slice(0, 10).map((cmd, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = cmd;
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}}
|
||||
className="btn btn-secondary"
|
||||
style={{
|
||||
justifyContent: "flex-start",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "0.875rem",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--color-text-muted)" }}>▸</span>
|
||||
<span>{cmd}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { json } from "@remix-run/node";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
|
||||
export async function loader() {
|
||||
try {
|
||||
const response = await fetch("http://localhost:8000/api/pm3/commands/history?limit=50");
|
||||
const data = await response.json();
|
||||
return json({ history: data.history || [] });
|
||||
} catch (error) {
|
||||
// Return mock data if backend unavailable
|
||||
return json({
|
||||
history: [
|
||||
{
|
||||
id: 1,
|
||||
command: "hw version",
|
||||
success: true,
|
||||
executed_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
command: "hw status",
|
||||
success: true,
|
||||
executed_at: new Date(Date.now() - 300000).toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Logs() {
|
||||
const { history } = useLoaderData<typeof loader>();
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1 style={{ marginBottom: "var(--space-6)" }}>
|
||||
<span style={{ color: "var(--color-info)" }}>≡</span> Command Logs
|
||||
</h1>
|
||||
|
||||
<div className="card">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "var(--space-4)" }}>
|
||||
<h3 className="card-title" style={{ marginBottom: 0 }}>
|
||||
Command History
|
||||
</h3>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
||||
<button className="btn btn-secondary" disabled>
|
||||
<span>⬇</span>
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
<button className="btn btn-danger" disabled>
|
||||
<span>🗑</span>
|
||||
<span>Clear</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{history.length === 0 ? (
|
||||
<div className="terminal" style={{ textAlign: "center", color: "var(--color-text-muted)" }}>
|
||||
<p>No command history yet.</p>
|
||||
<p style={{ fontSize: "0.875rem", marginTop: "var(--space-2)" }}>
|
||||
Execute commands from the{" "}
|
||||
<a href="/commands">Commands page</a>{" "}
|
||||
to see them here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--color-border)" }}>
|
||||
<th style={{ padding: "var(--space-3)", textAlign: "left", color: "var(--color-text-secondary)", fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
Time
|
||||
</th>
|
||||
<th style={{ padding: "var(--space-3)", textAlign: "left", color: "var(--color-text-secondary)", fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
Command
|
||||
</th>
|
||||
<th style={{ padding: "var(--space-3)", textAlign: "left", color: "var(--color-text-secondary)", fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.map((entry: any, idx: number) => (
|
||||
<tr
|
||||
key={entry.id || idx}
|
||||
style={{
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
transition: "background var(--transition-fast)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "var(--color-surface-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: "var(--space-3)", fontSize: "0.875rem", color: "var(--color-text-secondary)", fontFamily: "var(--font-mono)" }}>
|
||||
{formatDate(entry.executed_at)}
|
||||
</td>
|
||||
<td style={{ padding: "var(--space-3)", fontFamily: "var(--font-mono)", fontSize: "0.875rem" }}>
|
||||
<code style={{ color: "var(--color-accent)" }}>
|
||||
{entry.command}
|
||||
</code>
|
||||
</td>
|
||||
<td style={{ padding: "var(--space-3)" }}>
|
||||
<span className={`badge ${entry.success ? "badge-success" : "badge-error"}`}>
|
||||
<span aria-hidden="true">{entry.success ? "✓" : "✗"}</span>
|
||||
{entry.success ? "Success" : "Failed"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{history.length > 0 && (
|
||||
<div style={{ marginTop: "var(--space-4)", textAlign: "center", color: "var(--color-text-muted)", fontSize: "0.875rem" }}>
|
||||
Showing {history.length} recent commands
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: "var(--space-4)" }}>
|
||||
<h3 className="card-title">System Logs</h3>
|
||||
<p className="text-muted">System log viewing will be available in a future update.</p>
|
||||
<button className="btn btn-secondary" disabled>
|
||||
<span>📄</span>
|
||||
<span>View System Logs</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,426 @@
|
||||
import { json, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/node";
|
||||
import { useLoaderData, useActionData, Form, useNavigation } from "@remix-run/react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface UpdateInfo {
|
||||
update_available: boolean;
|
||||
current_version: string;
|
||||
latest_version?: string;
|
||||
release_date?: string;
|
||||
changelog?: string;
|
||||
is_prerelease: boolean;
|
||||
download_size?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface UpdateProgress {
|
||||
status: string;
|
||||
current_version: string;
|
||||
available_version?: string;
|
||||
download_progress: number;
|
||||
error_message?: string;
|
||||
last_check?: string;
|
||||
}
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
try {
|
||||
// Fetch current update status and progress
|
||||
const [updateRes, progressRes] = await Promise.all([
|
||||
fetch("http://localhost:8000/api/updates/check"),
|
||||
fetch("http://localhost:8000/api/updates/progress"),
|
||||
]);
|
||||
|
||||
const updateInfo = await updateRes.json();
|
||||
const progressInfo = await progressRes.json();
|
||||
|
||||
return json({ updateInfo, progressInfo });
|
||||
} catch (error) {
|
||||
console.error("Error loading update info:", error);
|
||||
return json({
|
||||
updateInfo: null,
|
||||
progressInfo: null,
|
||||
error: "Failed to load update information",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const formData = await request.formData();
|
||||
const action = formData.get("_action");
|
||||
|
||||
try {
|
||||
if (action === "check_updates") {
|
||||
const response = await fetch("http://localhost:8000/api/updates/check", {
|
||||
method: "GET",
|
||||
});
|
||||
const data = await response.json();
|
||||
return json({ success: true, message: "Update check complete", data });
|
||||
}
|
||||
|
||||
if (action === "download_update") {
|
||||
const response = await fetch("http://localhost:8000/api/updates/download", {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await response.json();
|
||||
return json({ success: true, message: "Download started", data });
|
||||
}
|
||||
|
||||
if (action === "install_update") {
|
||||
const response = await fetch("http://localhost:8000/api/updates/install", {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await response.json();
|
||||
return json({ success: true, message: "Installation started", data });
|
||||
}
|
||||
|
||||
return json({ success: false, message: "Unknown action" });
|
||||
} catch (error: any) {
|
||||
return json({ success: false, message: error.message || "Action failed" });
|
||||
}
|
||||
}
|
||||
|
||||
export default function Updates() {
|
||||
const { updateInfo, progressInfo, error } = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
|
||||
const [progress, setProgress] = useState<UpdateProgress | null>(progressInfo);
|
||||
|
||||
// Poll for progress updates when downloading or installing
|
||||
useEffect(() => {
|
||||
if (!progress || (progress.status !== "downloading" && progress.status !== "installing")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch("http://localhost:8000/api/updates/progress");
|
||||
const data = await response.json();
|
||||
setProgress(data);
|
||||
} catch (error) {
|
||||
console.error("Error polling progress:", error);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [progress?.status]);
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges: Record<string, { text: string; color: string }> = {
|
||||
idle: { text: "Idle", color: "var(--color-text-muted)" },
|
||||
checking: { text: "Checking...", color: "var(--color-primary)" },
|
||||
available: { text: "Update Available", color: "var(--color-accent)" },
|
||||
downloading: { text: "Downloading", color: "var(--color-primary)" },
|
||||
installing: { text: "Installing", color: "var(--color-warning)" },
|
||||
complete: { text: "Complete", color: "var(--color-success)" },
|
||||
failed: { text: "Failed", color: "var(--color-danger)" },
|
||||
};
|
||||
|
||||
const badge = badges[status] || badges.idle;
|
||||
|
||||
return (
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
backgroundColor: `${badge.color}20`,
|
||||
color: badge.color,
|
||||
border: `1px solid ${badge.color}40`,
|
||||
}}
|
||||
>
|
||||
{badge.text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i];
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container">
|
||||
<h1>⚠️ Error</h1>
|
||||
<div className="card">
|
||||
<p style={{ color: "var(--color-danger)" }}>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div style={{ marginBottom: "var(--space-6)" }}>
|
||||
<h1 style={{ marginBottom: "var(--space-2)" }}>
|
||||
<span style={{ color: "var(--color-primary)" }}>🔄</span> System Updates
|
||||
</h1>
|
||||
<p className="text-muted">
|
||||
Manage system updates from GitHub releases
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Messages */}
|
||||
{actionData && (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
marginBottom: "var(--space-6)",
|
||||
backgroundColor: actionData.success
|
||||
? "var(--color-success-bg)"
|
||||
: "var(--color-danger-bg)",
|
||||
borderColor: actionData.success
|
||||
? "var(--color-success)"
|
||||
: "var(--color-danger)",
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: 0 }}>{actionData.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Version */}
|
||||
<div className="card" style={{ marginBottom: "var(--space-6)" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "var(--space-4)" }}>
|
||||
<div>
|
||||
<h2 className="card-title" style={{ marginBottom: "var(--space-2)" }}>
|
||||
Current Version
|
||||
</h2>
|
||||
<div style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-primary)" }}>
|
||||
v{progressInfo?.current_version || updateInfo?.current_version || "Unknown"}
|
||||
</div>
|
||||
</div>
|
||||
{progress && getStatusBadge(progress.status)}
|
||||
</div>
|
||||
|
||||
{progressInfo?.last_check && (
|
||||
<p className="text-muted" style={{ fontSize: "0.875rem", marginTop: "var(--space-2)" }}>
|
||||
Last checked: {new Date(progressInfo.last_check).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Form method="post" style={{ marginTop: "var(--space-4)" }}>
|
||||
<input type="hidden" name="_action" value="check_updates" />
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-secondary"
|
||||
disabled={isSubmitting || progress?.status === "checking"}
|
||||
>
|
||||
{isSubmitting || progress?.status === "checking" ? (
|
||||
<>
|
||||
<span className="spinner"></span>
|
||||
<span>Checking...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>🔍</span>
|
||||
<span>Check for Updates</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* Update Available */}
|
||||
{updateInfo?.update_available && (
|
||||
<div className="card" style={{ marginBottom: "var(--space-6)", borderColor: "var(--color-accent)" }}>
|
||||
<h2 className="card-title" style={{ marginBottom: "var(--space-4)" }}>
|
||||
<span style={{ color: "var(--color-accent)" }}>✨</span> Update Available
|
||||
</h2>
|
||||
|
||||
<div style={{ marginBottom: "var(--space-4)" }}>
|
||||
<div style={{ display: "flex", gap: "var(--space-4)", marginBottom: "var(--space-3)" }}>
|
||||
<div>
|
||||
<div className="label" style={{ fontSize: "0.75rem" }}>New Version</div>
|
||||
<div style={{ fontSize: "1.25rem", fontWeight: 700, color: "var(--color-accent)" }}>
|
||||
v{updateInfo.latest_version}
|
||||
</div>
|
||||
</div>
|
||||
{updateInfo.download_size && (
|
||||
<div>
|
||||
<div className="label" style={{ fontSize: "0.75rem" }}>Download Size</div>
|
||||
<div style={{ fontSize: "1.25rem", fontWeight: 600 }}>
|
||||
{formatBytes(updateInfo.download_size)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{updateInfo.release_date && (
|
||||
<p className="text-muted" style={{ fontSize: "0.875rem" }}>
|
||||
Released: {formatDate(updateInfo.release_date)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{updateInfo.is_prerelease && (
|
||||
<div
|
||||
className="badge"
|
||||
style={{
|
||||
backgroundColor: "var(--color-warning-bg)",
|
||||
color: "var(--color-warning)",
|
||||
marginTop: "var(--space-2)",
|
||||
}}
|
||||
>
|
||||
Pre-release
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Changelog */}
|
||||
{updateInfo.changelog && (
|
||||
<div style={{ marginBottom: "var(--space-4)" }}>
|
||||
<div className="label" style={{ marginBottom: "var(--space-2)" }}>Release Notes</div>
|
||||
<div
|
||||
style={{
|
||||
padding: "var(--space-3)",
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: "var(--radius)",
|
||||
maxHeight: "300px",
|
||||
overflowY: "auto",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{updateInfo.changelog}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Update Actions */}
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap" }}>
|
||||
{progress?.status === "available" && (
|
||||
<Form method="post">
|
||||
<input type="hidden" name="_action" value="download_update" />
|
||||
<button type="submit" className="btn btn-primary" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="spinner"></span>
|
||||
<span>Starting...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>⬇️</span>
|
||||
<span>Download Update</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{progress?.status === "downloading" && (
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ marginBottom: "var(--space-2)" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: "0.875rem" }}>
|
||||
<span>Downloading...</span>
|
||||
<span>{Math.round(progress.download_progress)}%</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "8px",
|
||||
background: "var(--color-border)",
|
||||
borderRadius: "var(--radius)",
|
||||
overflow: "hidden",
|
||||
marginTop: "var(--space-2)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${progress.download_progress}%`,
|
||||
height: "100%",
|
||||
background: "linear-gradient(90deg, var(--color-primary), var(--color-accent))",
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(progress?.status === "idle" || progress?.download_progress === 100) &&
|
||||
updateInfo.update_available && (
|
||||
<Form method="post">
|
||||
<input type="hidden" name="_action" value="install_update" />
|
||||
<button type="submit" className="btn btn-accent" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="spinner"></span>
|
||||
<span>Installing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>⚡</span>
|
||||
<span>Install Update</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{progress?.status === "installing" && (
|
||||
<div className="btn btn-accent" style={{ cursor: "default" }}>
|
||||
<span className="spinner"></span>
|
||||
<span>Installing Update...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress?.status === "complete" && (
|
||||
<div style={{ flex: 1 }}>
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
backgroundColor: "var(--color-success-bg)",
|
||||
borderColor: "var(--color-success)",
|
||||
padding: "var(--space-3)",
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: 0 }}>
|
||||
✅ Update installed successfully! Please restart the service for changes to take effect.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{progress?.error_message && (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
marginTop: "var(--space-4)",
|
||||
backgroundColor: "var(--color-danger-bg)",
|
||||
borderColor: "var(--color-danger)",
|
||||
padding: "var(--space-3)",
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: 0, color: "var(--color-danger)" }}>
|
||||
❌ {progress.error_message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Update Available */}
|
||||
{updateInfo && !updateInfo.update_available && updateInfo.message && (
|
||||
<div className="card">
|
||||
<p style={{ margin: 0, textAlign: "center", color: "var(--color-text-muted)" }}>
|
||||
✅ {updateInfo.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
/* Dangerous Pi - Cyberpunk Theme */
|
||||
/* Optimized for Pi Zero 2 W - Minimal, Fast, Beautiful */
|
||||
|
||||
:root {
|
||||
/* Cyberpunk Color Palette - Dark Mode Default */
|
||||
--color-bg: #0a0e1a;
|
||||
--color-surface: #121827;
|
||||
--color-surface-hover: #1a2332;
|
||||
--color-border: #1e293b;
|
||||
|
||||
--color-text-primary: #e2e8f0;
|
||||
--color-text-secondary: #94a3b8;
|
||||
--color-text-muted: #64748b;
|
||||
|
||||
/* Neon Accents */
|
||||
--color-primary: #00ffff; /* Cyan */
|
||||
--color-primary-dim: #0891b2;
|
||||
--color-secondary: #ff00ff; /* Magenta */
|
||||
--color-accent: #00ff88; /* Green */
|
||||
|
||||
/* Status Colors */
|
||||
--color-success: #00ff88;
|
||||
--color-warning: #ffaa00;
|
||||
--color-error: #ff0055;
|
||||
--color-info: #00ffff;
|
||||
|
||||
/* Spacing (4px base) */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition: 250ms ease;
|
||||
|
||||
/* Monospace for terminal feel */
|
||||
--font-mono: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, monospace;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* Light mode override (if user prefers) */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root[data-theme="auto"] {
|
||||
--color-bg: #ffffff;
|
||||
--color-surface: #f8fafc;
|
||||
--color-surface-hover: #f1f5f9;
|
||||
--color-border: #e2e8f0;
|
||||
|
||||
--color-text-primary: #0f172a;
|
||||
--color-text-secondary: #475569;
|
||||
--color-text-muted: #94a3b8;
|
||||
|
||||
--color-primary: #0891b2;
|
||||
--color-primary-dim: #0e7490;
|
||||
--color-secondary: #c026d3;
|
||||
}
|
||||
}
|
||||
|
||||
/* Force dark mode */
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Force light mode */
|
||||
:root[data-theme="light"] {
|
||||
--color-bg: #ffffff;
|
||||
--color-surface: #f8fafc;
|
||||
--color-surface-hover: #f1f5f9;
|
||||
--color-border: #e2e8f0;
|
||||
|
||||
--color-text-primary: #0f172a;
|
||||
--color-text-secondary: #475569;
|
||||
--color-text-muted: #94a3b8;
|
||||
|
||||
--color-primary: #0891b2;
|
||||
--color-primary-dim: #0e7490;
|
||||
--color-secondary: #c026d3;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* Reset & Base */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
h1 { font-size: 1.875rem; } /* 30px */
|
||||
h2 { font-size: 1.5rem; } /* 24px */
|
||||
h3 { font-size: 1.25rem; } /* 20px */
|
||||
h4 { font-size: 1.125rem; } /* 18px */
|
||||
|
||||
p {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-4);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: var(--space-4);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(18, 24, 39, 0.9);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
color: var(--color-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-fast);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-surface-hover);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(0, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Mobile Navigation */
|
||||
@media (max-width: 768px) {
|
||||
.nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: var(--space-2);
|
||||
justify-content: space-around;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 0.75rem;
|
||||
padding: var(--space-2);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-bottom: 60px; /* Space for bottom nav */
|
||||
}
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main {
|
||||
padding: var(--space-6) var(--space-4);
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-3);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-6);
|
||||
border-radius: var(--radius);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all var(--transition-fast);
|
||||
min-height: 44px; /* Touch-friendly */
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-bg);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 20px rgba(0, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 30px rgba(0, 255, 136, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(0, 255, 255, 0.1);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #cc0044;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
color: var(--color-success);
|
||||
border: 1px solid var(--color-success);
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: rgba(255, 0, 85, 0.2);
|
||||
color: var(--color-error);
|
||||
border: 1px solid var(--color-error);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: rgba(255, 170, 0, 0.2);
|
||||
color: var(--color-warning);
|
||||
border: 1px solid var(--color-warning);
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: rgba(0, 255, 255, 0.2);
|
||||
color: var(--color-info);
|
||||
border: 1px solid var(--color-info);
|
||||
}
|
||||
|
||||
.badge-pulse {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1rem;
|
||||
font-family: var(--font-mono);
|
||||
transition: all var(--transition-fast);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(0, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Terminal/Code Output */
|
||||
.terminal {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-4);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-accent);
|
||||
overflow-x: auto;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.terminal::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.terminal::-webkit-scrollbar-track {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.terminal::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.terminal::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Loading States */
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-surface) 0%,
|
||||
var(--color-surface-hover) 50%,
|
||||
var(--color-surface) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s ease-in-out infinite;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Toast Notifications */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: var(--space-6);
|
||||
right: var(--space-6);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
z-index: 200;
|
||||
animation: toast-in 250ms ease;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.text-primary { color: var(--color-text-primary); }
|
||||
.text-secondary { color: var(--color-text-secondary); }
|
||||
.text-muted { color: var(--color-text-muted); }
|
||||
.text-success { color: var(--color-success); }
|
||||
.text-error { color: var(--color-error); }
|
||||
.text-warning { color: var(--color-warning); }
|
||||
|
||||
.text-center { text-align: center; }
|
||||
.text-right { text-align: right; }
|
||||
|
||||
.mb-2 { margin-bottom: var(--space-2); }
|
||||
.mb-4 { margin-bottom: var(--space-4); }
|
||||
.mb-6 { margin-bottom: var(--space-6); }
|
||||
|
||||
.mt-2 { margin-top: var(--space-2); }
|
||||
.mt-4 { margin-top: var(--space-4); }
|
||||
.mt-6 { margin-top: var(--space-6); }
|
||||
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-2 { gap: var(--space-2); }
|
||||
.gap-4 { gap: var(--space-4); }
|
||||
|
||||
/* Accessibility */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
/* Focus Styles */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
h1 { font-size: 1.5rem; }
|
||||
h2 { font-size: 1.25rem; }
|
||||
|
||||
.card {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: var(--space-4) var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
/* Power Widget */
|
||||
.power-widget {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
cursor: default;
|
||||
transition: all var(--transition-fast);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.power-widget:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.power-widget__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.power-widget__percentage {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
min-width: 2.5em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.power-widget__plug-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-accent);
|
||||
animation: plug-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.plug-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
@keyframes plug-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
/* Battery Icon */
|
||||
.battery-icon {
|
||||
width: 24px;
|
||||
height: 14px;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.battery-icon__fill {
|
||||
transition: width var(--transition);
|
||||
}
|
||||
|
||||
.battery-icon__bolt {
|
||||
animation: bolt-flash 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes bolt-flash {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* Power Widget States */
|
||||
.power-widget--loading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.power-widget--loading .battery-icon__fill {
|
||||
animation: loading-fill 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes loading-fill {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
.power-widget--normal {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.power-widget--charging {
|
||||
color: var(--color-accent);
|
||||
border-color: rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
.power-widget--full {
|
||||
color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.power-widget--low {
|
||||
color: var(--color-warning);
|
||||
border-color: rgba(255, 170, 0, 0.3);
|
||||
}
|
||||
|
||||
.power-widget--critical {
|
||||
color: var(--color-error);
|
||||
border-color: rgba(255, 0, 85, 0.3);
|
||||
animation: critical-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes critical-pulse {
|
||||
0%, 100% {
|
||||
border-color: rgba(255, 0, 85, 0.3);
|
||||
box-shadow: none;
|
||||
}
|
||||
50% {
|
||||
border-color: var(--color-error);
|
||||
box-shadow: 0 0 10px rgba(255, 0, 85, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.power-widget {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
.power-widget__percentage {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Header Widgets - Notification banners below header
|
||||
============================================================================ */
|
||||
|
||||
.header-widgets {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.widget {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9rem;
|
||||
animation: widget-slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes widget-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.widget-info {
|
||||
background: rgba(0, 255, 255, 0.1);
|
||||
border-left: 3px solid var(--color-info);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.widget-warning {
|
||||
background: rgba(255, 170, 0, 0.1);
|
||||
border-left: 3px solid var(--color-warning);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.widget-error {
|
||||
background: rgba(255, 0, 85, 0.1);
|
||||
border-left: 3px solid var(--color-error);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.widget-success {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
border-left: 3px solid var(--color-success);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.widget-icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.widget-message {
|
||||
flex: 1;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.widget-action {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
transition: background var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.widget-action:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.widget-dismiss {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: var(--space-1);
|
||||
opacity: 0.6;
|
||||
transition: opacity var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.widget-dismiss:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dismiss-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 768px) {
|
||||
.header-widgets {
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.widget {
|
||||
font-size: 0.85rem;
|
||||
padding: var(--space-2);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.widget-action {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
8854
stageDangerousPi/03-dangerous-pi/files/app/frontend/package-lock.json
generated
Normal file
8854
stageDangerousPi/03-dangerous-pi/files/app/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "dangerous-pi-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "remix vite:dev",
|
||||
"build": "remix vite:build",
|
||||
"start": "remix-serve build/server/index.js",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remix-run/node": "^2.14.0",
|
||||
"@remix-run/react": "^2.14.0",
|
||||
"@remix-run/serve": "^2.14.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@remix-run/dev": "^2.14.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^5.4.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"types": ["@remix-run/node", "vite/client"],
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"target": "ES2022",
|
||||
"strict": true,
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./app/*"]
|
||||
},
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { vitePlugin as remix } from "@remix-run/dev";
|
||||
import { defineConfig } from "vite";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"~": path.resolve(__dirname, "./app"),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
remix({
|
||||
future: {
|
||||
v3_fetcherPersist: true,
|
||||
v3_relativeSplatPath: true,
|
||||
v3_throwAbortReason: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
// Proxy API requests to backend
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/sse': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Hello World example plugin for Dangerous Pi.
|
||||
|
||||
This plugin demonstrates the plugin framework capabilities including:
|
||||
- Lifecycle methods (on_load, on_enable, on_disable, on_unload)
|
||||
- Hook registration
|
||||
- Header widget display
|
||||
"""
|
||||
import sys
|
||||
|
||||
# Import PluginBase from the already-loaded module to ensure class identity matches
|
||||
# This is necessary because the plugin manager uses issubclass() check
|
||||
# Try both module name variants (depends on how the app is started)
|
||||
_plugin_manager_module = (
|
||||
sys.modules.get('app.backend.managers.plugin_manager') or
|
||||
sys.modules.get('backend.managers.plugin_manager')
|
||||
)
|
||||
if _plugin_manager_module:
|
||||
PluginBase = _plugin_manager_module.PluginBase
|
||||
PluginMetadata = _plugin_manager_module.PluginMetadata
|
||||
WidgetSeverity = _plugin_manager_module.WidgetSeverity
|
||||
else:
|
||||
# Fallback for standalone testing
|
||||
from pathlib import Path
|
||||
app_path = Path(__file__).parent.parent.parent
|
||||
if str(app_path) not in sys.path:
|
||||
sys.path.insert(0, str(app_path))
|
||||
from backend.managers.plugin_manager import PluginBase, PluginMetadata, WidgetSeverity
|
||||
|
||||
|
||||
class HelloWorldPlugin(PluginBase):
|
||||
"""Example plugin that demonstrates the plugin framework."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the Hello World plugin."""
|
||||
super().__init__()
|
||||
self.counter = 0
|
||||
|
||||
async def on_load(self):
|
||||
"""Called when the plugin is loaded."""
|
||||
print("Hello World Plugin: Loaded!")
|
||||
|
||||
async def on_enable(self):
|
||||
"""Called when the plugin is enabled."""
|
||||
print("Hello World Plugin: Enabled!")
|
||||
|
||||
# Register a header widget to show plugin is active
|
||||
self.register_widget(
|
||||
widget_id="status",
|
||||
severity=WidgetSeverity.SUCCESS,
|
||||
message="Hello World plugin active",
|
||||
icon="👋",
|
||||
dismissible=True,
|
||||
action_label="Settings",
|
||||
action_url="/settings"
|
||||
)
|
||||
|
||||
# Register a hook for PM3 commands
|
||||
async def pm3_command_hook(command: str):
|
||||
"""Hook that logs PM3 commands."""
|
||||
self.counter += 1
|
||||
print(f"Hello World Plugin: PM3 command #{self.counter}: {command}")
|
||||
return {"plugin": "hello_world", "command_count": self.counter}
|
||||
|
||||
self.register_hook("pm3_command", pm3_command_hook)
|
||||
|
||||
# Register a hook for update checks
|
||||
async def update_check_hook():
|
||||
"""Hook that runs on update checks."""
|
||||
print("Hello World Plugin: Update check performed")
|
||||
return {"plugin": "hello_world", "message": "Hello from plugin!"}
|
||||
|
||||
self.register_hook("update_check", update_check_hook)
|
||||
|
||||
async def on_disable(self):
|
||||
"""Called when the plugin is disabled."""
|
||||
print(f"Hello World Plugin: Disabled! (processed {self.counter} commands)")
|
||||
|
||||
# Remove the header widget
|
||||
self.unregister_widget("status")
|
||||
|
||||
async def on_unload(self):
|
||||
"""Called when the plugin is unloaded."""
|
||||
print("Hello World Plugin: Unloaded! Goodbye!")
|
||||
|
||||
def get_metadata(self) -> PluginMetadata:
|
||||
"""Get plugin metadata."""
|
||||
return self.metadata
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "hello_world",
|
||||
"name": "Hello World Plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "Example plugin demonstrating the plugin framework",
|
||||
"author": "Dangerous Pi Team",
|
||||
"homepage": "https://github.com/yourusername/dangerous-pi",
|
||||
"dependencies": [],
|
||||
"permissions": []
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Dangerous Pi polkit rules
|
||||
// Allows the dangerous-pi user to manage NetworkManager and specific systemd services
|
||||
// without requiring sudo
|
||||
|
||||
polkit.addRule(function(action, subject) {
|
||||
// Allow NetworkManager control for dangerous-pi user
|
||||
if (action.id.indexOf("org.freedesktop.NetworkManager") === 0 &&
|
||||
subject.user === "__DANGEROUS_PI_USER__") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
|
||||
// Allow managing specific systemd services
|
||||
if (action.id === "org.freedesktop.systemd1.manage-units" &&
|
||||
subject.user === "__DANGEROUS_PI_USER__") {
|
||||
// Check if the unit is one of our allowed services
|
||||
var unit = action.lookup("unit");
|
||||
if (unit === "hostapd.service" ||
|
||||
unit === "dnsmasq.service" ||
|
||||
unit === "NetworkManager.service") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
}
|
||||
});
|
||||
13
stageDangerousPi/03-dangerous-pi/files/requirements.txt
Normal file
13
stageDangerousPi/03-dangerous-pi/files/requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.32.0
|
||||
python-multipart==0.0.12
|
||||
aiosqlite==0.20.0
|
||||
aiohttp==3.10.5
|
||||
httpx==0.27.2
|
||||
sse-starlette==2.1.3
|
||||
pydantic==2.9.0
|
||||
pydantic-settings==2.5.0
|
||||
psutil==6.1.0
|
||||
smbus2==0.4.3
|
||||
pyudev>=0.24.0
|
||||
pyserial>=3.5
|
||||
198
stageDangerousPi/03-dangerous-pi/files/scripts/audit-build-scripts.sh
Executable file
198
stageDangerousPi/03-dangerous-pi/files/scripts/audit-build-scripts.sh
Executable file
@@ -0,0 +1,198 @@
|
||||
#!/bin/bash
|
||||
# Audit build scripts for common issues
|
||||
# Usage: ./scripts/audit-build-scripts.sh
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
echo "=== Auditing Build Scripts ==="
|
||||
echo ""
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
WARNINGS=0
|
||||
ERRORS=0
|
||||
|
||||
# Function to report issues
|
||||
warn() {
|
||||
echo -e "${YELLOW}WARNING:${NC} $1"
|
||||
((WARNINGS++))
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}ERROR:${NC} $1"
|
||||
((ERRORS++))
|
||||
}
|
||||
|
||||
ok() {
|
||||
echo -e "${GREEN}✓${NC} $1"
|
||||
}
|
||||
|
||||
echo "1. Checking for commands used before installation..."
|
||||
echo "------------------------------------------------"
|
||||
|
||||
# Check PM3 stage
|
||||
PM3_SCRIPT="$PROJECT_DIR/pi-gen/stagePM3/01-proxmark3/00-run-chroot.sh"
|
||||
if [ -f "$PM3_SCRIPT" ]; then
|
||||
# Check if cmake is used before apt-get install
|
||||
if grep -q "cmake" "$PM3_SCRIPT"; then
|
||||
if ! grep -B50 "cmake" "$PM3_SCRIPT" | grep -q "apt-get install.*cmake"; then
|
||||
warn "PM3: cmake used but may not be installed"
|
||||
else
|
||||
ok "PM3: cmake installation verified"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for git before installation
|
||||
if grep -q "git clone" "$PM3_SCRIPT"; then
|
||||
if ! grep -B50 "git clone" "$PM3_SCRIPT" | grep -q "apt-get install.*git"; then
|
||||
warn "PM3: git used but may not be installed"
|
||||
else
|
||||
ok "PM3: git installation verified"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check DangerousPi stage
|
||||
DTPI_SCRIPT="$PROJECT_DIR/pi-gen/stageDangerousPi/03-dangerous-pi/00-run-chroot.sh"
|
||||
if [ -f "$DTPI_SCRIPT" ]; then
|
||||
# Check for pip3
|
||||
if grep -q "pip3 install" "$DTPI_SCRIPT"; then
|
||||
if ! grep -B50 "pip3 install" "$DTPI_SCRIPT" | grep -q "apt-get install.*python3-pip\|command -v pip3"; then
|
||||
error "DangerousPi: pip3 used but not installed"
|
||||
else
|
||||
ok "DangerousPi: pip3 installation check present"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "2. Checking for missing directory creation..."
|
||||
echo "-------------------------------------------"
|
||||
|
||||
# Check WiFi AP script
|
||||
WIFI_SCRIPT="$PROJECT_DIR/pi-gen/stageDangerousPi/01-Wireless-AP/00-run-chroot.sh"
|
||||
if [ -f "$WIFI_SCRIPT" ]; then
|
||||
# Check for directory writes
|
||||
DIRS_WRITTEN=$(grep -n "cat >" "$WIFI_SCRIPT" | grep -oE '/etc/[^/]+(/[^/]+)*/' | sort -u)
|
||||
for dir in $DIRS_WRITTEN; do
|
||||
dir_parent=$(dirname "$dir")
|
||||
if ! grep -q "mkdir -p $dir_parent\|mkdir -p $dir" "$WIFI_SCRIPT"; then
|
||||
warn "WiFi: Writing to $dir without mkdir -p"
|
||||
else
|
||||
ok "WiFi: $dir directory creation verified"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "3. Checking for undefined variables..."
|
||||
echo "------------------------------------"
|
||||
|
||||
for script in "$PROJECT_DIR"/pi-gen/stage*/*/00-run*.sh; do
|
||||
if [ -f "$script" ]; then
|
||||
stage=$(basename $(dirname $(dirname "$script")))
|
||||
substage=$(basename $(dirname "$script"))
|
||||
|
||||
# Check for common undefined vars
|
||||
if grep -q '\$ROOTFS_DIR' "$script" && [ "$(basename "$script")" != "00-run.sh" ]; then
|
||||
warn "$stage/$substage: Uses \$ROOTFS_DIR in chroot script (should be in 00-run.sh)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "4. Checking for missing error handling..."
|
||||
echo "---------------------------------------"
|
||||
|
||||
for script in "$PROJECT_DIR"/pi-gen/stage*/*/00-run*.sh; do
|
||||
if [ -f "$script" ]; then
|
||||
stage=$(basename $(dirname $(dirname "$script")))
|
||||
substage=$(basename $(dirname "$script"))
|
||||
|
||||
# Check if script has #!/bin/bash -e
|
||||
if ! head -1 "$script" | grep -q "bash -e"; then
|
||||
warn "$stage/$substage: Missing 'set -e' or '#!/bin/bash -e'"
|
||||
else
|
||||
ok "$stage/$substage: Error handling enabled"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "5. Checking for sudo usage (not allowed in chroot)..."
|
||||
echo "---------------------------------------------------"
|
||||
|
||||
for script in "$PROJECT_DIR"/pi-gen/stage*/*/00-run*.sh; do
|
||||
if [ -f "$script" ]; then
|
||||
stage=$(basename $(dirname $(dirname "$script")))
|
||||
substage=$(basename $(dirname "$script"))
|
||||
|
||||
if grep -q "sudo " "$script"; then
|
||||
error "$stage/$substage: Contains 'sudo' commands (not allowed in chroot)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
ok "No sudo commands found in stage scripts"
|
||||
|
||||
echo ""
|
||||
echo "6. Checking configuration consistency..."
|
||||
echo "--------------------------------------"
|
||||
|
||||
CONFIG="$PROJECT_DIR/pi-gen/config"
|
||||
if [ -f "$CONFIG" ]; then
|
||||
USERNAME=$(grep "FIRST_USER_NAME=" "$CONFIG" | cut -d'"' -f2)
|
||||
echo "Configured username: $USERNAME"
|
||||
|
||||
if [ "$USERNAME" != "pi" ]; then
|
||||
warn "Username is '$USERNAME' (not standard 'pi')"
|
||||
echo " This is OK if intentional, but verify all scripts handle it correctly"
|
||||
else
|
||||
ok "Using standard 'pi' username"
|
||||
fi
|
||||
|
||||
# Check if QCOW2 is enabled
|
||||
if grep -q "USE_QCOW2=1" "$CONFIG"; then
|
||||
ok "QCOW2 caching enabled for fast rebuilds"
|
||||
else
|
||||
warn "QCOW2 caching disabled - rebuilds will be slow"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "7. Checking for required EXPORT_IMAGE markers..."
|
||||
echo "----------------------------------------------"
|
||||
|
||||
for stage in "$PROJECT_DIR"/pi-gen/stage{PM3,DangerousPi}; do
|
||||
if [ -d "$stage" ]; then
|
||||
stage_name=$(basename "$stage")
|
||||
if [ -f "$stage/EXPORT_IMAGE" ]; then
|
||||
ok "$stage_name: EXPORT_IMAGE present"
|
||||
else
|
||||
error "$stage_name: Missing EXPORT_IMAGE marker"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Audit Summary ==="
|
||||
echo "Warnings: $WARNINGS"
|
||||
echo "Errors: $ERRORS"
|
||||
echo ""
|
||||
|
||||
if [ $ERRORS -gt 0 ]; then
|
||||
echo -e "${RED}BUILD SCRIPTS HAVE ERRORS - REVIEW REQUIRED${NC}"
|
||||
exit 1
|
||||
elif [ $WARNINGS -gt 0 ]; then
|
||||
echo -e "${YELLOW}BUILD SCRIPTS HAVE WARNINGS - REVIEW RECOMMENDED${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${GREEN}ALL CHECKS PASSED${NC}"
|
||||
exit 0
|
||||
fi
|
||||
39
stageDangerousPi/03-dangerous-pi/files/scripts/build-status.sh
Executable file
39
stageDangerousPi/03-dangerous-pi/files/scripts/build-status.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
# Quick build status checker
|
||||
# Usage: ./scripts/build-status.sh
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
if ! docker ps --filter name=pigen_work --format "{{.Status}}" &>/dev/null; then
|
||||
echo -e "${RED}Docker not available${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STATUS=$(docker ps --filter name=pigen_work --format "{{.Status}}")
|
||||
|
||||
if [ -z "$STATUS" ]; then
|
||||
echo -e "${YELLOW}No build running${NC}"
|
||||
echo "Start a build with: ./build-image.sh full"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Build Status:${NC} $STATUS"
|
||||
echo ""
|
||||
echo -e "${BLUE}Current Stage:${NC}"
|
||||
docker logs pigen_work 2>&1 | grep -E "^\[.*\] Begin /pi-gen/(stage[^/]+)" | tail -1
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}Recent Progress (last 10 steps):${NC}"
|
||||
docker logs pigen_work 2>&1 | grep -E "^\[.*\] (Begin|End)" | tail -10
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}Latest Activity:${NC}"
|
||||
docker logs --tail 3 pigen_work 2>&1
|
||||
|
||||
echo ""
|
||||
echo -e "Watch live: ${YELLOW}docker logs -f pigen_work${NC}"
|
||||
55
stageDangerousPi/03-dangerous-pi/files/scripts/install-udev-rules.sh
Executable file
55
stageDangerousPi/03-dangerous-pi/files/scripts/install-udev-rules.sh
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
# Install udev rules for Proxmark3 device detection
|
||||
# Run with sudo
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
UDEV_RULES_SRC="$PROJECT_ROOT/udev/77-dangerous-pi-pm3.rules"
|
||||
UDEV_RULES_DEST="/etc/udev/rules.d/77-dangerous-pi-pm3.rules"
|
||||
|
||||
echo "Installing Proxmark3 udev rules for Dangerous Pi..."
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "ERROR: This script must be run as root (use sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if source file exists
|
||||
if [ ! -f "$UDEV_RULES_SRC" ]; then
|
||||
echo "ERROR: Source udev rules file not found: $UDEV_RULES_SRC"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy rules file
|
||||
echo "Copying udev rules to $UDEV_RULES_DEST..."
|
||||
cp "$UDEV_RULES_SRC" "$UDEV_RULES_DEST"
|
||||
chmod 644 "$UDEV_RULES_DEST"
|
||||
|
||||
# Reload udev rules
|
||||
echo "Reloading udev rules..."
|
||||
udevadm control --reload-rules
|
||||
udevadm trigger
|
||||
|
||||
# Add user to plugdev group if not already a member
|
||||
if ! groups $SUDO_USER | grep -q '\bplugdev\b'; then
|
||||
echo "Adding user $SUDO_USER to plugdev group..."
|
||||
usermod -a -G plugdev $SUDO_USER
|
||||
echo "NOTE: User must log out and back in for group membership to take effect"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✓ Udev rules installed successfully!"
|
||||
echo ""
|
||||
echo "The following rules are now active:"
|
||||
echo " - Proxmark3 RDV4/Easy devices will be accessible at /dev/ttyACM*"
|
||||
echo " - Devices will have permissions 0666 (readable/writable by all)"
|
||||
echo " - Symlinks created: /dev/proxmark3-*"
|
||||
echo " - Events logged to system logger"
|
||||
echo ""
|
||||
echo "To test, connect a Proxmark3 device and run:"
|
||||
echo " ls -l /dev/ttyACM*"
|
||||
echo " ls -l /dev/proxmark3-*"
|
||||
echo ""
|
||||
657
stageDangerousPi/03-dangerous-pi/files/scripts/preflight-check.sh
Executable file
657
stageDangerousPi/03-dangerous-pi/files/scripts/preflight-check.sh
Executable file
@@ -0,0 +1,657 @@
|
||||
#!/bin/bash
|
||||
# Comprehensive Pre-Flight Validation for Dangerous Pi Builds
|
||||
# Catches errors BEFORE wasting hours on failed builds
|
||||
# Usage: ./scripts/preflight-check.sh
|
||||
|
||||
# Don't use set -e - we want to collect ALL errors, not stop on first one
|
||||
set +e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
PI_GEN_DIR="/home/work/pi-gen-builder"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
ERRORS=0
|
||||
WARNINGS=0
|
||||
CHECKS_PASSED=0
|
||||
|
||||
error() {
|
||||
echo -e "${RED}✗ ERROR:${NC} $1"
|
||||
((ERRORS++))
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}⚠ WARNING:${NC} $1"
|
||||
((WARNINGS++))
|
||||
}
|
||||
|
||||
pass() {
|
||||
echo -e "${GREEN}✓${NC} $1"
|
||||
((CHECKS_PASSED++))
|
||||
}
|
||||
|
||||
info() {
|
||||
echo -e "${BLUE}ℹ${NC} $1"
|
||||
}
|
||||
|
||||
section() {
|
||||
echo ""
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${BLUE}$1${NC}"
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════╗"
|
||||
echo "║ DANGEROUS PI BUILD PRE-FLIGHT VALIDATION ║"
|
||||
echo "╚════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
section "1. PI-GEN ENVIRONMENT CHECKS"
|
||||
# ============================================================================
|
||||
|
||||
# Check if pi-gen directory exists
|
||||
if [ -d "$PI_GEN_DIR" ]; then
|
||||
pass "Pi-gen directory exists: $PI_GEN_DIR"
|
||||
else
|
||||
error "Pi-gen directory not found: $PI_GEN_DIR"
|
||||
fi
|
||||
|
||||
# Check Docker availability
|
||||
if command -v docker &> /dev/null; then
|
||||
if docker ps &> /dev/null; then
|
||||
pass "Docker is available and accessible"
|
||||
else
|
||||
error "Docker command exists but cannot connect (check permissions)"
|
||||
fi
|
||||
else
|
||||
error "Docker is not installed or not in PATH"
|
||||
fi
|
||||
|
||||
# Check qemu registration
|
||||
if [ -f "/proc/sys/fs/binfmt_misc/qemu-aarch64" ]; then
|
||||
if grep -q "enabled" /proc/sys/fs/binfmt_misc/qemu-aarch64; then
|
||||
pass "qemu-aarch64 is registered and enabled"
|
||||
else
|
||||
warn "qemu-aarch64 exists but may not be enabled"
|
||||
fi
|
||||
else
|
||||
warn "qemu-aarch64 not registered (will be registered during build)"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
section "2. CONFIGURATION FILE VALIDATION"
|
||||
# ============================================================================
|
||||
|
||||
CONFIG_FILE="$PROJECT_DIR/pi-gen/config"
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
pass "Config file exists: $CONFIG_FILE"
|
||||
|
||||
# Check required variables
|
||||
for var in IMG_NAME RELEASE STAGE_LIST FIRST_USER_NAME; do
|
||||
if grep -q "^$var=" "$CONFIG_FILE"; then
|
||||
value=$(grep "^$var=" "$CONFIG_FILE" | cut -d'=' -f2- | tr -d '"')
|
||||
pass "Config has $var=$value"
|
||||
else
|
||||
error "Config missing required variable: $var"
|
||||
fi
|
||||
done
|
||||
|
||||
# Check QCOW2 setting
|
||||
if grep -q "USE_QCOW2=1" "$CONFIG_FILE"; then
|
||||
pass "QCOW2 caching enabled (fast incremental builds)"
|
||||
else
|
||||
warn "QCOW2 caching disabled (slow rebuilds)"
|
||||
fi
|
||||
else
|
||||
error "Config file not found: $CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
section "3. STAGE STRUCTURE VALIDATION"
|
||||
# ============================================================================
|
||||
|
||||
# Get list of custom stages from config
|
||||
CUSTOM_STAGES=$(grep "^STAGE_LIST=" "$CONFIG_FILE" | cut -d'"' -f2 | grep -oE 'stage[A-Za-z0-9_]+' | grep -v '^stage[0-9]$')
|
||||
|
||||
for stage in $CUSTOM_STAGES; do
|
||||
STAGE_DIR="$PROJECT_DIR/pi-gen/$stage"
|
||||
|
||||
if [ ! -d "$STAGE_DIR" ]; then
|
||||
error "Stage directory does not exist: $STAGE_DIR"
|
||||
continue
|
||||
fi
|
||||
|
||||
info "Checking stage: $stage"
|
||||
|
||||
# Check for prerun.sh (CRITICAL - this was the bug!)
|
||||
if [ -f "$STAGE_DIR/prerun.sh" ]; then
|
||||
pass " Has prerun.sh"
|
||||
|
||||
# Check if prerun.sh contains copy_previous
|
||||
if grep -q "copy_previous" "$STAGE_DIR/prerun.sh"; then
|
||||
pass " prerun.sh calls copy_previous"
|
||||
else
|
||||
error " prerun.sh exists but doesn't call copy_previous"
|
||||
info " This will cause: 'Unable to chroot' error"
|
||||
fi
|
||||
|
||||
# Check if executable
|
||||
if [ -x "$STAGE_DIR/prerun.sh" ]; then
|
||||
pass " prerun.sh is executable"
|
||||
else
|
||||
warn " prerun.sh is not executable (should be chmod +x)"
|
||||
fi
|
||||
else
|
||||
# Only error if this is not stage0 (stage0 doesn't need prerun.sh)
|
||||
if [ "$stage" != "stage0" ]; then
|
||||
error " MISSING prerun.sh (CRITICAL)"
|
||||
info " This will cause: 'No such file or directory' for rootfs"
|
||||
info " Fix: Create prerun.sh with copy_previous function"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for EXPORT_IMAGE
|
||||
if [ -f "$STAGE_DIR/EXPORT_IMAGE" ]; then
|
||||
pass " Has EXPORT_IMAGE"
|
||||
|
||||
# Validate EXPORT_IMAGE syntax
|
||||
if grep -q "IMG_SUFFIX" "$STAGE_DIR/EXPORT_IMAGE"; then
|
||||
pass " EXPORT_IMAGE has IMG_SUFFIX"
|
||||
else
|
||||
warn " EXPORT_IMAGE missing IMG_SUFFIX"
|
||||
fi
|
||||
else
|
||||
warn " No EXPORT_IMAGE (stage won't create image)"
|
||||
fi
|
||||
|
||||
# Check for substages
|
||||
substage_count=$(find "$STAGE_DIR" -maxdepth 1 -type d -name '[0-9][0-9]*' | wc -l)
|
||||
if [ $substage_count -eq 0 ]; then
|
||||
warn " No substages found (stage does nothing)"
|
||||
else
|
||||
pass " Found $substage_count substage(s)"
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
section "4. BUILD SCRIPT SYNTAX VALIDATION"
|
||||
# ============================================================================
|
||||
|
||||
info "Checking all stage scripts for syntax errors..."
|
||||
|
||||
for script in "$PROJECT_DIR"/pi-gen/stage*/*/[0-9][0-9]-*.sh; do
|
||||
if [ -f "$script" ]; then
|
||||
if bash -n "$script" 2>/dev/null; then
|
||||
pass " $(basename $(dirname $(dirname "$script")))/$(basename $(dirname "$script"))/$(basename "$script")"
|
||||
else
|
||||
error " Syntax error in: $script"
|
||||
bash -n "$script" 2>&1 | sed 's/^/ /'
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Check prerun.sh files
|
||||
for script in "$PROJECT_DIR"/pi-gen/stage*/prerun.sh; do
|
||||
if [ -f "$script" ]; then
|
||||
if bash -n "$script" 2>/dev/null; then
|
||||
pass " $(basename $(dirname "$script"))/prerun.sh"
|
||||
else
|
||||
error " Syntax error in: $script"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
section "5. DEPENDENCY CHECKS IN SCRIPTS"
|
||||
# ============================================================================
|
||||
|
||||
info "Checking if commands are installed before use..."
|
||||
|
||||
# Check PM3 stage for cmake before use
|
||||
PM3_SCRIPT="$PROJECT_DIR/pi-gen/stage*/01-proxmark3/00-run-chroot.sh"
|
||||
if ls $PM3_SCRIPT 2>/dev/null | head -1 | xargs -I {} bash -c '
|
||||
script="$1"
|
||||
if grep -q "cmake" "$script"; then
|
||||
if grep -B50 "cmake" "$script" | grep -q "apt-get install.*cmake"; then
|
||||
echo "PASS"
|
||||
else
|
||||
echo "FAIL"
|
||||
fi
|
||||
else
|
||||
echo "SKIP"
|
||||
fi
|
||||
' _ {} | grep -q "PASS"; then
|
||||
pass " PM3: cmake installed before use"
|
||||
elif ls $PM3_SCRIPT 2>/dev/null | head -1 | xargs -I {} bash -c '
|
||||
script="$1"
|
||||
if grep -q "cmake" "$script"; then
|
||||
if grep -B50 "cmake" "$script" | grep -q "apt-get install.*cmake"; then
|
||||
echo "PASS"
|
||||
else
|
||||
echo "FAIL"
|
||||
fi
|
||||
else
|
||||
echo "SKIP"
|
||||
fi
|
||||
' _ {} | grep -q "FAIL"; then
|
||||
warn " PM3: cmake may not be installed before use"
|
||||
fi
|
||||
|
||||
# Check dangerous-pi stage for pip3
|
||||
DTPI_SCRIPT="$PROJECT_DIR/pi-gen/stage*/*/03-dangerous-pi/00-run-chroot.sh"
|
||||
if ls $DTPI_SCRIPT 2>/dev/null | head -1 | xargs -I {} bash -c '
|
||||
script="$1"
|
||||
if grep -q "pip3 install" "$script"; then
|
||||
if grep -B20 "pip3 install" "$script" | grep -qE "apt-get install.*python3-pip|command -v pip3"; then
|
||||
echo "PASS"
|
||||
else
|
||||
echo "FAIL"
|
||||
fi
|
||||
else
|
||||
echo "SKIP"
|
||||
fi
|
||||
' _ {} | grep -q "PASS"; then
|
||||
pass " Dangerous-Pi: pip3 check before use"
|
||||
elif ls $DTPI_SCRIPT 2>/dev/null | head -1 | xargs -I {} bash -c '
|
||||
script="$1"
|
||||
if grep -q "pip3 install" "$script"; then
|
||||
if grep -B20 "pip3 install" "$script" | grep -qE "apt-get install.*python3-pip|command -v pip3"; then
|
||||
echo "PASS"
|
||||
else
|
||||
echo "FAIL"
|
||||
fi
|
||||
else
|
||||
echo "SKIP"
|
||||
fi
|
||||
' _ {} | grep -q "FAIL"; then
|
||||
error " Dangerous-Pi: pip3 used without installation check"
|
||||
fi
|
||||
|
||||
# Check PM3 dependencies by analyzing source
|
||||
info "Analyzing PM3 source for required dependencies..."
|
||||
PM3_SCRIPT_PATH="$PROJECT_DIR/pi-gen/stagePM3/01-proxmark3/00-run-chroot.sh"
|
||||
|
||||
if [ -f "$PM3_SCRIPT_PATH" ]; then
|
||||
# Clone PM3 temporarily to analyze (lightweight, just for analysis)
|
||||
PM3_TEMP="/tmp/pm3-preflight-check"
|
||||
if [ ! -d "$PM3_TEMP" ]; then
|
||||
info "Cloning Proxmark3 for dependency analysis..."
|
||||
git clone --depth 1 --quiet https://github.com/RfidResearchGroup/proxmark3 "$PM3_TEMP" 2>/dev/null
|
||||
fi
|
||||
|
||||
if [ -d "$PM3_TEMP" ]; then
|
||||
# Header to package mapping (common Debian packages)
|
||||
declare -A HEADER_TO_PKG=(
|
||||
["lz4frame.h"]="liblz4-dev"
|
||||
["lz4.h"]="liblz4-dev"
|
||||
["readline/readline.h"]="libreadline-dev"
|
||||
["readline.h"]="libreadline-dev"
|
||||
["libusb.h"]="libusb-1.0-0-dev"
|
||||
["libusb-1.0/libusb.h"]="libusb-1.0-0-dev"
|
||||
["pthread.h"]="libc6-dev"
|
||||
["zlib.h"]="zlib1g-dev"
|
||||
["bzlib.h"]="libbz2-dev"
|
||||
["jansson.h"]="libjansson-dev"
|
||||
["bluez/bluetooth.h"]="libbluetooth-dev"
|
||||
)
|
||||
|
||||
# Find all #include statements for system headers
|
||||
FOUND_HEADERS=$(grep -rh "^#include <" "$PM3_TEMP/client" 2>/dev/null | \
|
||||
grep -oE '<[^>]+>' | tr -d '<>' | sort -u)
|
||||
|
||||
# Check if required packages are installed
|
||||
for header in $FOUND_HEADERS; do
|
||||
pkg="${HEADER_TO_PKG[$header]}"
|
||||
if [ -n "$pkg" ]; then
|
||||
# Check if this package is in our install script
|
||||
if grep -q "$pkg" "$PM3_SCRIPT_PATH"; then
|
||||
pass " PM3: $pkg (for $header)"
|
||||
else
|
||||
error " PM3: Missing $pkg (required for #include <$header>)"
|
||||
info " Add '$pkg' to apt-get install in stagePM3/01-proxmark3/00-run-chroot.sh"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Also check for essential build tools
|
||||
ESSENTIAL_DEPS=("cmake" "build-essential" "git" "pkg-config")
|
||||
for dep in "${ESSENTIAL_DEPS[@]}"; do
|
||||
if grep -q "$dep" "$PM3_SCRIPT_PATH"; then
|
||||
pass " PM3: $dep (build tool)"
|
||||
else
|
||||
error " PM3: Missing $dep (essential build tool)"
|
||||
info " Add '$dep' to apt-get install in stagePM3/01-proxmark3/00-run-chroot.sh"
|
||||
fi
|
||||
done
|
||||
else
|
||||
warn " Could not clone PM3 for analysis - using basic checks only"
|
||||
# Fallback to basic check
|
||||
for dep in cmake build-essential git; do
|
||||
if grep -q "$dep" "$PM3_SCRIPT_PATH"; then
|
||||
pass " PM3: $dep"
|
||||
else
|
||||
error " PM3: Missing $dep"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
section "6. DIRECTORY CREATION VALIDATION"
|
||||
# ============================================================================
|
||||
|
||||
info "Checking for mkdir -p before file writes to non-standard dirs..."
|
||||
|
||||
WIFI_SCRIPT="$PROJECT_DIR/pi-gen/stage*/*/01-Wireless-AP/00-run-chroot.sh"
|
||||
if [ -f "$(ls $WIFI_SCRIPT 2>/dev/null | head -1)" ]; then
|
||||
# Check /etc/network/interfaces.d
|
||||
if grep -q "cat.*>.*\/etc\/network\/interfaces.d" "$(ls $WIFI_SCRIPT 2>/dev/null | head -1)"; then
|
||||
if grep -q "mkdir -p /etc/network/interfaces.d" "$(ls $WIFI_SCRIPT 2>/dev/null | head -1)"; then
|
||||
pass " WiFi: /etc/network/interfaces.d created before use"
|
||||
else
|
||||
error " WiFi: /etc/network/interfaces.d not created (will fail)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check lighttpd directories
|
||||
if grep -q "cat.*>.*lighttpd" "$(ls $WIFI_SCRIPT 2>/dev/null | head -1)"; then
|
||||
if grep -q "mkdir -p.*lighttpd.*conf-" "$(ls $WIFI_SCRIPT 2>/dev/null | head -1)"; then
|
||||
pass " WiFi: lighttpd config directories created"
|
||||
else
|
||||
warn " WiFi: lighttpd directories may not exist"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
section "7. SOURCE FILE AVAILABILITY"
|
||||
# ============================================================================
|
||||
|
||||
# Check if source files exist
|
||||
for dir in app systemd scripts; do
|
||||
if [ -d "$PROJECT_DIR/$dir" ]; then
|
||||
pass "Source directory exists: $dir/"
|
||||
else
|
||||
error "Source directory missing: $dir/"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -f "$PROJECT_DIR/requirements.txt" ]; then
|
||||
pass "requirements.txt exists"
|
||||
else
|
||||
error "requirements.txt missing"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
section "8. BUILD SCRIPT VALIDATION"
|
||||
# ============================================================================
|
||||
|
||||
BUILD_SCRIPT="$PROJECT_DIR/build-image.sh"
|
||||
if [ -f "$BUILD_SCRIPT" ]; then
|
||||
pass "build-image.sh exists"
|
||||
|
||||
if [ -x "$BUILD_SCRIPT" ]; then
|
||||
pass "build-image.sh is executable"
|
||||
else
|
||||
warn "build-image.sh is not executable"
|
||||
fi
|
||||
|
||||
# Check if it uses our custom stages
|
||||
if grep -q "stagePM3\|stageDangerousPi\|stage3\|stage4" "$BUILD_SCRIPT"; then
|
||||
pass "build-image.sh references custom stages"
|
||||
else
|
||||
warn "build-image.sh may not reference custom stages"
|
||||
fi
|
||||
else
|
||||
error "build-image.sh not found"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
section "9. PACKAGE NAME VALIDATION"
|
||||
# ============================================================================
|
||||
|
||||
info "Validating package names against Debian repositories..."
|
||||
|
||||
# Create temporary container to query package database
|
||||
TEMP_CONTAINER="preflight-pkg-check-$$"
|
||||
docker run -d --name "$TEMP_CONTAINER" debian:trixie sleep 3600 > /dev/null 2>&1
|
||||
docker exec "$TEMP_CONTAINER" apt-get update > /dev/null 2>&1
|
||||
|
||||
# Extract all package names from apt-get install commands
|
||||
ALL_PACKAGES=$(grep -rh "apt-get install" "$PROJECT_DIR/pi-gen/stage"* 2>/dev/null | \
|
||||
grep -v "^#" | \
|
||||
sed 's/.*apt-get install//' | \
|
||||
sed 's/-y//g; s/--no-install-recommends//g; s/--break-system-packages//g' | \
|
||||
tr '\\' ' ' | \
|
||||
tr '\n' ' ' | \
|
||||
tr -s ' ' | \
|
||||
grep -oE '[a-z0-9][a-z0-9+.-]+' | \
|
||||
sort -u)
|
||||
|
||||
PACKAGE_ERRORS=0
|
||||
for pkg in $ALL_PACKAGES; do
|
||||
# Skip common flags, keywords, and commands
|
||||
if echo "$pkg" | grep -qE '^(y|no|install|apt|get|update|upgrade|break|system|packages)$'; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if package exists in Debian repos
|
||||
if docker exec "$TEMP_CONTAINER" apt-cache show "$pkg" > /dev/null 2>&1; then
|
||||
pass " Package exists: $pkg"
|
||||
else
|
||||
error " Package NOT FOUND in Debian repos: $pkg"
|
||||
info " Check for typos or hallucinated package names!"
|
||||
((PACKAGE_ERRORS++))
|
||||
fi
|
||||
done
|
||||
|
||||
# Cleanup
|
||||
docker rm -f "$TEMP_CONTAINER" > /dev/null 2>&1
|
||||
|
||||
if [ $PACKAGE_ERRORS -eq 0 ]; then
|
||||
pass "All package names validated successfully"
|
||||
else
|
||||
error "Found $PACKAGE_ERRORS invalid package names"
|
||||
info " Common mistakes: python-swig (should be: swig), libbluez-dev (should be: libbluetooth-dev)"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
section "10. FILE REFERENCE VALIDATION"
|
||||
# ============================================================================
|
||||
|
||||
info "Checking for references to missing files..."
|
||||
|
||||
# Check for file copies in 00-run.sh scripts (outside chroot)
|
||||
for script in "$PROJECT_DIR"/pi-gen/stage*/*/00-run.sh; do
|
||||
if [ -f "$script" ]; then
|
||||
STAGE_DIR=$(dirname "$script")
|
||||
|
||||
# Look for cp commands with source files
|
||||
while read -r line; do
|
||||
# Extract source path from cp commands
|
||||
if echo "$line" | grep -q "^cp "; then
|
||||
SRC=$(echo "$line" | awk '{print $2}' | sed 's/"//g')
|
||||
|
||||
# Skip if it's a flag, variable, or special path
|
||||
if echo "$SRC" | grep -qE '^-|^\$|^\*|^/tmp|^/etc|^/var'; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if file exists relative to stage dir
|
||||
if [ -f "$STAGE_DIR/$SRC" ] || [ -d "$STAGE_DIR/$SRC" ]; then
|
||||
pass " Found: $SRC (in $(basename $(dirname "$script")))"
|
||||
else
|
||||
warn " Missing file reference: $SRC in $script"
|
||||
fi
|
||||
fi
|
||||
done < <(grep -E "^cp " "$script" 2>/dev/null)
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
section "11. SERVICE FILE SYNTAX VALIDATION"
|
||||
# ============================================================================
|
||||
|
||||
info "Validating systemd service files..."
|
||||
|
||||
# Find all .service files in stage directories
|
||||
for service_file in "$PROJECT_DIR"/pi-gen/stage*/*/*.service "$PROJECT_DIR"/systemd/*.service; do
|
||||
if [ -f "$service_file" ]; then
|
||||
BASENAME=$(basename "$service_file")
|
||||
|
||||
# Check for required sections
|
||||
if grep -q "^\[Unit\]" "$service_file" && \
|
||||
grep -q "^\[Service\]" "$service_file" && \
|
||||
grep -q "^\[Install\]" "$service_file"; then
|
||||
pass " Service file structure valid: $BASENAME"
|
||||
else
|
||||
error " Service file missing required sections: $BASENAME"
|
||||
info " Required: [Unit], [Service], [Install]"
|
||||
fi
|
||||
|
||||
# Check for ExecStart
|
||||
if grep -q "^ExecStart=" "$service_file"; then
|
||||
pass " Has ExecStart: $BASENAME"
|
||||
else
|
||||
error " Missing ExecStart in: $BASENAME"
|
||||
fi
|
||||
|
||||
# Check for invalid __PLACEHOLDERS__ that weren't substituted
|
||||
# Allow __DANGEROUS_PI_USER__ as it's intentionally substituted during build
|
||||
UNSUBBED=$(grep "__.*__" "$service_file" 2>/dev/null | grep -v "__DANGEROUS_PI_USER__" || true)
|
||||
if [ -n "$UNSUBBED" ]; then
|
||||
warn " Found un-substituted placeholder in: $BASENAME"
|
||||
echo "$UNSUBBED" | sed 's/^/ /'
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
section "12. ENVIRONMENT VARIABLE CHECKS"
|
||||
# ============================================================================
|
||||
|
||||
info "Checking for undefined environment variables in scripts..."
|
||||
|
||||
for script in "$PROJECT_DIR"/pi-gen/stage*/*/[0-9][0-9]-*.sh; do
|
||||
if [ -f "$script" ]; then
|
||||
SCRIPT_NAME=$(basename "$script")
|
||||
|
||||
# Look for variable usage
|
||||
USED_VARS=$(grep -oE '\$\{?[A-Z_][A-Z0-9_]*\}?' "$script" 2>/dev/null | \
|
||||
sed 's/[${}]//g' | sort -u)
|
||||
|
||||
for var in $USED_VARS; do
|
||||
# Check if variable is defined in script or is a known variable
|
||||
if grep -q "^$var=" "$script" || \
|
||||
grep -q "export $var=" "$script" || \
|
||||
grep -q "$var=" "$script" || \
|
||||
echo "$var" | grep -qE '^(ROOTFS_DIR|SCRIPT_DIR|DEFAULT_USER|DEFAULT_HOME|PATH|HOME|USER|HTTP|PM3_ROOT|BOOT_DIR|CONFIG_FILE|CMDLINE_FILE)$'; then
|
||||
# Variable is defined, standard, or from external system (lighttpd config, PM3 build)
|
||||
continue
|
||||
else
|
||||
warn " Potentially undefined variable: \$$var in $SCRIPT_NAME"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
section "13. DISK SPACE CHECK"
|
||||
# ============================================================================
|
||||
|
||||
info "Checking available disk space..."
|
||||
|
||||
REQUIRED_GB=15
|
||||
AVAILABLE_KB=$(df /home/work/pi-gen-builder 2>/dev/null | tail -1 | awk '{print $4}')
|
||||
AVAILABLE_GB=$((AVAILABLE_KB / 1024 / 1024))
|
||||
|
||||
if [ $AVAILABLE_GB -ge $REQUIRED_GB ]; then
|
||||
pass "Sufficient disk space: ${AVAILABLE_GB}GB available (${REQUIRED_GB}GB required)"
|
||||
else
|
||||
error "Insufficient disk space: ${AVAILABLE_GB}GB available (${REQUIRED_GB}GB required)"
|
||||
info " Pi-gen builds require ~15GB for image + cache"
|
||||
info " Free up space or build will fail mid-download"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
section "14. NETWORK CONNECTIVITY CHECK"
|
||||
# ============================================================================
|
||||
|
||||
info "Testing network connectivity to required repositories..."
|
||||
|
||||
# Test Debian repos
|
||||
if curl -s --connect-timeout 5 http://deb.debian.org/debian/dists/trixie/Release > /dev/null 2>&1; then
|
||||
pass "Can reach deb.debian.org"
|
||||
else
|
||||
error "Cannot reach deb.debian.org (Debian package repository)"
|
||||
info " Check internet connection or firewall"
|
||||
fi
|
||||
|
||||
# Test Raspberry Pi repos
|
||||
if curl -s --connect-timeout 5 http://archive.raspberrypi.com/debian/dists/trixie/Release > /dev/null 2>&1; then
|
||||
pass "Can reach archive.raspberrypi.com"
|
||||
else
|
||||
error "Cannot reach archive.raspberrypi.com (Raspberry Pi repository)"
|
||||
fi
|
||||
|
||||
# Test GitHub (for PM3 clone)
|
||||
if curl -s --connect-timeout 5 https://github.com > /dev/null 2>&1; then
|
||||
pass "Can reach github.com"
|
||||
else
|
||||
error "Cannot reach github.com (needed for Proxmark3 clone)"
|
||||
fi
|
||||
|
||||
# Test PM3 repo specifically
|
||||
if git ls-remote https://github.com/RfidResearchGroup/proxmark3 > /dev/null 2>&1; then
|
||||
pass "Proxmark3 repository is accessible"
|
||||
else
|
||||
error "Cannot access Proxmark3 repository"
|
||||
info " URL: https://github.com/RfidResearchGroup/proxmark3"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
section "VALIDATION SUMMARY"
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "┌────────────────────────────────────────┐"
|
||||
echo "│ VALIDATION RESULTS │"
|
||||
echo "├────────────────────────────────────────┤"
|
||||
printf "│ ${GREEN}Checks Passed:${NC} %-4d │\n" $CHECKS_PASSED
|
||||
printf "│ ${YELLOW}Warnings:${NC} %-4d │\n" $WARNINGS
|
||||
printf "│ ${RED}Errors:${NC} %-4d │\n" $ERRORS
|
||||
echo "└────────────────────────────────────────┘"
|
||||
echo ""
|
||||
|
||||
if [ $ERRORS -gt 0 ]; then
|
||||
echo -e "${RED}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${RED}║ BUILD WILL FAIL - FIX ERRORS BEFORE ATTEMPTING BUILD ║${NC}"
|
||||
echo -e "${RED}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo "Do NOT start a build until all errors are fixed."
|
||||
echo "Each error listed above will cause build failure."
|
||||
exit 1
|
||||
elif [ $WARNINGS -gt 0 ]; then
|
||||
echo -e "${YELLOW}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${YELLOW}║ WARNINGS DETECTED - REVIEW BEFORE BUILD ║${NC}"
|
||||
echo -e "${YELLOW}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo "Warnings may indicate potential issues but won't necessarily fail the build."
|
||||
exit 0
|
||||
else
|
||||
echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ ALL CHECKS PASSED - READY TO BUILD ║${NC}"
|
||||
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo "Build environment is validated. Safe to proceed with:"
|
||||
echo " ./build-image.sh full"
|
||||
exit 0
|
||||
fi
|
||||
114
stageDangerousPi/03-dangerous-pi/files/scripts/resolve-port-conflict.sh
Executable file
114
stageDangerousPi/03-dangerous-pi/files/scripts/resolve-port-conflict.sh
Executable file
@@ -0,0 +1,114 @@
|
||||
#!/bin/bash
|
||||
# Script to resolve port conflict between Dangerous Pi and ttyd-bash
|
||||
|
||||
set -e
|
||||
|
||||
echo "Dangerous Pi Port Conflict Resolution"
|
||||
echo "======================================"
|
||||
echo ""
|
||||
echo "The existing pi-pm3 setup uses:"
|
||||
echo " - ttyd-bash on port 8000"
|
||||
echo " - ttyd-pm3 on port 8080"
|
||||
echo " - RaspAP on port 80"
|
||||
echo ""
|
||||
echo "Dangerous Pi wants to use port 8000 for the backend."
|
||||
echo ""
|
||||
echo "Choose a resolution option:"
|
||||
echo " 1) Disable ttyd-bash (recommended - use Dangerous Pi's web terminal instead)"
|
||||
echo " 2) Change Dangerous Pi to port 8001 (keep both services)"
|
||||
echo " 3) Change ttyd-bash to port 8002 (keep both services)"
|
||||
echo " 4) Cancel (manual configuration)"
|
||||
echo ""
|
||||
|
||||
read -p "Enter option (1-4): " option
|
||||
|
||||
case $option in
|
||||
1)
|
||||
echo ""
|
||||
echo "Disabling ttyd-bash service..."
|
||||
|
||||
if systemctl is-active --quiet ttyd-bash 2>/dev/null; then
|
||||
sudo systemctl stop ttyd-bash
|
||||
echo "✅ Stopped ttyd-bash"
|
||||
fi
|
||||
|
||||
if systemctl is-enabled --quiet ttyd-bash 2>/dev/null; then
|
||||
sudo systemctl disable ttyd-bash
|
||||
echo "✅ Disabled ttyd-bash (won't start on boot)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ Done! Dangerous Pi can now use port 8000."
|
||||
echo ""
|
||||
echo "To re-enable ttyd-bash later:"
|
||||
echo " sudo systemctl enable ttyd-bash"
|
||||
echo " sudo systemctl start ttyd-bash"
|
||||
;;
|
||||
|
||||
2)
|
||||
echo ""
|
||||
echo "Changing Dangerous Pi to port 8001..."
|
||||
|
||||
# Update environment file
|
||||
if [ -f /opt/dangerous-pi/.env ]; then
|
||||
if grep -q "^PORT=" /opt/dangerous-pi/.env; then
|
||||
sudo sed -i 's/^PORT=.*/PORT=8001/' /opt/dangerous-pi/.env
|
||||
else
|
||||
echo "PORT=8001" | sudo tee -a /opt/dangerous-pi/.env
|
||||
fi
|
||||
echo "✅ Updated /opt/dangerous-pi/.env"
|
||||
else
|
||||
echo "⚠️ Environment file not found. Please edit manually."
|
||||
fi
|
||||
|
||||
# Restart service if running
|
||||
if systemctl is-active --quiet dangerous-pi 2>/dev/null; then
|
||||
sudo systemctl restart dangerous-pi
|
||||
echo "✅ Restarted dangerous-pi service"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ Done! Dangerous Pi now uses port 8001."
|
||||
echo " Access at: http://$(hostname -I | awk '{print $1}'):8001"
|
||||
;;
|
||||
|
||||
3)
|
||||
echo ""
|
||||
echo "Changing ttyd-bash to port 8002..."
|
||||
|
||||
# Find and update ttyd-bash service file
|
||||
if [ -f /etc/systemd/system/ttyd-bash.service ]; then
|
||||
sudo sed -i 's/--port 8000/--port 8002/' /etc/systemd/system/ttyd-bash.service
|
||||
sudo systemctl daemon-reload
|
||||
echo "✅ Updated ttyd-bash service"
|
||||
|
||||
if systemctl is-active --quiet ttyd-bash 2>/dev/null; then
|
||||
sudo systemctl restart ttyd-bash
|
||||
echo "✅ Restarted ttyd-bash"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ Done! ttyd-bash now uses port 8002."
|
||||
echo " ttyd-bash: http://$(hostname -I | awk '{print $1}'):8002"
|
||||
echo " Dangerous Pi can use port 8000"
|
||||
else
|
||||
echo "⚠️ ttyd-bash service file not found. Please configure manually."
|
||||
fi
|
||||
;;
|
||||
|
||||
4)
|
||||
echo ""
|
||||
echo "Cancelled. Manual configuration required."
|
||||
echo ""
|
||||
echo "Configuration files:"
|
||||
echo " - Dangerous Pi: /opt/dangerous-pi/.env (PORT variable)"
|
||||
echo " - ttyd-bash: /etc/systemd/system/ttyd-bash.service"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Invalid option. Exiting."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
235
stageDangerousPi/03-dangerous-pi/files/systemd/README.md
Normal file
235
stageDangerousPi/03-dangerous-pi/files/systemd/README.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Dangerous Pi Systemd Service
|
||||
|
||||
This directory contains systemd service files and installation scripts for running Dangerous Pi as a system service.
|
||||
|
||||
## Files
|
||||
|
||||
- `dangerous-pi.service` - Main systemd service unit file
|
||||
- `dangerous-pi.env.example` - Environment configuration template
|
||||
- `install-service.sh` - Installation script
|
||||
- `uninstall-service.sh` - Uninstallation script
|
||||
|
||||
## Installation
|
||||
|
||||
### Automated Installation
|
||||
|
||||
Run the installation script as root:
|
||||
|
||||
```bash
|
||||
cd /path/to/dangerous-pi/systemd
|
||||
sudo ./install-service.sh
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Copy the service file to `/etc/systemd/system/`
|
||||
2. Create the environment configuration file at `/opt/dangerous-pi/.env`
|
||||
3. Create data and logs directories
|
||||
4. Add the `pi` user to required hardware access groups
|
||||
5. Enable the service to start on boot
|
||||
|
||||
### Manual Installation
|
||||
|
||||
If you prefer to install manually:
|
||||
|
||||
```bash
|
||||
# Copy service file
|
||||
sudo cp dangerous-pi.service /etc/systemd/system/
|
||||
|
||||
# Copy environment template
|
||||
sudo cp dangerous-pi.env.example /opt/dangerous-pi/.env
|
||||
|
||||
# Create directories
|
||||
sudo mkdir -p /opt/dangerous-pi/data /opt/dangerous-pi/logs
|
||||
sudo chown -R pi:pi /opt/dangerous-pi/data /opt/dangerous-pi/logs
|
||||
|
||||
# Add pi user to groups
|
||||
sudo usermod -a -G i2c,bluetooth,gpio,dialout pi
|
||||
|
||||
# Reload systemd and enable service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable dangerous-pi
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit the environment file to customize your installation:
|
||||
|
||||
```bash
|
||||
sudo nano /opt/dangerous-pi/.env
|
||||
```
|
||||
|
||||
Available configuration options:
|
||||
- `PM3_DEVICE` - Proxmark3 device path (default: `/dev/ttyACM0`)
|
||||
- `PM3_TIMEOUT` - PM3 command timeout in seconds
|
||||
- `SESSION_TIMEOUT` - User session timeout in seconds
|
||||
- `HOST` - Server bind address (default: `0.0.0.0`)
|
||||
- `PORT` - Server port (default: `8000`)
|
||||
- `GITHUB_REPO` - GitHub repository for updates
|
||||
- `UPS_I2C_ADDRESS` - I2C address for UPS HAT
|
||||
- `BLE_ENABLED` - Enable/disable BLE notifications
|
||||
- `AUTH_ENABLED` - Enable/disable authentication
|
||||
- See `dangerous-pi.env.example` for all options
|
||||
|
||||
## Service Management
|
||||
|
||||
### Start the service
|
||||
|
||||
```bash
|
||||
sudo systemctl start dangerous-pi
|
||||
```
|
||||
|
||||
### Stop the service
|
||||
|
||||
```bash
|
||||
sudo systemctl stop dangerous-pi
|
||||
```
|
||||
|
||||
### Restart the service
|
||||
|
||||
```bash
|
||||
sudo systemctl restart dangerous-pi
|
||||
```
|
||||
|
||||
### Check service status
|
||||
|
||||
```bash
|
||||
sudo systemctl status dangerous-pi
|
||||
```
|
||||
|
||||
### View service logs
|
||||
|
||||
```bash
|
||||
# View recent logs
|
||||
sudo journalctl -u dangerous-pi
|
||||
|
||||
# Follow logs in real-time
|
||||
sudo journalctl -u dangerous-pi -f
|
||||
|
||||
# View logs since boot
|
||||
sudo journalctl -u dangerous-pi -b
|
||||
```
|
||||
|
||||
### Enable service (start on boot)
|
||||
|
||||
```bash
|
||||
sudo systemctl enable dangerous-pi
|
||||
```
|
||||
|
||||
### Disable service (don't start on boot)
|
||||
|
||||
```bash
|
||||
sudo systemctl disable dangerous-pi
|
||||
```
|
||||
|
||||
## Uninstallation
|
||||
|
||||
Run the uninstallation script as root:
|
||||
|
||||
```bash
|
||||
cd /path/to/dangerous-pi/systemd
|
||||
sudo ./uninstall-service.sh
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Stop the service if running
|
||||
2. Disable the service
|
||||
3. Remove the service unit file
|
||||
4. Reload systemd daemon
|
||||
|
||||
Note: Application files in `/opt/dangerous-pi` are NOT removed automatically.
|
||||
|
||||
## Security Features
|
||||
|
||||
The service includes security hardening:
|
||||
- Runs as non-root user (`pi`)
|
||||
- Private `/tmp` directory
|
||||
- Protected system directories
|
||||
- Read-only application directory (except for `data` and `logs`)
|
||||
- Resource limits (memory, CPU, file descriptors)
|
||||
- No new privileges allowed
|
||||
|
||||
## Hardware Access
|
||||
|
||||
The service is configured to access the following hardware:
|
||||
- I2C devices (for UPS HAT) via `i2c` group
|
||||
- Bluetooth (for BLE notifications) via `bluetooth` group
|
||||
- GPIO pins via `gpio` group
|
||||
- Serial devices (for Proxmark3) via `dialout` group
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Service fails to start
|
||||
|
||||
Check the logs for errors:
|
||||
```bash
|
||||
sudo journalctl -u dangerous-pi -n 50
|
||||
```
|
||||
|
||||
### Permission denied errors
|
||||
|
||||
Ensure the `pi` user is in the required groups:
|
||||
```bash
|
||||
groups pi
|
||||
```
|
||||
|
||||
Should include: `i2c`, `bluetooth`, `gpio`, `dialout`
|
||||
|
||||
### Port already in use
|
||||
|
||||
The default port (8000) conflicts with ttyd-bash from pi-pm3. See the main README for resolution options.
|
||||
|
||||
### Can't access Proxmark3
|
||||
|
||||
Ensure the PM3 device path is correct in `/opt/dangerous-pi/.env`:
|
||||
```bash
|
||||
PM3_DEVICE=/dev/ttyACM0
|
||||
```
|
||||
|
||||
Check that the device exists:
|
||||
```bash
|
||||
ls -l /dev/ttyACM*
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Custom Installation Directory
|
||||
|
||||
To use a different installation directory, edit the service file before installation:
|
||||
|
||||
```bash
|
||||
WorkingDirectory=/your/custom/path
|
||||
ReadWritePaths=/your/custom/path/data /your/custom/path/logs
|
||||
```
|
||||
|
||||
### Different User/Group
|
||||
|
||||
To run as a different user, edit the service file:
|
||||
|
||||
```bash
|
||||
User=your-user
|
||||
Group=your-group
|
||||
```
|
||||
|
||||
Don't forget to add the user to required hardware groups.
|
||||
|
||||
### Resource Limits
|
||||
|
||||
Adjust resource limits in the service file:
|
||||
|
||||
```bash
|
||||
MemoryMax=1G # Maximum memory
|
||||
CPUQuota=100% # CPU usage limit
|
||||
LimitNOFILE=131072 # Max open files
|
||||
```
|
||||
|
||||
## Integration with pi-pm3
|
||||
|
||||
When running alongside the existing pi-pm3 setup:
|
||||
|
||||
1. **Port Conflict**: Port 8000 is used by ttyd-bash. Options:
|
||||
- Change Dangerous Pi port in `.env`: `PORT=8001`
|
||||
- Disable ttyd-bash: `sudo systemctl disable ttyd-bash`
|
||||
|
||||
2. **RaspAP Compatibility**: Dangerous Pi WiFi manager can coexist with RaspAP or replace it.
|
||||
|
||||
3. **PM3 Access**: Only one service should access PM3 at a time. Disable ttyd-pm3 if using Dangerous Pi's PM3 interface.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Dangerous Pi Environment Configuration
|
||||
# Copy this file to /opt/dangerous-pi/.env and customize as needed
|
||||
|
||||
# PM3 Configuration
|
||||
PM3_DEVICE=/dev/ttyACM0
|
||||
PM3_TIMEOUT=30
|
||||
|
||||
# Session Configuration
|
||||
SESSION_TIMEOUT=300
|
||||
|
||||
# Server Configuration
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
VERSION=1.0.0
|
||||
|
||||
# Update Configuration
|
||||
GITHUB_REPO=yourusername/dangerous-pi
|
||||
UPDATE_CHECK_INTERVAL=3600
|
||||
|
||||
# Wi-Fi Configuration
|
||||
WLAN_INTERFACE=wlan0
|
||||
USB_WLAN_INTERFACE=wlan1
|
||||
|
||||
# UPS Configuration
|
||||
# UPS_TYPE options: "pisugar", "i2c", "none"
|
||||
UPS_TYPE=i2c
|
||||
UPS_CHECK_INTERVAL=60
|
||||
|
||||
# I2C UPS settings (for generic fuel gauge HATs like MAX17040/MAX17048)
|
||||
UPS_I2C_ADDRESS=0x36
|
||||
|
||||
# PiSugar UPS settings (for PiSugar 2/3 series)
|
||||
UPS_PISUGAR_HOST=127.0.0.1
|
||||
UPS_PISUGAR_PORT=8423
|
||||
|
||||
# BLE Configuration
|
||||
BLE_ENABLED=true
|
||||
BLE_DEVICE_NAME=DangerousPi
|
||||
|
||||
# Security
|
||||
AUTH_ENABLED=false
|
||||
HTTPS_ENABLED=false
|
||||
@@ -0,0 +1,55 @@
|
||||
[Unit]
|
||||
Description=Dangerous Pi Backend Service
|
||||
Documentation=https://github.com/yourusername/dangerous-pi
|
||||
After=network.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=__DANGEROUS_PI_USER__
|
||||
Group=__DANGEROUS_PI_USER__
|
||||
WorkingDirectory=/opt/dangerous-pi
|
||||
Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/sbin:/usr/bin:/bin"
|
||||
Environment="PYTHONUNBUFFERED=1"
|
||||
Environment="PYTHONPATH=/home/__DANGEROUS_PI_USER__/.pm3/proxmark3/client/pyscripts:/home/__DANGEROUS_PI_USER__/.pm3/proxmark3/client/experimental_lib/build"
|
||||
EnvironmentFile=-/opt/dangerous-pi/.env
|
||||
|
||||
# Main service command
|
||||
ExecStart=/usr/bin/python3 -m uvicorn app.backend.main:app \
|
||||
--host 0.0.0.0 \
|
||||
--port 8000 \
|
||||
--log-level info
|
||||
|
||||
# Restart policy
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StartLimitBurst=5
|
||||
StartLimitInterval=60
|
||||
|
||||
# Resource limits
|
||||
LimitNOFILE=65536
|
||||
MemoryMax=512M
|
||||
CPUQuota=80%
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/opt/dangerous-pi/data /opt/dangerous-pi/logs /etc/NetworkManager/conf.d
|
||||
ReadOnlyPaths=/opt/dangerous-pi /home/__DANGEROUS_PI_USER__/.pm3 /usr/local/bin
|
||||
|
||||
# Allow I2C and Bluetooth access
|
||||
SupplementaryGroups=i2c bluetooth gpio dialout netdev
|
||||
|
||||
# Network management capabilities (for WiFi scanning/configuration without sudo)
|
||||
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
|
||||
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW
|
||||
|
||||
# Graceful shutdown
|
||||
TimeoutStopSec=30
|
||||
KillMode=mixed
|
||||
KillSignal=SIGTERM
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
67
stageDangerousPi/03-dangerous-pi/files/systemd/install-service.sh
Executable file
67
stageDangerousPi/03-dangerous-pi/files/systemd/install-service.sh
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
# Installation script for Dangerous Pi systemd service
|
||||
|
||||
set -e
|
||||
|
||||
echo "Installing Dangerous Pi systemd service..."
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Error: This script must be run as root (use sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Variables
|
||||
SERVICE_NAME="dangerous-pi"
|
||||
SERVICE_FILE="dangerous-pi.service"
|
||||
INSTALL_DIR="/opt/dangerous-pi"
|
||||
SYSTEMD_DIR="/etc/systemd/system"
|
||||
|
||||
# Create installation directory if it doesn't exist
|
||||
if [ ! -d "$INSTALL_DIR" ]; then
|
||||
echo "Creating installation directory: $INSTALL_DIR"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# Copy service file to systemd directory
|
||||
echo "Installing systemd service unit..."
|
||||
cp "$(dirname "$0")/$SERVICE_FILE" "$SYSTEMD_DIR/"
|
||||
chmod 644 "$SYSTEMD_DIR/$SERVICE_FILE"
|
||||
|
||||
# Create .env file if it doesn't exist
|
||||
if [ ! -f "$INSTALL_DIR/.env" ]; then
|
||||
echo "Creating environment configuration file..."
|
||||
cp "$(dirname "$0")/dangerous-pi.env.example" "$INSTALL_DIR/.env"
|
||||
chown pi:pi "$INSTALL_DIR/.env"
|
||||
chmod 600 "$INSTALL_DIR/.env"
|
||||
echo "NOTE: Please edit $INSTALL_DIR/.env to configure your installation"
|
||||
fi
|
||||
|
||||
# Create data and logs directories
|
||||
echo "Creating data and logs directories..."
|
||||
mkdir -p "$INSTALL_DIR/data"
|
||||
mkdir -p "$INSTALL_DIR/logs"
|
||||
chown -R pi:pi "$INSTALL_DIR/data" "$INSTALL_DIR/logs"
|
||||
chmod 755 "$INSTALL_DIR/data" "$INSTALL_DIR/logs"
|
||||
|
||||
# Add pi user to required groups for hardware access
|
||||
echo "Adding pi user to hardware access groups..."
|
||||
usermod -a -G i2c,bluetooth,gpio,dialout pi || true
|
||||
|
||||
# Reload systemd daemon
|
||||
echo "Reloading systemd daemon..."
|
||||
systemctl daemon-reload
|
||||
|
||||
# Enable service to start on boot
|
||||
echo "Enabling $SERVICE_NAME service..."
|
||||
systemctl enable "$SERVICE_NAME.service"
|
||||
|
||||
echo ""
|
||||
echo "✅ Installation complete!"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Edit configuration: sudo nano $INSTALL_DIR/.env"
|
||||
echo " 2. Start the service: sudo systemctl start $SERVICE_NAME"
|
||||
echo " 3. Check status: sudo systemctl status $SERVICE_NAME"
|
||||
echo " 4. View logs: sudo journalctl -u $SERVICE_NAME -f"
|
||||
echo ""
|
||||
47
stageDangerousPi/03-dangerous-pi/files/systemd/uninstall-service.sh
Executable file
47
stageDangerousPi/03-dangerous-pi/files/systemd/uninstall-service.sh
Executable file
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
# Uninstallation script for Dangerous Pi systemd service
|
||||
|
||||
set -e
|
||||
|
||||
echo "Uninstalling Dangerous Pi systemd service..."
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Error: This script must be run as root (use sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Variables
|
||||
SERVICE_NAME="dangerous-pi"
|
||||
SERVICE_FILE="dangerous-pi.service"
|
||||
SYSTEMD_DIR="/etc/systemd/system"
|
||||
|
||||
# Stop the service if running
|
||||
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
||||
echo "Stopping $SERVICE_NAME service..."
|
||||
systemctl stop "$SERVICE_NAME"
|
||||
fi
|
||||
|
||||
# Disable the service
|
||||
if systemctl is-enabled --quiet "$SERVICE_NAME" 2>/dev/null; then
|
||||
echo "Disabling $SERVICE_NAME service..."
|
||||
systemctl disable "$SERVICE_NAME"
|
||||
fi
|
||||
|
||||
# Remove service file
|
||||
if [ -f "$SYSTEMD_DIR/$SERVICE_FILE" ]; then
|
||||
echo "Removing service unit file..."
|
||||
rm "$SYSTEMD_DIR/$SERVICE_FILE"
|
||||
fi
|
||||
|
||||
# Reload systemd daemon
|
||||
echo "Reloading systemd daemon..."
|
||||
systemctl daemon-reload
|
||||
systemctl reset-failed || true
|
||||
|
||||
echo ""
|
||||
echo "✅ Uninstallation complete!"
|
||||
echo ""
|
||||
echo "Note: Application files in /opt/dangerous-pi were NOT removed."
|
||||
echo "To remove them manually: sudo rm -rf /opt/dangerous-pi"
|
||||
echo ""
|
||||
Reference in New Issue
Block a user