Initial commit - Phase 3/4

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
michael
2026-01-06 13:45:29 -08:00
parent 1da6730735
commit 4f35df1781
323 changed files with 98287 additions and 1195 deletions

View File

@@ -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.

View File

@@ -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

View 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())

View File

@@ -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)

View 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",
]

View 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

View 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
)

View 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

View 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)

View File

@@ -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

View File

@@ -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