fix PageManager

This commit is contained in:
2026-01-30 12:30:38 +03:00
parent dc7fd19022
commit 958279b1b7
5 changed files with 77 additions and 431 deletions

View File

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

View File

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

View File

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

View File

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

View File

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