fix PageManager
This commit is contained in:
@@ -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(
|
self._pages.append(PageDefinition(
|
||||||
page_type=PageType.CONFIRM,
|
page_type=PageType.CONFIRM,
|
||||||
title="Confirm",
|
title="Confirm",
|
||||||
@@ -110,15 +117,42 @@ class PageManager:
|
|||||||
"""
|
"""
|
||||||
self._data_providers[name] = provider
|
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.
|
"""Register an action handler.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
action_id: Action identifier
|
action_id: Action identifier (ActionID enum or string)
|
||||||
handler: Callable that executes action, returns True on success
|
handler: Callable that executes action, returns True on success
|
||||||
"""
|
"""
|
||||||
self._action_handlers[action_id] = handler
|
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:
|
def get_data(self, name: str, default: Any = None) -> Any:
|
||||||
"""Get data from a provider.
|
"""Get data from a provider.
|
||||||
|
|
||||||
@@ -435,6 +469,40 @@ class PageManager:
|
|||||||
]
|
]
|
||||||
return Page(PageType.MENU, "Actions", actions=actions)
|
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:
|
def _generate_confirm_page(self, mgr: "PageManager") -> Page:
|
||||||
"""Generate confirmation page."""
|
"""Generate confirmation page."""
|
||||||
if mgr._pending_action == ActionID.REBOOT_SYSTEM:
|
if mgr._pending_action == ActionID.REBOOT_SYSTEM:
|
||||||
|
|||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -18,7 +18,7 @@ from .vehicle.poller import VehiclePoller
|
|||||||
from .flipper.server import FlipperServer
|
from .flipper.server import FlipperServer
|
||||||
from .flipper.pages import ActionID
|
from .flipper.pages import ActionID
|
||||||
from .handlers import HandlerPipeline, StorageHandler, PostgreSQLHandler, OBD2Reading
|
from .handlers import HandlerPipeline, StorageHandler, PostgreSQLHandler, OBD2Reading
|
||||||
from .flipper.pages.ups_status import UPSStatusPage
|
from .flipper.providers.ups_provider import UPSProvider
|
||||||
|
|
||||||
|
|
||||||
class OBD2Client:
|
class OBD2Client:
|
||||||
@@ -138,12 +138,12 @@ class OBD2Client:
|
|||||||
# Add UPS status page if enabled
|
# Add UPS status page if enabled
|
||||||
if self.config.flipper.show_ups:
|
if self.config.flipper.show_ups:
|
||||||
try:
|
try:
|
||||||
ups_page = UPSStatusPage()
|
ups_provider = UPSProvider()
|
||||||
if ups_page.is_enabled():
|
if ups_provider.is_available():
|
||||||
pm.add_page(ups_page)
|
pm.set_data_provider("ups", lambda: ups_provider.get_data())
|
||||||
self._logger.info("UPS status page added")
|
self._logger.info("UPS provider registered")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._logger.debug(f"UPS page not available: {e}")
|
self._logger.debug(f"UPS not available: {e}")
|
||||||
|
|
||||||
# Action handlers
|
# Action handlers
|
||||||
pm.set_action_handler(ActionID.RECONNECT_OBD, self._action_reconnect_obd)
|
pm.set_action_handler(ActionID.RECONNECT_OBD, self._action_reconnect_obd)
|
||||||
|
|||||||
Reference in New Issue
Block a user