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,15 +1,28 @@
|
||||
"""BLE Manager for Dangerous Pi.
|
||||
|
||||
Handles Bluetooth Low Energy notifications for updates, backups,
|
||||
and battery alerts using the Pi Zero 2 W built-in Bluetooth.
|
||||
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
|
||||
@@ -18,7 +31,7 @@ try:
|
||||
except ImportError:
|
||||
DBUS_AVAILABLE = False
|
||||
|
||||
from .. import config
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationType(str, Enum):
|
||||
@@ -30,6 +43,7 @@ class NotificationType(str, Enum):
|
||||
BATTERY_CRITICAL = "battery_critical"
|
||||
SHUTDOWN_INITIATED = "shutdown_initiated"
|
||||
PM3_STATUS = "pm3_status"
|
||||
CONFIG_REQUIRED = "config_required"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -42,7 +56,12 @@ class BLENotification:
|
||||
|
||||
|
||||
class BLEManager:
|
||||
"""Manages BLE notifications via Pi Zero 2 W Bluetooth."""
|
||||
"""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."""
|
||||
@@ -50,12 +69,14 @@ class BLEManager:
|
||||
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.
|
||||
@@ -64,58 +85,81 @@ class BLEManager:
|
||||
True if initialization successful, False otherwise
|
||||
"""
|
||||
if not self._enabled:
|
||||
print("BLE is disabled in configuration")
|
||||
logger.info("BLE is disabled in configuration")
|
||||
return False
|
||||
|
||||
if not DBUS_AVAILABLE:
|
||||
print("BLE not available: dbus/GLib libraries not installed")
|
||||
# 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
|
||||
|
||||
try:
|
||||
# Check if Bluetooth adapter is available
|
||||
has_adapter = await self._check_bluetooth_adapter()
|
||||
self._is_available = True
|
||||
|
||||
if has_adapter:
|
||||
self._is_available = True
|
||||
print(f"BLE initialized: {self._device_name}")
|
||||
return True
|
||||
else:
|
||||
print("No Bluetooth adapter found")
|
||||
return False
|
||||
# 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")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to initialize BLE: {e}")
|
||||
return False
|
||||
logger.info("BLE initialized: %s", self._device_name)
|
||||
return True
|
||||
|
||||
async def start_advertising(self):
|
||||
"""Start BLE advertising to allow device connections."""
|
||||
"""Start BLE advertising and GATT server."""
|
||||
if not self._is_available:
|
||||
print("BLE not available, cannot start advertising")
|
||||
logger.warning("BLE not available, cannot start advertising")
|
||||
return
|
||||
|
||||
try:
|
||||
# Set device name
|
||||
await self._set_device_name(self._device_name)
|
||||
# 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")
|
||||
|
||||
# Make device discoverable
|
||||
# Fallback to basic advertising via bluetoothctl
|
||||
await self._set_device_name(self._device_name)
|
||||
await self._set_discoverable(True)
|
||||
|
||||
self._is_advertising = True
|
||||
print(f"BLE advertising started: {self._device_name}")
|
||||
logger.info("BLE advertising started (basic mode): %s", self._device_name)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to start BLE advertising: {e}")
|
||||
logger.error("Failed to start BLE advertising: %s", e)
|
||||
self._is_advertising = False
|
||||
|
||||
async def stop_advertising(self):
|
||||
"""Stop BLE advertising."""
|
||||
if self._is_advertising:
|
||||
try:
|
||||
"""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
|
||||
print("BLE advertising stopped")
|
||||
except Exception as e:
|
||||
print(f"Failed to stop BLE advertising: {e}")
|
||||
|
||||
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,
|
||||
@@ -142,11 +186,11 @@ class BLEManager:
|
||||
|
||||
await self._notification_queue.put(notification)
|
||||
|
||||
# Process notification
|
||||
if self._connected_devices:
|
||||
# Process notification - always broadcast if GATT server is running
|
||||
if self._connected_devices or self._gatt_server_running:
|
||||
await self._broadcast_notification(notification)
|
||||
else:
|
||||
print(f"BLE notification queued (no devices connected): {message}")
|
||||
logger.debug("BLE notification queued (no devices connected): %s", message)
|
||||
|
||||
async def get_status(self) -> Dict[str, Any]:
|
||||
"""Get BLE manager status.
|
||||
@@ -158,6 +202,8 @@ class BLEManager:
|
||||
"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()
|
||||
@@ -182,10 +228,10 @@ class BLEManager:
|
||||
return b"Controller" in stdout
|
||||
|
||||
except FileNotFoundError:
|
||||
print("bluetoothctl not found - BlueZ not installed")
|
||||
logger.warning("bluetoothctl not found - BlueZ not installed")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Error checking Bluetooth adapter: {e}")
|
||||
logger.error("Error checking Bluetooth adapter: %s", e)
|
||||
return False
|
||||
|
||||
async def _set_device_name(self, name: str):
|
||||
@@ -202,7 +248,7 @@ class BLEManager:
|
||||
)
|
||||
await process.communicate()
|
||||
except Exception as e:
|
||||
print(f"Failed to set device name: {e}")
|
||||
logger.error("Failed to set device name: %s", e)
|
||||
|
||||
async def _set_discoverable(self, enabled: bool):
|
||||
"""Set Bluetooth discoverable state.
|
||||
@@ -219,7 +265,7 @@ class BLEManager:
|
||||
)
|
||||
await process.communicate()
|
||||
except Exception as e:
|
||||
print(f"Failed to set discoverable: {e}")
|
||||
logger.error("Failed to set discoverable: %s", e)
|
||||
|
||||
async def _broadcast_notification(self, notification: BLENotification):
|
||||
"""Broadcast notification to all connected devices.
|
||||
@@ -234,14 +280,24 @@ class BLEManager:
|
||||
"data": notification.data,
|
||||
"timestamp": notification.timestamp
|
||||
}
|
||||
payload_bytes = json.dumps(payload).encode('utf-8')
|
||||
|
||||
# In a full implementation, this would write to a BLE characteristic
|
||||
# that connected devices are subscribed to. For now, we'll just log it.
|
||||
print(f"BLE Notification: {notification.type.value} - {notification.message}")
|
||||
# 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)
|
||||
|
||||
# If we had connected devices, we would send the notification here
|
||||
# This would require setting up a GATT server with proper characteristics
|
||||
# For simplicity in this MVP, we're using a notification-based approach
|
||||
# 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.
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
|
||||
Provides a plugin framework for extending functionality.
|
||||
Supports dynamic loading, enabling/disabling, and lifecycle management.
|
||||
Includes header widget system and websocket broadcasting for plugins.
|
||||
"""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import inspect
|
||||
import json
|
||||
from dataclasses import dataclass, asdict
|
||||
import time
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List, Callable
|
||||
from typing import Optional, Dict, Any, List, Callable, Set
|
||||
import sys
|
||||
|
||||
from .. import config
|
||||
@@ -24,6 +27,48 @@ class PluginStatus(str, Enum):
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class WidgetSeverity(str, Enum):
|
||||
"""Widget severity levels for header widgets."""
|
||||
INFO = "info"
|
||||
WARNING = "warning"
|
||||
ERROR = "error"
|
||||
SUCCESS = "success"
|
||||
|
||||
|
||||
@dataclass
|
||||
class HeaderWidget:
|
||||
"""Header widget data for displaying status in the UI.
|
||||
|
||||
Attributes:
|
||||
id: Unique identifier (will be prefixed with source for plugins)
|
||||
source: Source identifier (e.g., "ups_manager", "plugin:hello_world")
|
||||
severity: Visual severity level
|
||||
message: Display message
|
||||
dismissible: Whether user can dismiss the widget
|
||||
icon: Optional emoji/icon
|
||||
action_label: Optional action button label
|
||||
action_url: Optional action button URL
|
||||
metadata: Additional data
|
||||
created_at: ISO timestamp of creation
|
||||
expires_at: Optional expiry (ISO timestamp)
|
||||
"""
|
||||
id: str
|
||||
source: str
|
||||
severity: WidgetSeverity
|
||||
message: str
|
||||
dismissible: bool = True
|
||||
icon: Optional[str] = None
|
||||
action_label: Optional[str] = None
|
||||
action_url: Optional[str] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
created_at: Optional[str] = None
|
||||
expires_at: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.created_at is None:
|
||||
self.created_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginMetadata:
|
||||
"""Plugin metadata information."""
|
||||
@@ -57,12 +102,30 @@ class PluginBase:
|
||||
"""Base class for all plugins.
|
||||
|
||||
Plugins should inherit from this class and implement the required methods.
|
||||
Provides access to header widgets, websocket broadcasting, and hardware.
|
||||
"""
|
||||
|
||||
# Websocket rate limiting: max events per second
|
||||
WS_RATE_LIMIT = 10
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the plugin."""
|
||||
self.metadata: Optional[PluginMetadata] = None
|
||||
self.hooks: Dict[str, List[Callable]] = {}
|
||||
self._ws_event_times: List[float] = []
|
||||
|
||||
def _has_permission(self, permission: str) -> bool:
|
||||
"""Check if plugin has a specific permission.
|
||||
|
||||
Args:
|
||||
permission: Permission name to check
|
||||
|
||||
Returns:
|
||||
True if plugin has permission, False otherwise
|
||||
"""
|
||||
if self.metadata is None:
|
||||
return False
|
||||
return permission in (self.metadata.permissions or [])
|
||||
|
||||
async def on_load(self):
|
||||
"""Called when the plugin is loaded.
|
||||
@@ -114,9 +177,234 @@ class PluginBase:
|
||||
"""
|
||||
raise NotImplementedError("Plugin must implement get_metadata()")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Header Widget Methods
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def register_widget(
|
||||
self,
|
||||
widget_id: str,
|
||||
severity: WidgetSeverity,
|
||||
message: str,
|
||||
dismissible: bool = True,
|
||||
icon: Optional[str] = None,
|
||||
action_label: Optional[str] = None,
|
||||
action_url: Optional[str] = None,
|
||||
expires_at: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> bool:
|
||||
"""Register a header widget for display in the UI.
|
||||
|
||||
Widget ID is automatically prefixed with plugin namespace.
|
||||
Example: "status" becomes "plugin.hello_world.status"
|
||||
|
||||
Args:
|
||||
widget_id: Short identifier for the widget
|
||||
severity: Visual severity level (info, warning, error, success)
|
||||
message: Display message
|
||||
dismissible: Whether user can dismiss the widget
|
||||
icon: Optional emoji/icon
|
||||
action_label: Optional action button label
|
||||
action_url: Optional action button URL
|
||||
expires_at: Optional expiry timestamp (ISO format)
|
||||
metadata: Additional data
|
||||
|
||||
Returns:
|
||||
True if registered successfully, False otherwise
|
||||
"""
|
||||
if self.metadata is None:
|
||||
return False
|
||||
|
||||
# Create full widget ID with plugin namespace
|
||||
full_id = f"plugin.{self.metadata.id}.{widget_id}"
|
||||
|
||||
widget = HeaderWidget(
|
||||
id=full_id,
|
||||
source=f"plugin:{self.metadata.id}",
|
||||
severity=severity,
|
||||
message=message,
|
||||
dismissible=dismissible,
|
||||
icon=icon,
|
||||
action_label=action_label,
|
||||
action_url=action_url,
|
||||
expires_at=expires_at,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
plugin_manager = get_plugin_manager()
|
||||
return plugin_manager.register_widget(widget)
|
||||
|
||||
def unregister_widget(self, widget_id: str) -> None:
|
||||
"""Remove a header widget.
|
||||
|
||||
Args:
|
||||
widget_id: Short identifier (without plugin prefix)
|
||||
"""
|
||||
if self.metadata is None:
|
||||
return
|
||||
|
||||
full_id = f"plugin.{self.metadata.id}.{widget_id}"
|
||||
plugin_manager = get_plugin_manager()
|
||||
plugin_manager.unregister_widget(full_id)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Websocket Broadcasting Methods
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def broadcast_event(self, event_type: str, data: dict) -> bool:
|
||||
"""Broadcast a websocket event to all connected clients.
|
||||
|
||||
Requires 'websocket' permission in plugin.json.
|
||||
Event type is prefixed with plugin namespace: 'plugin.{plugin_id}.{event_type}'
|
||||
Rate limited to WS_RATE_LIMIT events per second.
|
||||
|
||||
Args:
|
||||
event_type: Event type name (will be prefixed)
|
||||
data: Event data dictionary
|
||||
|
||||
Returns:
|
||||
True if broadcast successful, False if rate limited or no permission
|
||||
"""
|
||||
if not self._has_permission("websocket"):
|
||||
print(f"Plugin {self.metadata.id if self.metadata else 'unknown'}: "
|
||||
"websocket permission required for broadcast_event()")
|
||||
return False
|
||||
|
||||
# Rate limiting
|
||||
now = time.time()
|
||||
self._ws_event_times = [t for t in self._ws_event_times if now - t < 1.0]
|
||||
if len(self._ws_event_times) >= self.WS_RATE_LIMIT:
|
||||
print(f"Plugin {self.metadata.id}: rate limited (>{self.WS_RATE_LIMIT}/s)")
|
||||
return False
|
||||
self._ws_event_times.append(now)
|
||||
|
||||
# Broadcast with namespaced event type
|
||||
try:
|
||||
from ..websocket.notifications import notify_plugin_event
|
||||
full_event_type = f"plugin.{self.metadata.id}.{event_type}"
|
||||
await notify_plugin_event(self.metadata.id, full_event_type, data)
|
||||
return True
|
||||
except ImportError:
|
||||
print(f"Plugin {self.metadata.id}: websocket notifications not available")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Plugin {self.metadata.id}: broadcast error: {e}")
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Hardware Access Methods
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def get_i2c(self, bus: int = 1):
|
||||
"""Get I2C bus access.
|
||||
|
||||
Requires 'i2c' permission in plugin.json.
|
||||
|
||||
Args:
|
||||
bus: I2C bus number (default: 1)
|
||||
|
||||
Returns:
|
||||
SMBus instance or None if not available/permitted
|
||||
|
||||
Raises:
|
||||
PermissionError: If plugin lacks 'i2c' permission
|
||||
"""
|
||||
if not self._has_permission("i2c"):
|
||||
raise PermissionError(
|
||||
f"Plugin {self.metadata.id if self.metadata else 'unknown'}: "
|
||||
"'i2c' permission required"
|
||||
)
|
||||
|
||||
from ..services.hardware_service import HardwareService
|
||||
return HardwareService.get_i2c_bus(
|
||||
self.metadata.id if self.metadata else "unknown",
|
||||
bus
|
||||
)
|
||||
|
||||
def get_gpio(self):
|
||||
"""Get GPIO access.
|
||||
|
||||
Requires 'gpio' permission in plugin.json.
|
||||
|
||||
Returns:
|
||||
GPIO module or None if not available/permitted
|
||||
|
||||
Raises:
|
||||
PermissionError: If plugin lacks 'gpio' permission
|
||||
"""
|
||||
if not self._has_permission("gpio"):
|
||||
raise PermissionError(
|
||||
f"Plugin {self.metadata.id if self.metadata else 'unknown'}: "
|
||||
"'gpio' permission required"
|
||||
)
|
||||
|
||||
from ..services.hardware_service import HardwareService
|
||||
return HardwareService.get_gpio(
|
||||
self.metadata.id if self.metadata else "unknown"
|
||||
)
|
||||
|
||||
def get_spi(self, bus: int = 0, device: int = 0):
|
||||
"""Get SPI device access.
|
||||
|
||||
Requires 'spi' permission in plugin.json.
|
||||
|
||||
Args:
|
||||
bus: SPI bus number (default: 0)
|
||||
device: SPI device/chip select (default: 0)
|
||||
|
||||
Returns:
|
||||
SpiDev instance or None if not available/permitted
|
||||
|
||||
Raises:
|
||||
PermissionError: If plugin lacks 'spi' permission
|
||||
"""
|
||||
if not self._has_permission("spi"):
|
||||
raise PermissionError(
|
||||
f"Plugin {self.metadata.id if self.metadata else 'unknown'}: "
|
||||
"'spi' permission required"
|
||||
)
|
||||
|
||||
from ..services.hardware_service import HardwareService
|
||||
return HardwareService.get_spi(
|
||||
self.metadata.id if self.metadata else "unknown",
|
||||
bus,
|
||||
device
|
||||
)
|
||||
|
||||
def get_serial(self, port: str, baudrate: int = 9600):
|
||||
"""Get serial port access.
|
||||
|
||||
Requires 'serial' permission in plugin.json.
|
||||
|
||||
Args:
|
||||
port: Serial port path (e.g., '/dev/ttyUSB0')
|
||||
baudrate: Baud rate (default: 9600)
|
||||
|
||||
Returns:
|
||||
Serial instance or None if not available/permitted
|
||||
|
||||
Raises:
|
||||
PermissionError: If plugin lacks 'serial' permission
|
||||
"""
|
||||
if not self._has_permission("serial"):
|
||||
raise PermissionError(
|
||||
f"Plugin {self.metadata.id if self.metadata else 'unknown'}: "
|
||||
"'serial' permission required"
|
||||
)
|
||||
|
||||
from ..services.hardware_service import HardwareService
|
||||
return HardwareService.get_serial(
|
||||
self.metadata.id if self.metadata else "unknown",
|
||||
port,
|
||||
baudrate
|
||||
)
|
||||
|
||||
|
||||
class PluginManager:
|
||||
"""Manages plugin loading, enabling, and lifecycle."""
|
||||
"""Manages plugin loading, enabling, lifecycle, and header widgets."""
|
||||
|
||||
# Maximum number of active widgets
|
||||
MAX_WIDGETS = 10
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the plugin manager."""
|
||||
@@ -126,6 +414,10 @@ class PluginManager:
|
||||
self._hooks: Dict[str, List[Callable]] = {}
|
||||
self._enabled_plugins: List[str] = []
|
||||
|
||||
# Header widget registry
|
||||
self._header_widgets: Dict[str, HeaderWidget] = {}
|
||||
self._dismissed_widgets: Set[str] = set()
|
||||
|
||||
# Create plugins directory if it doesn't exist
|
||||
self._plugin_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -406,6 +698,98 @@ class PluginManager:
|
||||
"""
|
||||
return self._enabled_plugins.copy()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Header Widget Methods
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def register_widget(self, widget: HeaderWidget) -> bool:
|
||||
"""Register a header widget.
|
||||
|
||||
Args:
|
||||
widget: HeaderWidget to register
|
||||
|
||||
Returns:
|
||||
True if registered successfully, False if dismissed or at limit
|
||||
"""
|
||||
# Check if user dismissed this widget
|
||||
if widget.id in self._dismissed_widgets:
|
||||
return False
|
||||
|
||||
# Check widget limit
|
||||
if len(self._header_widgets) >= self.MAX_WIDGETS:
|
||||
# Remove oldest expired widget if any
|
||||
self._cleanup_expired_widgets()
|
||||
if len(self._header_widgets) >= self.MAX_WIDGETS:
|
||||
print(f"Widget limit reached ({self.MAX_WIDGETS}), cannot register {widget.id}")
|
||||
return False
|
||||
|
||||
self._header_widgets[widget.id] = widget
|
||||
print(f"Registered widget: {widget.id}")
|
||||
return True
|
||||
|
||||
def unregister_widget(self, widget_id: str) -> None:
|
||||
"""Remove a widget from the registry.
|
||||
|
||||
Args:
|
||||
widget_id: ID of widget to remove
|
||||
"""
|
||||
if widget_id in self._header_widgets:
|
||||
del self._header_widgets[widget_id]
|
||||
print(f"Unregistered widget: {widget_id}")
|
||||
|
||||
def get_active_widgets(self) -> List[HeaderWidget]:
|
||||
"""Get all active, non-expired widgets.
|
||||
|
||||
Returns:
|
||||
List of active HeaderWidget objects
|
||||
"""
|
||||
self._cleanup_expired_widgets()
|
||||
return list(self._header_widgets.values())
|
||||
|
||||
def dismiss_widget(self, widget_id: str) -> bool:
|
||||
"""Mark widget as dismissed by user.
|
||||
|
||||
Args:
|
||||
widget_id: ID of widget to dismiss
|
||||
|
||||
Returns:
|
||||
True if widget was dismissed, False if not found
|
||||
"""
|
||||
if widget_id in self._header_widgets:
|
||||
widget = self._header_widgets[widget_id]
|
||||
if not widget.dismissible:
|
||||
print(f"Widget {widget_id} is not dismissible")
|
||||
return False
|
||||
|
||||
self._dismissed_widgets.add(widget_id)
|
||||
del self._header_widgets[widget_id]
|
||||
print(f"Dismissed widget: {widget_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def clear_dismissed(self) -> None:
|
||||
"""Clear all dismissed widget IDs, allowing them to appear again."""
|
||||
self._dismissed_widgets.clear()
|
||||
print("Cleared dismissed widgets")
|
||||
|
||||
def _cleanup_expired_widgets(self) -> None:
|
||||
"""Remove expired widgets from the registry."""
|
||||
now = datetime.now(timezone.utc)
|
||||
expired = []
|
||||
|
||||
for widget_id, widget in self._header_widgets.items():
|
||||
if widget.expires_at:
|
||||
try:
|
||||
expiry = datetime.fromisoformat(widget.expires_at.replace('Z', '+00:00'))
|
||||
if now > expiry:
|
||||
expired.append(widget_id)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
for widget_id in expired:
|
||||
del self._header_widgets[widget_id]
|
||||
print(f"Expired widget removed: {widget_id}")
|
||||
|
||||
|
||||
# Global plugin manager instance
|
||||
_plugin_manager: Optional[PluginManager] = None
|
||||
|
||||
970
app/backend/managers/pm3_device_manager.py
Normal file
970
app/backend/managers/pm3_device_manager.py
Normal file
@@ -0,0 +1,970 @@
|
||||
"""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
|
||||
self._device_change_callbacks: List = [] # Callbacks for device changes
|
||||
|
||||
# Device discovery settings
|
||||
self.auto_discover = True
|
||||
self.discovery_interval = 0.5 # seconds - 500ms for responsive SSE updates
|
||||
self.device_timeout = 300 # seconds
|
||||
self._last_device_state: Dict[str, str] = {} # device_id -> status for change detection
|
||||
|
||||
logger.info("PM3DeviceManager initialized")
|
||||
|
||||
def register_device_change_callback(self, callback):
|
||||
"""Register a callback for device list changes.
|
||||
|
||||
Args:
|
||||
callback: Async function that takes a list of PM3Device
|
||||
"""
|
||||
self._device_change_callbacks.append(callback)
|
||||
logger.info(f"Registered device change callback: {callback}")
|
||||
|
||||
async def _notify_device_change(self, force: bool = False):
|
||||
"""Notify all registered callbacks of device list changes.
|
||||
|
||||
Args:
|
||||
force: If True, send notification even if no changes detected
|
||||
"""
|
||||
# Build current state snapshot
|
||||
current_state = {
|
||||
d.device_id: d.status.value if hasattr(d.status, 'value') else str(d.status)
|
||||
for d in self._devices.values()
|
||||
}
|
||||
|
||||
# Check if anything changed
|
||||
if not force and current_state == self._last_device_state:
|
||||
return # No changes, skip notification
|
||||
|
||||
# Update last state
|
||||
self._last_device_state = current_state.copy()
|
||||
|
||||
# Notify all callbacks
|
||||
devices = list(self._devices.values())
|
||||
for callback in self._device_change_callbacks:
|
||||
try:
|
||||
await callback(devices)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in device change callback: {e}")
|
||||
|
||||
async def start(self):
|
||||
"""Start device manager and monitoring."""
|
||||
logger.info("Starting PM3 device manager")
|
||||
|
||||
# Initial device discovery (force notification to send initial state)
|
||||
await self.discover_devices()
|
||||
await self._notify_device_change(force=True)
|
||||
|
||||
# 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
|
||||
|
||||
connected_count = len([d for d in self._devices.values() if d.status == DeviceStatus.CONNECTED])
|
||||
logger.info(f"Discovered {connected_count} connected PM3 devices")
|
||||
|
||||
# Notify callbacks of device changes (only if state changed)
|
||||
await self._notify_device_change()
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
# Use SubprocessPM3Worker (more reliable than SWIG bindings which may not be built)
|
||||
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 where [ Client ] is on separate line)
|
||||
# Format: "[ Client ]\n Iceman/master/4fa8f27-dirty-suspect 2026-01-04 ..."
|
||||
client_match = re.search(
|
||||
r'\[\s*Client\s*\]\s*\n\s*([A-Za-z]+/\w+/[^\s]+)',
|
||||
output,
|
||||
re.IGNORECASE | re.MULTILINE
|
||||
)
|
||||
if client_match:
|
||||
info.client_version = client_match.group(1).strip()
|
||||
|
||||
# Check compatibility by comparing base version (commit hash)
|
||||
# Extract the version identifier (e.g., "Iceman/master/66c7374-dirty-suspect")
|
||||
# The key part is the commit hash (66c7374) - timestamps will differ between builds
|
||||
def extract_base_version(ver_str: str) -> str:
|
||||
"""Extract base version identifier for comparison."""
|
||||
# Match patterns like:
|
||||
# "Iceman/master/66c7374-dirty-suspect" (standard)
|
||||
# "Iceman/DangerousPi/master/66c7374-dirty-suspect" (custom build)
|
||||
# "Iceman/master/v4.20469-104-ge509967ab" (version tag)
|
||||
# Capture everything up to and including the commit/version identifier
|
||||
match = re.match(r'([A-Za-z]+(?:/[A-Za-z]+)?/\w+/[a-z0-9v.-]+)', ver_str)
|
||||
return match.group(1) if match else ver_str
|
||||
|
||||
os_base = extract_base_version(info.os_version)
|
||||
bootrom_base = extract_base_version(info.bootrom_version)
|
||||
client_base = extract_base_version(info.client_version)
|
||||
|
||||
info.compatible = (
|
||||
info.os_version != "unknown" and
|
||||
info.bootrom_version != "unknown" and
|
||||
info.client_version != "unknown" and
|
||||
os_base == client_base and
|
||||
bootrom_base == client_base
|
||||
)
|
||||
|
||||
# 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.
|
||||
|
||||
Runs the client binary with --version flag to detect installed version.
|
||||
|
||||
Returns:
|
||||
Client version string or "unknown"
|
||||
"""
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
client_path = Path(config.PM3_CLIENT_PATH)
|
||||
if not client_path.exists():
|
||||
logger.debug(f"PM3 client not found at {client_path}")
|
||||
return "unknown"
|
||||
|
||||
try:
|
||||
# Run proxmark3 --version to get version info
|
||||
result = subprocess.run(
|
||||
[str(client_path), "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
# Parse version from output (typically like "Proxmark3 client - (RRG/Iceman v4.14831)")
|
||||
import re
|
||||
match = re.search(r'v[\d.]+', output)
|
||||
if match:
|
||||
return match.group(0)
|
||||
|
||||
# Fallback: look for any version-like pattern
|
||||
match = re.search(r'(\d+\.\d+)', output)
|
||||
if match:
|
||||
return f"v{match.group(1)}"
|
||||
|
||||
logger.debug(f"Could not parse version from: {output[:200]}")
|
||||
return "unknown"
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Timeout getting PM3 client version")
|
||||
return "unknown"
|
||||
except Exception as e:
|
||||
logger.debug(f"Error getting PM3 client version: {e}")
|
||||
return "unknown"
|
||||
|
||||
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.
|
||||
|
||||
Uses the custom hw led command (from our led-pwm-control.patch) to
|
||||
flash all LEDs in a recognizable pattern.
|
||||
|
||||
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")
|
||||
|
||||
try:
|
||||
# Calculate number of blink cycles based on duration
|
||||
# Each cycle is ~400ms (200ms on + 200ms off)
|
||||
cycles = max(1, duration_ms // 400)
|
||||
|
||||
for i in range(cycles):
|
||||
# Turn all LEDs on
|
||||
result = await device.worker.execute_command("hw led --led a,b,c,d --on")
|
||||
if not result.success:
|
||||
logger.warning(f"hw led on failed: {result.error}")
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
# Turn all LEDs off
|
||||
result = await device.worker.execute_command("hw led --led a,b,c,d --off")
|
||||
if not result.success:
|
||||
logger.warning(f"hw led off failed: {result.error}")
|
||||
|
||||
if i < cycles - 1:
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
logger.info(f"Identified device {device_id} with {cycles} LED blink cycles")
|
||||
except Exception as e:
|
||||
logger.error(f"Error identifying device {device_id}: {e}")
|
||||
raise RuntimeError(f"Failed to identify device: {e}")
|
||||
|
||||
async def flash_firmware(self, device_id: str) -> dict:
|
||||
"""Flash firmware to a PM3 device.
|
||||
|
||||
Flashes both bootrom and fullimage from bundled firmware files.
|
||||
Progress is reported via WebSocket notifications.
|
||||
|
||||
Args:
|
||||
device_id: Device ID to flash
|
||||
|
||||
Returns:
|
||||
dict with success status and message
|
||||
"""
|
||||
from ..websocket.notifications import notify_pm3_flash_progress
|
||||
|
||||
device = await self.get_device(device_id)
|
||||
if not device:
|
||||
return {"success": False, "error": "Device not found"}
|
||||
|
||||
# Check device is not already flashing
|
||||
if device.status == DeviceStatus.FLASHING:
|
||||
return {"success": False, "error": "Device is already being flashed"}
|
||||
|
||||
# Check device is not in use
|
||||
if device.status == DeviceStatus.IN_USE:
|
||||
return {"success": False, "error": "Device is in use. End session first."}
|
||||
|
||||
# Firmware paths
|
||||
firmware_dir = Path(config.FIRMWARE_DIR)
|
||||
bootrom_path = firmware_dir / "bootrom.elf"
|
||||
fullimage_path = firmware_dir / "fullimage.elf"
|
||||
|
||||
# Validate firmware files exist
|
||||
if not bootrom_path.exists():
|
||||
return {"success": False, "error": f"Bootrom firmware not found at {bootrom_path}"}
|
||||
if not fullimage_path.exists():
|
||||
return {"success": False, "error": f"Fullimage firmware not found at {fullimage_path}"}
|
||||
|
||||
# Set device to flashing state
|
||||
original_status = device.status
|
||||
device.status = DeviceStatus.FLASHING
|
||||
await self._notify_device_change(force=True)
|
||||
|
||||
try:
|
||||
# Send starting notification
|
||||
await notify_pm3_flash_progress(
|
||||
device_id=device_id,
|
||||
status="starting",
|
||||
progress=0,
|
||||
message="Preparing to flash firmware..."
|
||||
)
|
||||
|
||||
# Find pm3-flash-all utility
|
||||
pm3_flash_path = await self._find_pm3_flash_utility()
|
||||
if not pm3_flash_path:
|
||||
raise RuntimeError("pm3-flash-all utility not found")
|
||||
|
||||
# Phase 1: Flash bootrom (0-40%)
|
||||
await notify_pm3_flash_progress(
|
||||
device_id=device_id,
|
||||
status="bootrom",
|
||||
progress=10,
|
||||
message="Flashing bootrom..."
|
||||
)
|
||||
|
||||
bootrom_result = await self._execute_flash_command(
|
||||
pm3_flash_path,
|
||||
device.device_path,
|
||||
str(bootrom_path),
|
||||
"bootrom",
|
||||
device_id
|
||||
)
|
||||
|
||||
if not bootrom_result["success"]:
|
||||
raise RuntimeError(f"Bootrom flash failed: {bootrom_result['error']}")
|
||||
|
||||
await notify_pm3_flash_progress(
|
||||
device_id=device_id,
|
||||
status="bootrom",
|
||||
progress=40,
|
||||
message="Bootrom flashed successfully"
|
||||
)
|
||||
|
||||
# Brief delay for device to restart
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Phase 2: Flash fullimage (50-90%)
|
||||
await notify_pm3_flash_progress(
|
||||
device_id=device_id,
|
||||
status="fullimage",
|
||||
progress=50,
|
||||
message="Flashing fullimage..."
|
||||
)
|
||||
|
||||
fullimage_result = await self._execute_flash_command(
|
||||
pm3_flash_path,
|
||||
device.device_path,
|
||||
str(fullimage_path),
|
||||
"fullimage",
|
||||
device_id
|
||||
)
|
||||
|
||||
if not fullimage_result["success"]:
|
||||
raise RuntimeError(f"Fullimage flash failed: {fullimage_result['error']}")
|
||||
|
||||
await notify_pm3_flash_progress(
|
||||
device_id=device_id,
|
||||
status="fullimage",
|
||||
progress=90,
|
||||
message="Fullimage flashed successfully"
|
||||
)
|
||||
|
||||
# Phase 3: Verify (95%)
|
||||
await notify_pm3_flash_progress(
|
||||
device_id=device_id,
|
||||
status="verifying",
|
||||
progress=95,
|
||||
message="Verifying firmware..."
|
||||
)
|
||||
|
||||
# Wait for device to restart
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# Re-query firmware version
|
||||
await self._query_firmware_version(device)
|
||||
|
||||
# Check if now compatible
|
||||
if device.firmware_info.compatible:
|
||||
device.status = DeviceStatus.CONNECTED
|
||||
message = "Firmware updated successfully"
|
||||
else:
|
||||
device.status = DeviceStatus.VERSION_MISMATCH
|
||||
message = "Firmware flashed but version mismatch persists"
|
||||
|
||||
await notify_pm3_flash_progress(
|
||||
device_id=device_id,
|
||||
status="complete",
|
||||
progress=100,
|
||||
message=message
|
||||
)
|
||||
|
||||
await self._notify_device_change(force=True)
|
||||
|
||||
logger.info(f"Successfully flashed firmware to device {device_id}")
|
||||
return {"success": True, "message": message}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Firmware flash failed for device {device_id}: {e}")
|
||||
|
||||
# Notify of error
|
||||
await notify_pm3_flash_progress(
|
||||
device_id=device_id,
|
||||
status="error",
|
||||
progress=0,
|
||||
message=str(e)
|
||||
)
|
||||
|
||||
# Restore original status or set to error
|
||||
device.status = DeviceStatus.ERROR
|
||||
await self._notify_device_change(force=True)
|
||||
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def _find_pm3_flash_utility(self) -> Optional[str]:
|
||||
"""Find pm3-flash-all or pm3-flash utility.
|
||||
|
||||
Returns:
|
||||
Path to flash utility or None if not found
|
||||
"""
|
||||
import shutil
|
||||
|
||||
# Search locations in order of preference
|
||||
search_paths = [
|
||||
# Bundled with dangerous-pi
|
||||
Path(config.PM3_CLIENT_PATH).parent / "pm3-flash-all" if hasattr(config, 'PM3_CLIENT_PATH') else None,
|
||||
# Default installation
|
||||
Path("/home/dt/.pm3/proxmark3/pm3-flash-all"),
|
||||
Path("/home/dt/.pm3/proxmark3/client/flasher"),
|
||||
# System path
|
||||
Path(shutil.which("pm3-flash-all") or ""),
|
||||
Path(shutil.which("flasher") or ""),
|
||||
]
|
||||
|
||||
for path in search_paths:
|
||||
if path and path.exists() and path.is_file():
|
||||
logger.info(f"Found pm3 flash utility at: {path}")
|
||||
return str(path)
|
||||
|
||||
logger.warning("pm3 flash utility not found in standard locations")
|
||||
return None
|
||||
|
||||
async def _execute_flash_command(
|
||||
self,
|
||||
flash_path: str,
|
||||
device_path: str,
|
||||
firmware_path: str,
|
||||
phase: str,
|
||||
device_id: str
|
||||
) -> dict:
|
||||
"""Execute flash command and parse output.
|
||||
|
||||
Args:
|
||||
flash_path: Path to flash utility
|
||||
device_path: Device path (e.g., /dev/ttyACM0)
|
||||
firmware_path: Path to .elf firmware file
|
||||
phase: "bootrom" or "fullimage"
|
||||
device_id: Device ID for progress notifications
|
||||
|
||||
Returns:
|
||||
dict with success status
|
||||
"""
|
||||
from ..websocket.notifications import notify_pm3_flash_progress
|
||||
|
||||
# Build command
|
||||
# pm3-flash-all expects: pm3-flash-all -p /dev/ttyACMx [bootrom.elf] [fullimage.elf]
|
||||
# But we're doing them separately for better progress tracking
|
||||
cmd = [flash_path, "-p", device_path, firmware_path]
|
||||
|
||||
logger.info(f"Executing flash command: {' '.join(cmd)}")
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT
|
||||
)
|
||||
|
||||
output_lines = []
|
||||
base_progress = 10 if phase == "bootrom" else 50
|
||||
max_progress = 40 if phase == "bootrom" else 90
|
||||
|
||||
# Read output line by line and parse progress
|
||||
while True:
|
||||
line = await process.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
|
||||
line_str = line.decode('utf-8', errors='replace').strip()
|
||||
output_lines.append(line_str)
|
||||
logger.debug(f"Flash output: {line_str}")
|
||||
|
||||
# Parse progress from output
|
||||
progress = self._parse_flash_progress(line_str, base_progress, max_progress)
|
||||
if progress:
|
||||
await notify_pm3_flash_progress(
|
||||
device_id=device_id,
|
||||
status=phase,
|
||||
progress=progress,
|
||||
message=f"Flashing {phase}..."
|
||||
)
|
||||
|
||||
await process.wait()
|
||||
|
||||
output = "\n".join(output_lines)
|
||||
|
||||
if process.returncode == 0:
|
||||
return {"success": True, "output": output}
|
||||
else:
|
||||
# Check for common errors in output
|
||||
error_msg = self._extract_flash_error(output)
|
||||
return {"success": False, "error": error_msg or f"Flash exited with code {process.returncode}"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Flash command execution failed: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _parse_flash_progress(self, line: str, base: int, max_val: int) -> Optional[int]:
|
||||
"""Parse progress percentage from flash output line.
|
||||
|
||||
Args:
|
||||
line: Output line from flash process
|
||||
base: Base progress percentage
|
||||
max_val: Maximum progress percentage
|
||||
|
||||
Returns:
|
||||
Progress percentage or None
|
||||
"""
|
||||
import re
|
||||
|
||||
# Look for percentage patterns like "50%" or "Writing: 50%"
|
||||
percent_match = re.search(r'(\d+)%', line)
|
||||
if percent_match:
|
||||
raw_percent = int(percent_match.group(1))
|
||||
# Scale to our range
|
||||
scaled = base + (raw_percent / 100) * (max_val - base)
|
||||
return int(scaled)
|
||||
|
||||
# Look for block-based progress like "Writing block 10/20"
|
||||
block_match = re.search(r'(\d+)\s*/\s*(\d+)', line)
|
||||
if block_match and 'block' in line.lower():
|
||||
current = int(block_match.group(1))
|
||||
total = int(block_match.group(2))
|
||||
if total > 0:
|
||||
raw_percent = (current / total) * 100
|
||||
scaled = base + (raw_percent / 100) * (max_val - base)
|
||||
return int(scaled)
|
||||
|
||||
return None
|
||||
|
||||
def _extract_flash_error(self, output: str) -> Optional[str]:
|
||||
"""Extract error message from flash output.
|
||||
|
||||
Args:
|
||||
output: Full flash output
|
||||
|
||||
Returns:
|
||||
Error message or None
|
||||
"""
|
||||
error_patterns = [
|
||||
r"error[:\s]+(.+?)(?:\n|$)",
|
||||
r"failed[:\s]+(.+?)(?:\n|$)",
|
||||
r"cannot\s+(.+?)(?:\n|$)",
|
||||
r"permission denied",
|
||||
r"device not found",
|
||||
r"timeout",
|
||||
]
|
||||
|
||||
import re
|
||||
for pattern in error_patterns:
|
||||
match = re.search(pattern, output, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(0).strip()
|
||||
|
||||
return None
|
||||
|
||||
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())
|
||||
@@ -1,7 +1,11 @@
|
||||
"""Session manager for single-user access control."""
|
||||
"""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
|
||||
from typing import Optional, Dict
|
||||
from dataclasses import dataclass
|
||||
import uuid
|
||||
|
||||
@@ -12,6 +16,7 @@ from .. import config
|
||||
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
|
||||
@@ -19,52 +24,92 @@ class Session:
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""Manages single active session for PM3 access."""
|
||||
"""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_session: Optional[Session] = None
|
||||
self._active_sessions: Dict[str, Session] = {} # keyed by device_id
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def has_active_session(self) -> bool:
|
||||
"""Check if there's an active session."""
|
||||
if not self._active_session:
|
||||
return False
|
||||
def _get_device_key(self, device_id: Optional[str]) -> str:
|
||||
"""Get the key to use for session lookup.
|
||||
|
||||
# Check if session has timed out
|
||||
if time.time() - self._active_session.last_activity > config.SESSION_TIMEOUT:
|
||||
self._active_session = None
|
||||
return False
|
||||
Args:
|
||||
device_id: Device ID or None for legacy mode
|
||||
|
||||
return True
|
||||
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
|
||||
force_takeover: bool = False,
|
||||
device_id: Optional[str] = None
|
||||
) -> tuple[bool, Optional[str], Optional[str]]:
|
||||
"""Create a new session.
|
||||
"""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:
|
||||
# Check if another session is active
|
||||
if self.has_active_session() and not force_takeover:
|
||||
return False, None, "Another session is active"
|
||||
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_session = Session(
|
||||
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,
|
||||
@@ -73,60 +118,111 @@ class SessionManager:
|
||||
|
||||
return True, session_id, None
|
||||
|
||||
async def release_session(self, session_id: str) -> bool:
|
||||
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 self._active_session and self._active_session.session_id == session_id:
|
||||
self._active_session = None
|
||||
return True
|
||||
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) -> bool:
|
||||
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 self._active_session and self._active_session.session_id == session_id:
|
||||
self._active_session.last_activity = time.time()
|
||||
return True
|
||||
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]) -> bool:
|
||||
"""Check if a session can execute commands.
|
||||
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
|
||||
"""
|
||||
# No active session - allow execution
|
||||
if not self.has_active_session():
|
||||
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 active session
|
||||
if session_id and self._active_session and self._active_session.session_id == session_id:
|
||||
# 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) -> Optional[Session]:
|
||||
"""Get the currently active session.
|
||||
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
|
||||
"""
|
||||
if self.has_active_session():
|
||||
return self._active_session
|
||||
return 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)
|
||||
|
||||
89
app/backend/managers/ups_drivers/__init__.py
Normal file
89
app/backend/managers/ups_drivers/__init__.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""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)
|
||||
"""
|
||||
print("UPS auto-detect: Starting detection...")
|
||||
|
||||
# Try native PiSugar I2C first (no daemon overhead, minimal CPU)
|
||||
if prefer_native_i2c:
|
||||
print("UPS auto-detect: Trying PiSugar I2C (native)...")
|
||||
detected, driver = await PiSugarI2CDriver.detect()
|
||||
if detected and driver:
|
||||
model = driver.get_model_name()
|
||||
print(f"UPS auto-detect: SUCCESS - PiSugar I2C: {model}")
|
||||
return (driver, f"Detected PiSugar UPS via I2C: {model}")
|
||||
print("UPS auto-detect: PiSugar I2C not detected")
|
||||
|
||||
# Try PiSugar daemon (legacy fallback)
|
||||
print("UPS auto-detect: Trying PiSugar daemon...")
|
||||
detected, model = await PiSugarDriver.detect(host=pisugar_host, port=pisugar_port)
|
||||
if detected:
|
||||
driver = PiSugarDriver(host=pisugar_host, port=pisugar_port)
|
||||
print(f"UPS auto-detect: SUCCESS - PiSugar daemon: {model}")
|
||||
return (driver, f"Detected PiSugar UPS via daemon: {model}")
|
||||
print("UPS auto-detect: PiSugar daemon not detected")
|
||||
|
||||
# Try native I2C again if we skipped it earlier
|
||||
if not prefer_native_i2c:
|
||||
print("UPS auto-detect: Trying PiSugar I2C (second attempt)...")
|
||||
detected, driver = await PiSugarI2CDriver.detect()
|
||||
if detected and driver:
|
||||
model = driver.get_model_name()
|
||||
print(f"UPS auto-detect: SUCCESS - PiSugar I2C: {model}")
|
||||
return (driver, f"Detected PiSugar UPS via I2C: {model}")
|
||||
|
||||
# Try generic I2C fuel gauge (exclude PiSugar I2C addresses)
|
||||
print("UPS auto-detect: Trying generic I2C fuel gauge...")
|
||||
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)
|
||||
print(f"UPS auto-detect: SUCCESS - I2C fuel gauge at 0x{i2c_addr:02X}")
|
||||
return (driver, f"Detected I2C fuel gauge at address 0x{i2c_addr:02X}")
|
||||
print("UPS auto-detect: No I2C fuel gauge detected")
|
||||
|
||||
print("UPS auto-detect: No UPS hardware found")
|
||||
return (None, "No UPS hardware detected")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"UPSDriver",
|
||||
"UPSData",
|
||||
"PiSugarDriver",
|
||||
"PiSugarI2CDriver",
|
||||
"PiSugarModel",
|
||||
"ButtonEvent",
|
||||
"I2CFuelGaugeDriver",
|
||||
"auto_detect_driver",
|
||||
]
|
||||
60
app/backend/managers/ups_drivers/base.py
Normal file
60
app/backend/managers/ups_drivers/base.py
Normal file
@@ -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
|
||||
216
app/backend/managers/ups_drivers/i2c_driver.py
Normal file
216
app/backend/managers/ups_drivers/i2c_driver.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""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:
|
||||
print("I2C Fuel Gauge: smbus2 not available")
|
||||
return (False, None)
|
||||
|
||||
exclude = exclude_addresses or []
|
||||
print(f"I2C Fuel Gauge: Scanning addresses {[hex(a) for a in cls.FUEL_GAUGE_ADDRESSES]}, excluding {[hex(a) for a in exclude]}")
|
||||
|
||||
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)
|
||||
print(f"I2C Fuel Gauge: Found device at 0x{addr:02X}")
|
||||
|
||||
# Additional validation: try to read SOC register (0x04)
|
||||
# MAX17040/MAX17048 should respond to this
|
||||
try:
|
||||
data = bus.read_i2c_block_data(addr, 0x04, 2)
|
||||
print(f"I2C Fuel Gauge: SOC register read OK at 0x{addr:02X}: {data}")
|
||||
return (True, addr)
|
||||
except OSError as e:
|
||||
# Device responded to address but not to register read
|
||||
# Still likely a fuel gauge, just different protocol
|
||||
print(f"I2C Fuel Gauge: SOC register read failed at 0x{addr:02X}: {e}")
|
||||
return (True, addr)
|
||||
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
finally:
|
||||
bus.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"I2C Fuel Gauge: Detection error: {e}")
|
||||
|
||||
print("I2C Fuel Gauge: No device found")
|
||||
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
|
||||
)
|
||||
282
app/backend/managers/ups_drivers/pisugar_driver.py
Normal file
282
app/backend/managers/ups_drivers/pisugar_driver.py
Normal file
@@ -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
|
||||
659
app/backend/managers/ups_drivers/pisugar_i2c_driver.py
Normal file
659
app/backend/managers/ups_drivers/pisugar_i2c_driver.py
Normal file
@@ -0,0 +1,659 @@
|
||||
"""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, retries: int = 3, retry_delay: float = 0.5) -> Tuple[bool, Optional["PiSugarI2CDriver"]]:
|
||||
"""Detect PiSugar hardware by probing I2C addresses.
|
||||
|
||||
Args:
|
||||
i2c_bus: I2C bus number (default: 1 for Raspberry Pi)
|
||||
retries: Number of detection attempts (for early-boot scenarios)
|
||||
retry_delay: Delay between retries in seconds
|
||||
|
||||
Returns:
|
||||
Tuple of (detected: bool, driver_instance or None)
|
||||
"""
|
||||
if SMBus is None:
|
||||
print("PiSugar I2C: smbus2 not available")
|
||||
return (False, None)
|
||||
|
||||
for attempt in range(retries):
|
||||
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:
|
||||
low = bus.read_byte_data(addr, config.voltage_reg_low)
|
||||
high = bus.read_byte_data(addr, config.voltage_reg_high)
|
||||
|
||||
# Success - create driver instance
|
||||
print(f"PiSugar I2C: Found {config.model.value} at 0x{addr:02X} (voltage regs: {low}, {high})")
|
||||
driver = cls(chip_config=config, i2c_bus=i2c_bus)
|
||||
return (True, driver)
|
||||
except OSError as e:
|
||||
# Address responded but not the expected chip
|
||||
print(f"PiSugar I2C: 0x{addr:02X} responded but voltage reg read failed: {e}")
|
||||
continue
|
||||
|
||||
except OSError:
|
||||
# No device at this address
|
||||
continue
|
||||
|
||||
finally:
|
||||
bus.close()
|
||||
|
||||
except Exception as e:
|
||||
if attempt < retries - 1:
|
||||
print(f"PiSugar I2C: Detection attempt {attempt + 1} failed ({e}), retrying...")
|
||||
await asyncio.sleep(retry_delay)
|
||||
else:
|
||||
print(f"PiSugar I2C: All detection attempts failed: {e}")
|
||||
|
||||
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)
|
||||
@@ -1,7 +1,11 @@
|
||||
"""UPS Manager for Dangerous Pi.
|
||||
|
||||
Handles I2C battery monitoring, safe shutdown triggers,
|
||||
and battery percentage reporting for UPS HAT devices.
|
||||
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
|
||||
@@ -9,12 +13,9 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, Any
|
||||
try:
|
||||
from smbus2 import SMBus
|
||||
except ImportError:
|
||||
SMBus = None # Mock for development environments
|
||||
|
||||
from .. import config
|
||||
from .ups_drivers import UPSDriver, PiSugarDriver, I2CFuelGaugeDriver, auto_detect_driver
|
||||
|
||||
|
||||
class BatteryStatus(str, Enum):
|
||||
@@ -51,11 +52,14 @@ class UPSStatus:
|
||||
class UPSManager:
|
||||
"""Manages UPS battery monitoring and safe shutdown."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the UPS manager."""
|
||||
self._i2c_address = int(config.UPS_I2C_ADDRESS, 16)
|
||||
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._bus: Optional[SMBus] = None
|
||||
self._driver = driver or self._create_driver()
|
||||
self._status = UPSStatus(
|
||||
battery_percentage=0.0,
|
||||
voltage=0.0,
|
||||
@@ -67,30 +71,91 @@ class UPSManager:
|
||||
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 = []
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""Initialize I2C connection to UPS.
|
||||
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, startup_delay: float = 1.0) -> bool:
|
||||
"""Initialize connection to UPS hardware.
|
||||
|
||||
Args:
|
||||
startup_delay: Delay before detection to allow I2C bus to settle
|
||||
|
||||
Returns:
|
||||
True if initialization successful, False otherwise
|
||||
"""
|
||||
if SMBus is None:
|
||||
self._status.is_available = False
|
||||
self._status.error_message = "smbus2 library not available"
|
||||
return False
|
||||
|
||||
try:
|
||||
# Try to open I2C bus (usually bus 1 on Raspberry Pi)
|
||||
self._bus = SMBus(1)
|
||||
# If no driver set, try auto-detection (for "auto" or unknown types)
|
||||
if self._driver is None:
|
||||
ups_type = config.UPS_TYPE.lower()
|
||||
|
||||
# Try to read from the device to verify it exists
|
||||
await self._read_battery_data()
|
||||
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
|
||||
|
||||
self._status.is_available = True
|
||||
self._status.error_message = None
|
||||
return True
|
||||
# Small delay to ensure I2C bus is ready after boot
|
||||
if startup_delay > 0:
|
||||
await asyncio.sleep(startup_delay)
|
||||
|
||||
# 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
|
||||
@@ -125,6 +190,116 @@ class UPSManager:
|
||||
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.
|
||||
|
||||
@@ -161,21 +336,64 @@ class UPSManager:
|
||||
self._event_callbacks.append(callback)
|
||||
|
||||
async def _update_battery_status(self):
|
||||
"""Update battery status from I2C device."""
|
||||
if not self._bus or not self._status.is_available:
|
||||
"""Update battery status from UPS hardware."""
|
||||
if not self._status.is_available:
|
||||
return
|
||||
|
||||
try:
|
||||
data = await self._read_battery_data()
|
||||
# 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.power_source = data['power_source']
|
||||
self._status.battery_status = data['battery_status']
|
||||
self._status.time_remaining = data.get('time_remaining')
|
||||
self._status.temperature = data.get('temperature')
|
||||
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
|
||||
|
||||
@@ -183,78 +401,6 @@ class UPSManager:
|
||||
print(f"Error reading battery data: {e}")
|
||||
self._status.error_message = str(e)
|
||||
|
||||
async def _read_battery_data(self) -> Dict[str, Any]:
|
||||
"""Read battery data from I2C device.
|
||||
|
||||
This implementation is for MAX17040/MAX17048 fuel gauge.
|
||||
Adjust register addresses for different UPS HAT models.
|
||||
|
||||
Returns:
|
||||
Dictionary with battery data
|
||||
"""
|
||||
if not self._bus:
|
||||
raise RuntimeError("I2C bus not initialized")
|
||||
|
||||
# Run I2C read in thread pool to avoid blocking
|
||||
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 # 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 based on voltage change
|
||||
# This is a simple estimation; adjust based on your UPS HAT
|
||||
current = 0.0 # Some UPS HATs don't provide current readings
|
||||
|
||||
# Determine power source and battery status
|
||||
# Typically voltage > 4.1V means charging
|
||||
if voltage > 4100: # 4.1V in mV
|
||||
power_source = PowerSource.AC
|
||||
if percentage >= 99.0:
|
||||
battery_status = BatteryStatus.FULL
|
||||
else:
|
||||
battery_status = BatteryStatus.CHARGING
|
||||
else:
|
||||
power_source = PowerSource.BATTERY
|
||||
if percentage < self._critical_threshold:
|
||||
battery_status = BatteryStatus.CRITICAL
|
||||
else:
|
||||
battery_status = BatteryStatus.DISCHARGING
|
||||
|
||||
# Estimate time remaining (simplified)
|
||||
time_remaining = None
|
||||
if power_source == PowerSource.BATTERY and current > 0:
|
||||
# Rough estimation: battery_mah * (percentage/100) / current_ma
|
||||
# This would need actual battery capacity from config
|
||||
pass
|
||||
|
||||
return {
|
||||
'percentage': percentage,
|
||||
'voltage': voltage,
|
||||
'current': current,
|
||||
'power_source': power_source,
|
||||
'battery_status': battery_status,
|
||||
'time_remaining': time_remaining,
|
||||
'temperature': None # Not all UPS HATs provide temperature
|
||||
}
|
||||
|
||||
async def _check_battery_thresholds(self):
|
||||
"""Check battery levels and trigger actions."""
|
||||
@@ -327,10 +473,9 @@ class UPSManager:
|
||||
self._warning_threshold = threshold
|
||||
|
||||
def close(self):
|
||||
"""Close I2C bus connection."""
|
||||
if self._bus:
|
||||
self._bus.close()
|
||||
self._bus = None
|
||||
"""Close UPS driver connection."""
|
||||
if self._driver:
|
||||
self._driver.close()
|
||||
|
||||
|
||||
# Global UPS manager instance
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""WiFi manager for network configuration and mode switching."""
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
@@ -9,6 +10,12 @@ from pathlib import Path
|
||||
|
||||
from .. import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Persistent storage for WiFi mode
|
||||
WIFI_MODE_FILE = Path("/opt/dangerous-pi/data/wifi_mode")
|
||||
WIFI_DATA_DIR = Path("/opt/dangerous-pi/data")
|
||||
|
||||
|
||||
class WiFiMode(str, Enum):
|
||||
"""WiFi operation modes."""
|
||||
@@ -29,6 +36,7 @@ class WiFiInterface:
|
||||
connected: bool
|
||||
ssid: Optional[str] = None
|
||||
ip_address: Optional[str] = None
|
||||
mode: str = "managed" # "AP" or "managed" (client)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -62,6 +70,68 @@ class WiFiManager:
|
||||
self._current_mode = WiFiMode.AUTO
|
||||
self._interfaces: List[WiFiInterface] = []
|
||||
self._supports_dual = False
|
||||
self._startup_applied = False
|
||||
|
||||
def _save_mode(self, mode: WiFiMode) -> bool:
|
||||
"""Save WiFi mode to persistent storage.
|
||||
|
||||
Args:
|
||||
mode: WiFi mode to save
|
||||
|
||||
Returns:
|
||||
True if saved successfully
|
||||
"""
|
||||
try:
|
||||
# Ensure data directory exists
|
||||
WIFI_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write mode to file
|
||||
WIFI_MODE_FILE.write_text(mode.value)
|
||||
logger.info(f"WiFi mode saved: {mode.value}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save WiFi mode: {e}")
|
||||
return False
|
||||
|
||||
def _load_mode(self) -> Optional[WiFiMode]:
|
||||
"""Load WiFi mode from persistent storage.
|
||||
|
||||
Returns:
|
||||
Saved WiFi mode or None if not found
|
||||
"""
|
||||
try:
|
||||
if WIFI_MODE_FILE.exists():
|
||||
mode_str = WIFI_MODE_FILE.read_text().strip()
|
||||
mode = WiFiMode(mode_str)
|
||||
logger.info(f"WiFi mode loaded: {mode.value}")
|
||||
return mode
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load WiFi mode: {e}")
|
||||
return None
|
||||
|
||||
async def apply_saved_mode(self) -> bool:
|
||||
"""Apply the saved WiFi mode on startup.
|
||||
|
||||
This should be called once during service startup to restore
|
||||
the previously configured WiFi mode.
|
||||
|
||||
Returns:
|
||||
True if mode was applied successfully
|
||||
"""
|
||||
if self._startup_applied:
|
||||
logger.debug("Saved mode already applied, skipping")
|
||||
return True
|
||||
|
||||
saved_mode = self._load_mode()
|
||||
if saved_mode:
|
||||
logger.info(f"Applying saved WiFi mode: {saved_mode.value}")
|
||||
success = await self.set_mode(saved_mode, persist=False) # Don't re-save
|
||||
self._startup_applied = True
|
||||
return success
|
||||
else:
|
||||
logger.info("No saved WiFi mode found, using default (AUTO)")
|
||||
self._startup_applied = True
|
||||
return True
|
||||
|
||||
async def detect_interfaces(self) -> List[WiFiInterface]:
|
||||
"""Detect available WiFi interfaces.
|
||||
@@ -79,6 +149,15 @@ class WiFiManager:
|
||||
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()
|
||||
@@ -95,7 +174,8 @@ class WiFiManager:
|
||||
'is_up': False,
|
||||
'connected': False,
|
||||
'ssid': None,
|
||||
'ip_address': None
|
||||
'ip_address': None,
|
||||
'mode': 'managed' # Default to managed (client) mode
|
||||
}
|
||||
|
||||
elif current_interface and line.startswith('addr '):
|
||||
@@ -103,7 +183,14 @@ class WiFiManager:
|
||||
|
||||
elif current_interface and line.startswith('ssid '):
|
||||
current_interface['ssid'] = ' '.join(line.split()[1:])
|
||||
current_interface['connected'] = True
|
||||
|
||||
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)
|
||||
@@ -117,8 +204,9 @@ class WiFiManager:
|
||||
if iface_dict['is_up']:
|
||||
iface_dict['ip_address'] = await self._get_interface_ip(iface_dict['name'])
|
||||
|
||||
# Create WiFiInterface object
|
||||
wifi_iface = WiFiInterface(**iface_dict)
|
||||
# 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]
|
||||
@@ -206,28 +294,35 @@ class WiFiManager:
|
||||
ap_ip = None
|
||||
|
||||
for iface in self._interfaces:
|
||||
if iface.connected and iface.ssid:
|
||||
# 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
|
||||
|
||||
# Check if running as AP (typically 10.3.141.1)
|
||||
if iface.ip_address and iface.ip_address.startswith("10.3.141"):
|
||||
ap_ip = iface.ip_address
|
||||
# Try to get AP SSID from hostapd
|
||||
ap_ssid = await self._get_ap_ssid()
|
||||
# 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 "raspi-webgui", # Default from existing setup
|
||||
ap_ip=ap_ip or "10.3.141.1",
|
||||
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.
|
||||
"""Detect current WiFi mode based on interface types.
|
||||
|
||||
Returns:
|
||||
Current WiFi mode
|
||||
@@ -235,14 +330,16 @@ class WiFiManager:
|
||||
if not self._interfaces:
|
||||
return WiFiMode.OFF
|
||||
|
||||
# Check if any interface is in AP mode
|
||||
has_ap = any(
|
||||
iface.ip_address and iface.ip_address.startswith("10.3.141")
|
||||
# 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
|
||||
)
|
||||
|
||||
# Check if any interface is connected as client
|
||||
has_client = any(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
|
||||
@@ -250,8 +347,25 @@ class WiFiManager:
|
||||
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.AUTO
|
||||
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.
|
||||
@@ -277,6 +391,10 @@ class WiFiManager:
|
||||
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:
|
||||
@@ -292,13 +410,14 @@ class WiFiManager:
|
||||
|
||||
try:
|
||||
# Request scan
|
||||
await self._run_command(f"sudo iw dev {interface} scan trigger", check=False)
|
||||
# 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"sudo iw dev {interface} scan")
|
||||
result = await self._run_command(f"iw dev {interface} scan")
|
||||
|
||||
networks = []
|
||||
current_network = None
|
||||
@@ -306,11 +425,13 @@ class WiFiManager:
|
||||
for line in result.split('\n'):
|
||||
line = line.strip()
|
||||
|
||||
if line.startswith('BSS '):
|
||||
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)
|
||||
|
||||
bssid = line.split()[1].rstrip('(')
|
||||
# Handle format: BSS aa:bb:cc:dd:ee:ff(on wlan0)
|
||||
bssid = line.split()[1].split('(')[0]
|
||||
current_network = {
|
||||
'ssid': '',
|
||||
'bssid': bssid,
|
||||
@@ -324,7 +445,8 @@ class WiFiManager:
|
||||
if line.startswith('SSID: '):
|
||||
current_network['ssid'] = line[6:]
|
||||
elif line.startswith('freq: '):
|
||||
current_network['frequency'] = int(line[6:])
|
||||
# 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]
|
||||
@@ -346,11 +468,12 @@ class WiFiManager:
|
||||
print(f"Error scanning networks: {e}")
|
||||
return []
|
||||
|
||||
async def set_mode(self, mode: WiFiMode) -> bool:
|
||||
async def set_mode(self, mode: WiFiMode, persist: bool = True) -> bool:
|
||||
"""Set WiFi mode.
|
||||
|
||||
Args:
|
||||
mode: Desired WiFi mode
|
||||
persist: If True, save mode to persistent storage for boot persistence
|
||||
|
||||
Returns:
|
||||
True if mode was set successfully
|
||||
@@ -371,40 +494,122 @@ class WiFiManager:
|
||||
await self._enable_auto_mode()
|
||||
|
||||
self._current_mode = mode
|
||||
|
||||
# Persist mode for boot persistence
|
||||
if persist:
|
||||
self._save_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.
|
||||
|
||||
Uses nmcli to set wlan0 as managed (no file permissions needed).
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Check current device status
|
||||
status = await self._run_command("nmcli device status", check=False)
|
||||
logger.warning(f"[NM Management] Current status:\n{status}")
|
||||
|
||||
# Use nmcli to set device as managed (works without root)
|
||||
logger.warning("[NM Management] Setting wlan0 as managed...")
|
||||
result = await self._run_command("nmcli device set wlan0 managed yes", check=False)
|
||||
logger.warning(f"[NM Management] nmcli set managed result: '{result}'")
|
||||
|
||||
# Give NM time to pick up the device
|
||||
logger.warning("[NM Management] Waiting 2s...")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Verify device is now managed
|
||||
status = await self._run_command("nmcli device status", check=False)
|
||||
logger.warning(f"[NM Management] Status after:\n{status}")
|
||||
|
||||
async def _disable_nm_management(self):
|
||||
"""Disable NetworkManager management of wlan0 for AP mode.
|
||||
|
||||
Uses nmcli to set wlan0 as unmanaged (for hostapd).
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Use nmcli to set device as unmanaged
|
||||
logger.warning("[NM Management] Setting wlan0 as unmanaged...")
|
||||
result = await self._run_command("nmcli device set wlan0 managed no", check=False)
|
||||
logger.warning(f"[NM Management] nmcli set unmanaged result: '{result}'")
|
||||
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._run_command("sudo systemctl start hostapd")
|
||||
await self._run_command("sudo systemctl start dnsmasq")
|
||||
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."""
|
||||
"""Enable Client mode using NetworkManager."""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Stop AP services
|
||||
await self._run_command("sudo systemctl stop hostapd", check=False)
|
||||
await self._run_command("sudo systemctl stop dnsmasq", check=False)
|
||||
# Start wpa_supplicant
|
||||
await self._run_command("sudo systemctl start wpa_supplicant")
|
||||
print("Client mode enabled")
|
||||
logger.warning("[Client Mode] Stopping hostapd...")
|
||||
await self._systemctl("stop", "hostapd")
|
||||
logger.warning("[Client Mode] Stopping dnsmasq...")
|
||||
await self._systemctl("stop", "dnsmasq")
|
||||
|
||||
# Check hostapd status
|
||||
hostapd_status = await self._run_command("systemctl is-active hostapd", check=False)
|
||||
logger.warning(f"[Client Mode] hostapd status after stop: {hostapd_status.strip()}")
|
||||
|
||||
# Enable NetworkManager to manage wlan0
|
||||
logger.warning("[Client Mode] Enabling NM management...")
|
||||
await self._enable_nm_management()
|
||||
logger.warning("[Client Mode] Client mode enabled")
|
||||
|
||||
async def _enable_dual_mode(self):
|
||||
"""Enable Dual mode (AP + Client)."""
|
||||
# Enable both AP and client
|
||||
await self._run_command("sudo systemctl start hostapd")
|
||||
await self._run_command("sudo systemctl start dnsmasq")
|
||||
await self._run_command("sudo systemctl start wpa_supplicant")
|
||||
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._run_command("sudo systemctl stop hostapd", check=False)
|
||||
await self._run_command("sudo systemctl stop dnsmasq", check=False)
|
||||
await self._run_command("sudo systemctl stop wpa_supplicant", check=False)
|
||||
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):
|
||||
@@ -426,19 +631,25 @@ class WiFiManager:
|
||||
ssid: str,
|
||||
password: Optional[str] = None,
|
||||
interface: Optional[str] = None,
|
||||
hidden: bool = False
|
||||
hidden: bool = False,
|
||||
fallback_to_ap: bool = True
|
||||
) -> bool:
|
||||
"""Connect to a WiFi network.
|
||||
"""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:
|
||||
@@ -449,52 +660,79 @@ class WiFiManager:
|
||||
interface = self._interfaces[0].name
|
||||
|
||||
if not interface:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning("[WiFi Connect] No interface found!")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Add network to wpa_supplicant configuration
|
||||
config_path = Path("/etc/wpa_supplicant/wpa_supplicant.conf")
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.warning(f"[WiFi Connect] Starting connect to {ssid}")
|
||||
|
||||
# Switch to client mode (stops hostapd, enables NM management)
|
||||
logger.warning("[WiFi Connect] Step 1: Enabling client mode...")
|
||||
await self._enable_client_mode()
|
||||
logger.warning("[WiFi Connect] Step 1: Client mode enabled")
|
||||
|
||||
# Wait for NM to be ready and detect wlan0
|
||||
logger.warning("[WiFi Connect] Step 2: Waiting 2s for NM...")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Check device status
|
||||
dev_status = await self._run_command("nmcli device status", check=False)
|
||||
logger.warning(f"[WiFi Connect] Device status after wait:\n{dev_status}")
|
||||
|
||||
# Delete any existing saved connection for this SSID
|
||||
# (handles corrupted connections with missing key-mgmt property)
|
||||
ssid_escaped = ssid.replace("'", "'\\''")
|
||||
logger.warning(f"[WiFi Connect] Step 3: Deleting existing connection for {ssid}...")
|
||||
del_result = await self._run_command(
|
||||
f"nmcli connection delete '{ssid_escaped}'",
|
||||
check=False
|
||||
)
|
||||
logger.warning(f"[WiFi Connect] Delete result: {del_result}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Build nmcli connect command
|
||||
cmd = f"nmcli device wifi connect '{ssid_escaped}'"
|
||||
|
||||
# Create network block
|
||||
if password:
|
||||
# Encrypted network
|
||||
network_block = f"""
|
||||
network={{
|
||||
ssid="{ssid}"
|
||||
psk="{password}"
|
||||
scan_ssid={1 if hidden else 0}
|
||||
priority=1
|
||||
}}
|
||||
"""
|
||||
else:
|
||||
# Open network
|
||||
network_block = f"""
|
||||
network={{
|
||||
ssid="{ssid}"
|
||||
key_mgmt=NONE
|
||||
scan_ssid={1 if hidden else 0}
|
||||
priority=1
|
||||
}}
|
||||
"""
|
||||
password_escaped = password.replace("'", "'\\''")
|
||||
cmd += f" password '{password_escaped}'"
|
||||
|
||||
# Append to config (or use wpa_cli)
|
||||
# Using wpa_cli for immediate connection
|
||||
await self._add_network_via_wpa_cli(ssid, password, hidden)
|
||||
if hidden:
|
||||
cmd += " hidden yes"
|
||||
|
||||
# Reconnect wpa_supplicant
|
||||
await self._run_command(f"sudo wpa_cli -i {interface} reconfigure")
|
||||
# Attempt connection
|
||||
logger.warning(f"[WiFi Connect] Step 4: Running nmcli connect...")
|
||||
result = await self._run_command(cmd, check=False)
|
||||
logger.warning(f"[WiFi Connect] Connect result: {result}")
|
||||
|
||||
# Wait for connection
|
||||
await asyncio.sleep(3)
|
||||
# Check if connection was successful
|
||||
if "successfully activated" in result.lower():
|
||||
logger.warning(f"[WiFi Connect] SUCCESS - connected to {ssid}")
|
||||
|
||||
# Verify connection
|
||||
result = await self._run_command(f"sudo wpa_cli -i {interface} status")
|
||||
if f'ssid={ssid}' in result and 'wpa_state=COMPLETED' in result:
|
||||
print(f"Successfully connected to {ssid}")
|
||||
return True
|
||||
else:
|
||||
print(f"Connection to {ssid} failed")
|
||||
return False
|
||||
# Verify we have an IP address
|
||||
await asyncio.sleep(2)
|
||||
ip_result = await self._run_command(f"ip addr show {interface}")
|
||||
logger.warning(f"[WiFi Connect] IP result: {ip_result}")
|
||||
if "inet " in ip_result and "192.168.4." not in ip_result:
|
||||
# Save client mode for boot persistence
|
||||
self._current_mode = WiFiMode.CLIENT
|
||||
self._save_mode(WiFiMode.CLIENT)
|
||||
logger.info(f"WiFi client mode saved for boot persistence")
|
||||
return True
|
||||
|
||||
# Connection failed
|
||||
logger.warning(f"[WiFi Connect] 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}")
|
||||
@@ -532,7 +770,7 @@ network={{
|
||||
return False
|
||||
|
||||
async def disconnect_from_network(self, interface: Optional[str] = None) -> bool:
|
||||
"""Disconnect from current network.
|
||||
"""Disconnect from current network and return to AP mode.
|
||||
|
||||
Args:
|
||||
interface: Interface to disconnect (default: first connected)
|
||||
@@ -550,30 +788,36 @@ network={{
|
||||
return False
|
||||
|
||||
try:
|
||||
await self._run_command(f"sudo wpa_cli -i {interface} disconnect")
|
||||
# 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 networks from wpa_supplicant.
|
||||
"""Get list of saved WiFi networks from NetworkManager.
|
||||
|
||||
Returns:
|
||||
List of saved networks with SSID and other info
|
||||
"""
|
||||
try:
|
||||
result = await self._run_command("sudo wpa_cli list_networks")
|
||||
result = await self._run_command(
|
||||
"nmcli -t -f NAME,TYPE connection show",
|
||||
check=False
|
||||
)
|
||||
networks = []
|
||||
|
||||
for line in result.split('\n')[1:]: # Skip header
|
||||
for line in result.split('\n'):
|
||||
if line.strip():
|
||||
parts = line.split('\t')
|
||||
if len(parts) >= 2:
|
||||
parts = line.split(':')
|
||||
if len(parts) >= 2 and parts[1] == '802-11-wireless':
|
||||
networks.append({
|
||||
'id': parts[0].strip(),
|
||||
'ssid': parts[1].strip(),
|
||||
'enabled': 'CURRENT' in line or 'ENABLED' in line
|
||||
'ssid': parts[0],
|
||||
'type': 'wifi',
|
||||
'enabled': True
|
||||
})
|
||||
|
||||
return networks
|
||||
@@ -591,23 +835,14 @@ network={{
|
||||
True if network was forgotten
|
||||
"""
|
||||
try:
|
||||
# Get network ID
|
||||
networks = await self.get_saved_networks()
|
||||
network_id = None
|
||||
# Delete connection using nmcli
|
||||
ssid_escaped = ssid.replace("'", "'\\''")
|
||||
result = await self._run_command(
|
||||
f"nmcli connection delete '{ssid_escaped}'",
|
||||
check=False
|
||||
)
|
||||
|
||||
for net in networks:
|
||||
if net['ssid'] == ssid:
|
||||
network_id = net['id']
|
||||
break
|
||||
|
||||
if network_id is None:
|
||||
return False
|
||||
|
||||
# Remove network
|
||||
await self._run_command(f"sudo wpa_cli remove_network {network_id}")
|
||||
await self._run_command("sudo wpa_cli save_config")
|
||||
|
||||
return True
|
||||
return "successfully deleted" in result.lower()
|
||||
except Exception as e:
|
||||
print(f"Error forgetting network: {e}")
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user