diff --git a/obd2_client/src/flipper/pages.py b/obd2_client/src/flipper/pages.py index 77041c4..aaa4d8b 100644 --- a/obd2_client/src/flipper/pages.py +++ b/obd2_client/src/flipper/pages.py @@ -84,7 +84,14 @@ class PageManager: ], )) - # Page 4: Confirm (dynamic) + # Page 4: UPS Status (if available) + self._pages.append(PageDefinition( + page_type=PageType.INFO, + title="UPS Status", + generator=self._generate_ups_page, + )) + + # Page 5: Confirm (dynamic) self._pages.append(PageDefinition( page_type=PageType.CONFIRM, title="Confirm", @@ -110,15 +117,42 @@ class PageManager: """ self._data_providers[name] = provider - def set_action_handler(self, action_id: ActionID, handler: Callable[[], bool]) -> None: + def set_action_handler(self, action_id: ActionID | str, handler: Callable[[], bool]) -> None: """Register an action handler. Args: - action_id: Action identifier + action_id: Action identifier (ActionID enum or string) handler: Callable that executes action, returns True on success """ self._action_handlers[action_id] = handler + def add_page(self, page: Any) -> None: + """Add a custom page to the page manager. + + Args: + page: Page object with get_content(), is_enabled(), name, title attributes + """ + # Create a PageDefinition wrapper for custom page + def generator(mgr: "PageManager") -> Page: + content = page.get_content() + return Page( + page_type=PageType(content.page_type.value), + title=content.title, + lines=content.lines, + actions=content.actions, + selected=content.selected, + ) + + # Insert before the confirm page (last page) + page_def = PageDefinition( + page_type=PageType.INFO, + title=page.title, + generator=generator, + ) + # Insert at position -1 (before confirm page) + self._pages.insert(len(self._pages) - 1, page_def) + _logger.info(f"Added custom page: {page.name}") + def get_data(self, name: str, default: Any = None) -> Any: """Get data from a provider. @@ -435,6 +469,40 @@ class PageManager: ] return Page(PageType.MENU, "Actions", actions=actions) + def _generate_ups_page(self, mgr: "PageManager") -> Page: + """Generate UPS status page.""" + ups_data = mgr.get_data("ups") + + if ups_data is None: + lines = ["UPS not available", "", "Check I2C connection"] + return Page(PageType.INFO, "UPS Status", lines) + + # Battery bar visualization + capacity = ups_data.capacity if hasattr(ups_data, 'capacity') else 0 + filled = int(capacity / 16.67) # 6 segments + empty = 6 - filled + bar = "[" + "=" * filled + " " * empty + "]" + + lines = [ + f"Bat: {capacity:.1f}% {bar}", + f"Voltage: {ups_data.voltage:.2f}V", + ] + + if hasattr(ups_data, 'power_loss') and ups_data.power_loss: + lines.append("Power: BATTERY MODE") + if capacity < 15: + lines.append("!! CRITICAL !!") + elif capacity < 25: + lines.append("! LOW BATTERY !") + else: + status = "Charging" if getattr(ups_data, 'is_charging', False) else "Full" + lines.append(f"Status: {status}") + input_v = getattr(ups_data, 'input_voltage', 0) + if input_v > 0: + lines.append(f"Input: {input_v:.2f}V") + + return Page(PageType.INFO, "UPS Status", lines) + def _generate_confirm_page(self, mgr: "PageManager") -> Page: """Generate confirmation page.""" if mgr._pending_action == ActionID.REBOOT_SYSTEM: diff --git a/obd2_client/src/flipper/pages/__init__.py b/obd2_client/src/flipper/pages/__init__.py deleted file mode 100644 index b7ac79d..0000000 --- a/obd2_client/src/flipper/pages/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -Flipper Zero UI Pages. - -Each page represents a screen on the Flipper Zero display. -""" - -from .base import BasePage, InfoPage, MenuPage, ConfirmPage -from .ups_status import UPSStatusPage - -__all__ = [ - "BasePage", - "InfoPage", - "MenuPage", - "ConfirmPage", - "UPSStatusPage", -] diff --git a/obd2_client/src/flipper/pages/base.py b/obd2_client/src/flipper/pages/base.py deleted file mode 100644 index 6387b71..0000000 --- a/obd2_client/src/flipper/pages/base.py +++ /dev/null @@ -1,312 +0,0 @@ -""" -Base Page Interface for Flipper Zero UI. - -All pages must inherit from BasePage and implement required methods. -""" - -from abc import ABC, abstractmethod -from typing import Optional, Callable, List -from dataclasses import dataclass, field -from enum import Enum - - -class PageType(Enum): - """Types of pages for different UI layouts.""" - INFO = "info" - MENU = "menu" - CONFIRM = "confirm" - - -@dataclass -class PageContent: - """ - Content structure for a page to be displayed on Flipper Zero. - - Attributes: - page_type: Type of page (info, menu, confirm) - title: Page title (max ~20 chars for display) - lines: Content lines to display (max 4-5 lines) - actions: Available actions/menu items (for menu type) - selected: Currently selected item index (for menu type) - icon: Optional icon identifier for the page - """ - page_type: PageType - title: str - lines: List[str] = field(default_factory=list) - actions: List[str] = field(default_factory=list) - selected: int = 0 - icon: str = "" - - def __post_init__(self): - """Truncate title if too long.""" - if len(self.title) > 20: - self.title = self.title[:17] + "..." - - -class BasePage(ABC): - """ - Abstract base class for Flipper Zero pages. - - Each page represents a screen on Flipper Zero display. - Pages can be informational (read-only), interactive menus, - or confirmation dialogs. - - Attributes: - name: Unique identifier for the page - title: Display title (shown in header) - icon: Icon identifier for visual representation - enabled: Whether the page is active - """ - - def __init__( - self, - name: str, - title: str, - icon: str = "", - enabled: bool = True, - ): - """ - Initialize base page. - - Args: - name: Unique page identifier - title: Display title - icon: Icon identifier - enabled: Whether page is enabled - """ - self.name = name - self.title = title - self.icon = icon - self.enabled = enabled - self._selected_index = 0 - self._scroll_offset = 0 - self._pending_action: Optional[str] = None - - @abstractmethod - def get_content(self) -> PageContent: - """ - Get current page content for display. - - Returns: - PageContent object with type, title, lines, and actions - """ - pass - - def handle_select(self, index: int) -> Optional[str]: - """ - Handle menu item selection. - - Called when user presses OK on a menu item. - - Args: - index: Selected item index - - Returns: - Optional result message to display, or None - """ - self._selected_index = index - return None - - def handle_confirm(self) -> Optional[str]: - """ - Handle confirmation action. - - Called when user confirms a pending action. - - Returns: - Optional result message, or None - """ - return None - - def handle_cancel(self) -> Optional[str]: - """ - Handle cancel action. - - Called when user cancels a pending action. - - Returns: - Optional result message, or None - """ - self._pending_action = None - return None - - def handle_scroll(self, direction: str) -> None: - """ - Handle scroll up/down. - - Args: - direction: "up" or "down" - """ - if direction == "up": - self._scroll_offset = max(0, self._scroll_offset - 1) - elif direction == "down": - self._scroll_offset += 1 - - def get_selected_index(self) -> int: - """Get currently selected index for menu pages.""" - return self._selected_index - - def set_selected_index(self, index: int) -> None: - """Set selected index for menu pages.""" - self._selected_index = index - - def get_scroll_offset(self) -> int: - """Get current scroll offset.""" - return self._scroll_offset - - def reset_scroll(self) -> None: - """Reset scroll position to top.""" - self._scroll_offset = 0 - - def has_pending_action(self) -> bool: - """Check if there's a pending action requiring confirmation.""" - return self._pending_action is not None - - def get_pending_action(self) -> Optional[str]: - """Get pending action name.""" - return self._pending_action - - def is_enabled(self) -> bool: - """Check if page is enabled.""" - return self.enabled - - def on_enter(self) -> None: - """Called when user navigates to this page.""" - pass - - def on_leave(self) -> None: - """Called when user navigates away from this page.""" - pass - - -class InfoPage(BasePage): - """ - Base class for information-only pages. - - Info pages display read-only data that updates periodically. - They have no interactive elements. - """ - - def __init__( - self, - name: str, - title: str, - icon: str = "", - enabled: bool = True, - ): - super().__init__(name, title, icon, enabled) - - @abstractmethod - def get_lines(self) -> List[str]: - """ - Get content lines for display. - - Returns: - List of strings to display (max 4-5 lines) - """ - pass - - def get_content(self) -> PageContent: - """Get page content as INFO type.""" - return PageContent( - page_type=PageType.INFO, - title=self.title, - lines=self.get_lines(), - icon=self.icon, - ) - - -class MenuPage(BasePage): - """ - Base class for menu pages. - - Menu pages display selectable items that can trigger actions. - """ - - def __init__( - self, - name: str, - title: str, - icon: str = "", - enabled: bool = True, - ): - super().__init__(name, title, icon, enabled) - self._menu_items: List[tuple[str, Callable[[], Optional[str]]]] = [] - - def add_item(self, label: str, action: Callable[[], Optional[str]]) -> None: - """ - Add menu item. - - Args: - label: Display label for the item - action: Callback function when item is selected - """ - self._menu_items.append((label, action)) - - def clear_items(self) -> None: - """Clear all menu items.""" - self._menu_items.clear() - self._selected_index = 0 - - def get_content(self) -> PageContent: - """Get page content as MENU type.""" - labels = [item[0] for item in self._menu_items] - return PageContent( - page_type=PageType.MENU, - title=self.title, - lines=[], - actions=labels, - selected=self._selected_index, - icon=self.icon, - ) - - def handle_select(self, index: int) -> Optional[str]: - """Execute action for selected menu item.""" - self._selected_index = index - if 0 <= index < len(self._menu_items): - _, action = self._menu_items[index] - return action() - return None - - -class ConfirmPage(BasePage): - """ - Confirmation dialog page. - - Used to confirm dangerous actions like shutdown or reboot. - """ - - def __init__( - self, - name: str, - title: str, - message: str, - on_confirm: Callable[[], Optional[str]], - on_cancel: Optional[Callable[[], Optional[str]]] = None, - icon: str = "", - ): - super().__init__(name, title, icon, enabled=True) - self.message = message - self._on_confirm = on_confirm - self._on_cancel = on_cancel - - def get_content(self) -> PageContent: - """Get page content as CONFIRM type.""" - return PageContent( - page_type=PageType.CONFIRM, - title=self.title, - lines=[self.message], - actions=["Yes", "No"], - selected=1, # Default to "No" for safety - icon=self.icon, - ) - - def handle_confirm(self) -> Optional[str]: - """Execute confirm action.""" - return self._on_confirm() - - def handle_cancel(self) -> Optional[str]: - """Execute cancel action.""" - if self._on_cancel: - return self._on_cancel() - return None diff --git a/obd2_client/src/flipper/pages/ups_status.py b/obd2_client/src/flipper/pages/ups_status.py deleted file mode 100644 index a879f74..0000000 --- a/obd2_client/src/flipper/pages/ups_status.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -UPS Status Page. - -Displays X120x UPS battery and power status on Flipper Zero. -""" - -from typing import List - -from .base import InfoPage -from ..providers.ups_provider import UPSProvider - - -class UPSStatusPage(InfoPage): - """ - Page displaying UPS (X120x) status. - - Shows: - - Battery percentage and voltage - - Charging status - - Power loss indicator - - Input voltage - - Visual battery bar for quick status check. - """ - - def __init__(self): - super().__init__( - name="ups_status", - title="UPS Status", - icon="battery", - ) - self._provider = UPSProvider() - - def get_lines(self) -> List[str]: - """Get UPS status lines for display.""" - if not self._provider.is_available(): - return [ - "UPS not available", - self._provider.get_last_error() or "Check I2C connection", - ] - - # Force refresh to get fresh data - self._provider.refresh() - data = self._provider.get_data() - - # Battery bar visualization [=====] - battery_bar = self._get_battery_bar(data.capacity) - - lines = [ - f"Bat: {data.capacity:.1f}% {battery_bar}", - f"Voltage: {data.voltage:.2f}V", - ] - - # Charging/power status - if data.power_loss: - lines.append("Power: BATTERY MODE") - if data.capacity < 15: - lines.append("!! CRITICAL !!") - elif data.capacity < 25: - lines.append("! LOW BATTERY !") - else: - status = "Charging" if data.is_charging else "Full" - lines.append(f"Status: {status}") - if data.input_voltage > 0: - lines.append(f"Input: {data.input_voltage:.2f}V") - - return lines - - def _get_battery_bar(self, percent: float) -> str: - """ - Create ASCII battery bar. - - Args: - percent: Battery percentage (0-100) - - Returns: - Battery visualization string like "[==== ]" - """ - filled = int(percent / 16.67) # 6 segments - empty = 6 - filled - bar = "[" + "=" * filled + " " * empty + "]" - return bar - - def is_enabled(self) -> bool: - """Check if page should be shown.""" - return self.enabled and self._provider.is_available() - - def get_provider(self) -> UPSProvider: - """Get the UPS provider instance.""" - return self._provider - - def on_enter(self) -> None: - """Refresh data when page is shown.""" - self._provider.refresh() diff --git a/obd2_client/src/main.py b/obd2_client/src/main.py index 77fedba..5f8dcf7 100644 --- a/obd2_client/src/main.py +++ b/obd2_client/src/main.py @@ -18,7 +18,7 @@ from .vehicle.poller import VehiclePoller from .flipper.server import FlipperServer from .flipper.pages import ActionID from .handlers import HandlerPipeline, StorageHandler, PostgreSQLHandler, OBD2Reading -from .flipper.pages.ups_status import UPSStatusPage +from .flipper.providers.ups_provider import UPSProvider class OBD2Client: @@ -138,12 +138,12 @@ class OBD2Client: # Add UPS status page if enabled if self.config.flipper.show_ups: try: - ups_page = UPSStatusPage() - if ups_page.is_enabled(): - pm.add_page(ups_page) - self._logger.info("UPS status page added") + ups_provider = UPSProvider() + if ups_provider.is_available(): + pm.set_data_provider("ups", lambda: ups_provider.get_data()) + self._logger.info("UPS provider registered") except Exception as e: - self._logger.debug(f"UPS page not available: {e}") + self._logger.debug(f"UPS not available: {e}") # Action handlers pm.set_action_handler(ActionID.RECONNECT_OBD, self._action_reconnect_obd)