Create new module for flipper and update UI flipper zero
This commit is contained in:
11
can_sniffer/src/flipper/__init__.py
Normal file
11
can_sniffer/src/flipper/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""
|
||||||
|
Flipper Zero Dynamic UI System.
|
||||||
|
|
||||||
|
This module provides a plugin-based page system for Flipper Zero display
|
||||||
|
with bidirectional communication via UART.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flipper.protocol import PageType, PageContent, Protocol
|
||||||
|
from flipper.page_manager import PageManager
|
||||||
|
|
||||||
|
__all__ = ["PageType", "PageContent", "Protocol", "PageManager"]
|
||||||
255
can_sniffer/src/flipper/page_manager.py
Normal file
255
can_sniffer/src/flipper/page_manager.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
"""
|
||||||
|
Page Manager for Flipper Zero UI.
|
||||||
|
|
||||||
|
Manages page navigation, state, and content generation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
|
||||||
|
from flipper.protocol import Protocol, PageContent, Command, CommandType
|
||||||
|
from flipper.pages.base import BasePage
|
||||||
|
|
||||||
|
|
||||||
|
class PageManager:
|
||||||
|
"""
|
||||||
|
Manages Flipper Zero pages and navigation.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Page registration and lifecycle
|
||||||
|
- Navigation between pages
|
||||||
|
- Command processing
|
||||||
|
- Content generation
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize page manager."""
|
||||||
|
self._pages: List[BasePage] = []
|
||||||
|
self._current_index = 0
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._last_result: Optional[str] = None
|
||||||
|
|
||||||
|
def register_page(self, page: BasePage) -> None:
|
||||||
|
"""
|
||||||
|
Register a page.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: Page instance to register
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
# Check for duplicate names
|
||||||
|
for existing in self._pages:
|
||||||
|
if existing.name == page.name:
|
||||||
|
raise ValueError(f"Page '{page.name}' already registered")
|
||||||
|
|
||||||
|
self._pages.append(page)
|
||||||
|
|
||||||
|
def unregister_page(self, name: str) -> bool:
|
||||||
|
"""
|
||||||
|
Unregister a page by name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Page name to remove
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if page was found and removed
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
for i, page in enumerate(self._pages):
|
||||||
|
if page.name == name:
|
||||||
|
self._pages.pop(i)
|
||||||
|
# Adjust current index if needed
|
||||||
|
if self._current_index >= len(self._pages):
|
||||||
|
self._current_index = max(0, len(self._pages) - 1)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_enabled_pages(self) -> List[BasePage]:
|
||||||
|
"""Get list of enabled pages."""
|
||||||
|
with self._lock:
|
||||||
|
return [p for p in self._pages if p.is_enabled()]
|
||||||
|
|
||||||
|
def get_page_count(self) -> int:
|
||||||
|
"""Get number of enabled pages."""
|
||||||
|
return len(self.get_enabled_pages())
|
||||||
|
|
||||||
|
def get_current_page(self) -> Optional[BasePage]:
|
||||||
|
"""Get currently active page."""
|
||||||
|
pages = self.get_enabled_pages()
|
||||||
|
with self._lock:
|
||||||
|
if 0 <= self._current_index < len(pages):
|
||||||
|
return pages[self._current_index]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_current_index(self) -> int:
|
||||||
|
"""Get current page index."""
|
||||||
|
with self._lock:
|
||||||
|
return self._current_index
|
||||||
|
|
||||||
|
def navigate_next(self) -> bool:
|
||||||
|
"""
|
||||||
|
Navigate to next page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if navigation occurred
|
||||||
|
"""
|
||||||
|
pages = self.get_enabled_pages()
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
# Don't navigate if current page has pending action
|
||||||
|
if self._current_index < len(pages):
|
||||||
|
current = pages[self._current_index]
|
||||||
|
if current.has_pending_action():
|
||||||
|
return False
|
||||||
|
|
||||||
|
current.on_leave()
|
||||||
|
|
||||||
|
if self._current_index < len(pages) - 1:
|
||||||
|
self._current_index += 1
|
||||||
|
pages[self._current_index].on_enter()
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def navigate_prev(self) -> bool:
|
||||||
|
"""
|
||||||
|
Navigate to previous page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if navigation occurred
|
||||||
|
"""
|
||||||
|
pages = self.get_enabled_pages()
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
# Don't navigate if current page has pending action
|
||||||
|
if self._current_index < len(pages):
|
||||||
|
current = pages[self._current_index]
|
||||||
|
if current.has_pending_action():
|
||||||
|
return False
|
||||||
|
|
||||||
|
current.on_leave()
|
||||||
|
|
||||||
|
if self._current_index > 0:
|
||||||
|
self._current_index -= 1
|
||||||
|
pages[self._current_index].on_enter()
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def navigate_to(self, index: int) -> bool:
|
||||||
|
"""
|
||||||
|
Navigate to specific page index.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
index: Page index to navigate to
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if navigation occurred
|
||||||
|
"""
|
||||||
|
pages = self.get_enabled_pages()
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
if 0 <= index < len(pages):
|
||||||
|
if self._current_index < len(pages):
|
||||||
|
pages[self._current_index].on_leave()
|
||||||
|
|
||||||
|
self._current_index = index
|
||||||
|
pages[self._current_index].on_enter()
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_current_content(self) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get encoded content for current page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Protocol-encoded page content string or None
|
||||||
|
"""
|
||||||
|
page = self.get_current_page()
|
||||||
|
if page is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
content = page.get_content()
|
||||||
|
total = self.get_page_count()
|
||||||
|
index = self.get_current_index()
|
||||||
|
|
||||||
|
return Protocol.encode_page(index, total, content)
|
||||||
|
|
||||||
|
def process_command(self, command: Command) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Process command from Flipper Zero.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: Parsed command
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result message or None
|
||||||
|
"""
|
||||||
|
self._last_result = None
|
||||||
|
|
||||||
|
if command.cmd_type == CommandType.NAV_NEXT:
|
||||||
|
self.navigate_next()
|
||||||
|
return None
|
||||||
|
|
||||||
|
if command.cmd_type == CommandType.NAV_PREV:
|
||||||
|
self.navigate_prev()
|
||||||
|
return None
|
||||||
|
|
||||||
|
if command.cmd_type == CommandType.SELECT:
|
||||||
|
page = self.get_current_page()
|
||||||
|
if page:
|
||||||
|
result = page.handle_select(command.select_index)
|
||||||
|
self._last_result = result
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
|
if command.cmd_type == CommandType.CONFIRM:
|
||||||
|
page = self.get_current_page()
|
||||||
|
if page:
|
||||||
|
result = page.handle_confirm()
|
||||||
|
self._last_result = result
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
|
if command.cmd_type == CommandType.CANCEL:
|
||||||
|
page = self.get_current_page()
|
||||||
|
if page:
|
||||||
|
result = page.handle_cancel()
|
||||||
|
self._last_result = result
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
|
if command.cmd_type == CommandType.REFRESH:
|
||||||
|
# Just return None, content will be sent anyway
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_last_result(self) -> Optional[str]:
|
||||||
|
"""Get last action result message."""
|
||||||
|
return self._last_result
|
||||||
|
|
||||||
|
def clear_last_result(self) -> None:
|
||||||
|
"""Clear last result message."""
|
||||||
|
self._last_result = None
|
||||||
|
|
||||||
|
def get_stats(self) -> Dict[str, Any]:
|
||||||
|
"""Get manager statistics."""
|
||||||
|
pages = self.get_enabled_pages()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_pages": len(self._pages),
|
||||||
|
"enabled_pages": len(pages),
|
||||||
|
"current_index": self._current_index,
|
||||||
|
"current_page": pages[self._current_index].name if pages else None,
|
||||||
|
"pages": [p.name for p in pages]
|
||||||
|
}
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""Shutdown all pages."""
|
||||||
|
with self._lock:
|
||||||
|
for page in self._pages:
|
||||||
|
page.on_leave()
|
||||||
|
self._pages.clear()
|
||||||
|
self._current_index = 0
|
||||||
20
can_sniffer/src/flipper/pages/__init__.py
Normal file
20
can_sniffer/src/flipper/pages/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""
|
||||||
|
Flipper Zero Pages.
|
||||||
|
|
||||||
|
Each page is a plugin that provides content for display on Flipper Zero
|
||||||
|
and handles user actions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flipper.pages.base import BasePage
|
||||||
|
from flipper.pages.can_stats import CANStatsPage
|
||||||
|
from flipper.pages.ups_status import UPSStatusPage
|
||||||
|
from flipper.pages.system_info import SystemInfoPage
|
||||||
|
from flipper.pages.actions import ActionsPage
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BasePage",
|
||||||
|
"CANStatsPage",
|
||||||
|
"UPSStatusPage",
|
||||||
|
"SystemInfoPage",
|
||||||
|
"ActionsPage",
|
||||||
|
]
|
||||||
163
can_sniffer/src/flipper/pages/actions.py
Normal file
163
can_sniffer/src/flipper/pages/actions.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"""
|
||||||
|
Actions Page.
|
||||||
|
|
||||||
|
Menu page for system control actions (shutdown, reboot, etc.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, Callable
|
||||||
|
|
||||||
|
from flipper.pages.base import BasePage
|
||||||
|
from flipper.protocol import PageContent, PageType
|
||||||
|
from flipper.providers.system_provider import SystemProvider
|
||||||
|
|
||||||
|
|
||||||
|
class ActionsPage(BasePage):
|
||||||
|
"""
|
||||||
|
Page with system control actions.
|
||||||
|
|
||||||
|
Provides menu items for:
|
||||||
|
- Shutdown
|
||||||
|
- Reboot
|
||||||
|
- Cancel pending shutdown
|
||||||
|
|
||||||
|
Dangerous actions require confirmation before execution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Action identifiers
|
||||||
|
ACTION_SHUTDOWN = "shutdown"
|
||||||
|
ACTION_REBOOT = "reboot"
|
||||||
|
ACTION_CANCEL = "cancel_shutdown"
|
||||||
|
|
||||||
|
def __init__(self, on_result: Optional[Callable[[str], None]] = None):
|
||||||
|
"""
|
||||||
|
Initialize actions page.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
on_result: Callback when action result is available
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name="actions",
|
||||||
|
title="Actions",
|
||||||
|
icon="settings"
|
||||||
|
)
|
||||||
|
self._provider = SystemProvider()
|
||||||
|
self._on_result = on_result
|
||||||
|
self._confirm_mode = False
|
||||||
|
self._pending_action: Optional[str] = None
|
||||||
|
|
||||||
|
# Menu items: (label, action_id, requires_confirm)
|
||||||
|
self._menu_items = [
|
||||||
|
("Shutdown", self.ACTION_SHUTDOWN, True),
|
||||||
|
("Reboot", self.ACTION_REBOOT, True),
|
||||||
|
("Cancel Shutdown", self.ACTION_CANCEL, False),
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_content(self) -> PageContent:
|
||||||
|
"""Get page content."""
|
||||||
|
if self._confirm_mode and self._pending_action:
|
||||||
|
# Show confirmation dialog
|
||||||
|
action_name = self._get_action_label(self._pending_action)
|
||||||
|
return PageContent(
|
||||||
|
page_type=PageType.CONFIRM,
|
||||||
|
title=f"Confirm {action_name}?",
|
||||||
|
lines=[f"Are you sure you want to {action_name.lower()}?"],
|
||||||
|
actions=["Yes", "No"],
|
||||||
|
selected=1, # Default to "No" for safety
|
||||||
|
icon=self.icon
|
||||||
|
)
|
||||||
|
|
||||||
|
# Normal menu mode
|
||||||
|
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]:
|
||||||
|
"""Handle menu item selection."""
|
||||||
|
self._selected_index = index
|
||||||
|
|
||||||
|
if 0 <= index < len(self._menu_items):
|
||||||
|
label, action_id, requires_confirm = self._menu_items[index]
|
||||||
|
|
||||||
|
if requires_confirm:
|
||||||
|
# Enter confirmation mode
|
||||||
|
self._confirm_mode = True
|
||||||
|
self._pending_action = action_id
|
||||||
|
return None # Will show confirm dialog
|
||||||
|
else:
|
||||||
|
# Execute immediately
|
||||||
|
return self._execute_action(action_id)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def handle_confirm(self) -> Optional[str]:
|
||||||
|
"""Execute confirmed action."""
|
||||||
|
if self._pending_action:
|
||||||
|
action_id = self._pending_action
|
||||||
|
self._confirm_mode = False
|
||||||
|
self._pending_action = None
|
||||||
|
return self._execute_action(action_id)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def handle_cancel(self) -> Optional[str]:
|
||||||
|
"""Cancel pending action."""
|
||||||
|
self._confirm_mode = False
|
||||||
|
self._pending_action = None
|
||||||
|
return "Cancelled"
|
||||||
|
|
||||||
|
def has_pending_action(self) -> bool:
|
||||||
|
"""Check if in confirmation mode."""
|
||||||
|
return self._confirm_mode
|
||||||
|
|
||||||
|
def _execute_action(self, action_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Execute the specified action.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
action_id: Action identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result message
|
||||||
|
"""
|
||||||
|
result = ""
|
||||||
|
|
||||||
|
if action_id == self.ACTION_SHUTDOWN:
|
||||||
|
if self._provider.shutdown(delay_minutes=1):
|
||||||
|
result = "Shutdown in 1 min"
|
||||||
|
else:
|
||||||
|
result = "Shutdown failed"
|
||||||
|
|
||||||
|
elif action_id == self.ACTION_REBOOT:
|
||||||
|
if self._provider.reboot():
|
||||||
|
result = "Rebooting..."
|
||||||
|
else:
|
||||||
|
result = "Reboot failed"
|
||||||
|
|
||||||
|
elif action_id == self.ACTION_CANCEL:
|
||||||
|
if self._provider.cancel_shutdown():
|
||||||
|
result = "Shutdown cancelled"
|
||||||
|
else:
|
||||||
|
result = "No pending shutdown"
|
||||||
|
|
||||||
|
# Notify callback if available
|
||||||
|
if self._on_result:
|
||||||
|
self._on_result(result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _get_action_label(self, action_id: str) -> str:
|
||||||
|
"""Get human-readable label for action ID."""
|
||||||
|
for label, aid, _ in self._menu_items:
|
||||||
|
if aid == action_id:
|
||||||
|
return label
|
||||||
|
return action_id
|
||||||
|
|
||||||
|
def get_provider(self) -> SystemProvider:
|
||||||
|
"""Get the System provider instance."""
|
||||||
|
return self._provider
|
||||||
260
can_sniffer/src/flipper/pages/base.py
Normal file
260
can_sniffer/src/flipper/pages/base.py
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
"""
|
||||||
|
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, Dict, Any, Callable
|
||||||
|
|
||||||
|
from flipper.protocol import PageContent, PageType
|
||||||
|
|
||||||
|
|
||||||
|
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._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 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 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
|
||||||
66
can_sniffer/src/flipper/pages/can_stats.py
Normal file
66
can_sniffer/src/flipper/pages/can_stats.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""
|
||||||
|
CAN Statistics Page.
|
||||||
|
|
||||||
|
Displays CAN sniffer statistics on Flipper Zero.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flipper.pages.base import InfoPage
|
||||||
|
from flipper.providers.can_provider import CANProvider
|
||||||
|
|
||||||
|
|
||||||
|
class CANStatsPage(InfoPage):
|
||||||
|
"""
|
||||||
|
Page displaying CAN bus statistics.
|
||||||
|
|
||||||
|
Shows:
|
||||||
|
- Total frames received
|
||||||
|
- Pending/processed frames
|
||||||
|
- Queue status
|
||||||
|
- Dropped frames (if any)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="can_stats",
|
||||||
|
title="CAN Statistics",
|
||||||
|
icon="can"
|
||||||
|
)
|
||||||
|
self._provider = CANProvider()
|
||||||
|
|
||||||
|
def get_lines(self) -> list[str]:
|
||||||
|
"""Get statistics lines for display."""
|
||||||
|
data = self._provider.get_data()
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"Total: {self._format_number(data.total_frames)}",
|
||||||
|
f"Processed: {self._format_number(data.processed_frames)}",
|
||||||
|
f"Queue: {data.queue_size}/{data.queue_capacity}",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add interface breakdown if available
|
||||||
|
if data.interfaces:
|
||||||
|
iface_str = ", ".join(
|
||||||
|
f"{k}: {self._format_number(v)}"
|
||||||
|
for k, v in data.interfaces.items()
|
||||||
|
)
|
||||||
|
if len(iface_str) <= 25:
|
||||||
|
lines.append(iface_str)
|
||||||
|
|
||||||
|
# Show dropped count if non-zero
|
||||||
|
if data.dropped_frames > 0:
|
||||||
|
lines.append(f"Dropped: {data.dropped_frames}")
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _format_number(self, num: int) -> str:
|
||||||
|
"""Format large numbers with K/M suffix."""
|
||||||
|
if num >= 1_000_000:
|
||||||
|
return f"{num / 1_000_000:.1f}M"
|
||||||
|
elif num >= 1_000:
|
||||||
|
return f"{num / 1_000:.1f}K"
|
||||||
|
else:
|
||||||
|
return str(num)
|
||||||
|
|
||||||
|
def get_provider(self) -> CANProvider:
|
||||||
|
"""Get the CAN provider instance."""
|
||||||
|
return self._provider
|
||||||
72
can_sniffer/src/flipper/pages/system_info.py
Normal file
72
can_sniffer/src/flipper/pages/system_info.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""
|
||||||
|
System Information Page.
|
||||||
|
|
||||||
|
Displays Raspberry Pi system metrics on Flipper Zero.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flipper.pages.base import InfoPage
|
||||||
|
from flipper.providers.system_provider import SystemProvider
|
||||||
|
|
||||||
|
|
||||||
|
class SystemInfoPage(InfoPage):
|
||||||
|
"""
|
||||||
|
Page displaying Raspberry Pi system metrics.
|
||||||
|
|
||||||
|
Shows:
|
||||||
|
- CPU temperature
|
||||||
|
- Power consumption
|
||||||
|
- Fan RPM
|
||||||
|
- Input voltage
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="system_info",
|
||||||
|
title="System Info",
|
||||||
|
icon="cpu"
|
||||||
|
)
|
||||||
|
self._provider = SystemProvider()
|
||||||
|
|
||||||
|
def get_lines(self) -> list[str]:
|
||||||
|
"""Get system info lines for display."""
|
||||||
|
data = self._provider.get_data()
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"CPU Temp: {data.cpu_temp:.1f}C {self._get_temp_indicator(data.cpu_temp)}",
|
||||||
|
f"Power: {data.power_watts:.2f}W",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Fan RPM (may not be available on all systems)
|
||||||
|
if data.fan_rpm > 0:
|
||||||
|
lines.append(f"Fan: {data.fan_rpm} RPM")
|
||||||
|
else:
|
||||||
|
lines.append("Fan: N/A")
|
||||||
|
|
||||||
|
# Input voltage
|
||||||
|
if data.input_voltage > 0:
|
||||||
|
lines.append(f"Input: {data.input_voltage:.2f}V")
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _get_temp_indicator(self, temp: float) -> str:
|
||||||
|
"""
|
||||||
|
Get temperature status indicator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
temp: Temperature in Celsius
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Status indicator string
|
||||||
|
"""
|
||||||
|
if temp >= 80:
|
||||||
|
return "(!)" # Critical
|
||||||
|
elif temp >= 70:
|
||||||
|
return "(W)" # Warning
|
||||||
|
elif temp >= 60:
|
||||||
|
return "(~)" # Warm
|
||||||
|
else:
|
||||||
|
return "" # OK
|
||||||
|
|
||||||
|
def get_provider(self) -> SystemProvider:
|
||||||
|
"""Get the System provider instance."""
|
||||||
|
return self._provider
|
||||||
85
can_sniffer/src/flipper/pages/ups_status.py
Normal file
85
can_sniffer/src/flipper/pages/ups_status.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
UPS Status Page.
|
||||||
|
|
||||||
|
Displays X120x UPS battery and power status on Flipper Zero.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flipper.pages.base import InfoPage
|
||||||
|
from flipper.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
|
||||||
|
"""
|
||||||
|
|
||||||
|
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"
|
||||||
|
]
|
||||||
|
|
||||||
|
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("WARNING: Critical!")
|
||||||
|
elif data.capacity < 25:
|
||||||
|
lines.append("WARNING: Low battery")
|
||||||
|
else:
|
||||||
|
status = "Charging" if data.is_charging else "Full"
|
||||||
|
lines.append(f"Status: {status}")
|
||||||
|
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 / 20) # 5 segments
|
||||||
|
empty = 5 - 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
|
||||||
194
can_sniffer/src/flipper/protocol.py
Normal file
194
can_sniffer/src/flipper/protocol.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
"""
|
||||||
|
Flipper Zero Communication Protocol.
|
||||||
|
|
||||||
|
Defines message formats for RPi <-> Flipper Zero UART communication.
|
||||||
|
|
||||||
|
Protocol Messages:
|
||||||
|
RPi -> Flipper:
|
||||||
|
PAGE:<idx>/<total>|<type>|<title>|<lines>|<actions>|<selected>
|
||||||
|
ACK:<device>,ip=<ip>
|
||||||
|
RESULT:<status>|<message>
|
||||||
|
|
||||||
|
Flipper -> RPi:
|
||||||
|
INIT:<device>
|
||||||
|
STOP:<device>
|
||||||
|
CMD:NAV:<next|prev>
|
||||||
|
CMD:SELECT:<index>
|
||||||
|
CMD:CONFIRM
|
||||||
|
CMD:CANCEL
|
||||||
|
CMD:REFRESH
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class PageType(Enum):
|
||||||
|
"""Types of pages for different UI layouts."""
|
||||||
|
|
||||||
|
INFO = "info" # Read-only information display
|
||||||
|
MENU = "menu" # Selectable menu items
|
||||||
|
CONFIRM = "confirm" # Confirmation dialog (Yes/No)
|
||||||
|
|
||||||
|
|
||||||
|
class CommandType(Enum):
|
||||||
|
"""Types of commands from Flipper to RPi."""
|
||||||
|
|
||||||
|
INIT = "INIT"
|
||||||
|
STOP = "STOP"
|
||||||
|
NAV_NEXT = "NAV:next"
|
||||||
|
NAV_PREV = "NAV:prev"
|
||||||
|
SELECT = "SELECT"
|
||||||
|
CONFIRM = "CONFIRM"
|
||||||
|
CANCEL = "CANCEL"
|
||||||
|
REFRESH = "REFRESH"
|
||||||
|
|
||||||
|
|
||||||
|
@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] + "..."
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Command:
|
||||||
|
"""Parsed command from Flipper Zero."""
|
||||||
|
|
||||||
|
cmd_type: CommandType
|
||||||
|
payload: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def select_index(self) -> int:
|
||||||
|
"""Get selected index for SELECT command."""
|
||||||
|
if self.cmd_type == CommandType.SELECT:
|
||||||
|
try:
|
||||||
|
return int(self.payload)
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
class Protocol:
|
||||||
|
"""
|
||||||
|
Protocol encoder/decoder for Flipper Zero communication.
|
||||||
|
|
||||||
|
Message format uses pipe (|) as field separator and semicolon (;)
|
||||||
|
as item separator within fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
FIELD_SEP = "|"
|
||||||
|
ITEM_SEP = ";"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def encode_page(
|
||||||
|
page_index: int,
|
||||||
|
total_pages: int,
|
||||||
|
content: PageContent
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Encode page content to protocol message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page_index: Current page index (0-based)
|
||||||
|
total_pages: Total number of pages
|
||||||
|
content: Page content to encode
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Encoded message string with newline
|
||||||
|
"""
|
||||||
|
# PAGE:<idx>/<total>|<type>|<title>|<lines>|<actions>|<selected>
|
||||||
|
lines_str = Protocol.ITEM_SEP.join(content.lines)
|
||||||
|
actions_str = Protocol.ITEM_SEP.join(content.actions)
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
f"PAGE:{page_index}/{total_pages}",
|
||||||
|
content.page_type.value,
|
||||||
|
content.title,
|
||||||
|
lines_str,
|
||||||
|
actions_str,
|
||||||
|
str(content.selected)
|
||||||
|
]
|
||||||
|
|
||||||
|
return Protocol.FIELD_SEP.join(parts) + "\n"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def encode_ack(device: str, ip: str) -> str:
|
||||||
|
"""Encode ACK message."""
|
||||||
|
return f"ACK:{device},ip={ip}\n"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def encode_result(success: bool, message: str) -> str:
|
||||||
|
"""Encode result message after action execution."""
|
||||||
|
status = "OK" if success else "ERROR"
|
||||||
|
return f"RESULT:{status}|{message}\n"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def decode_command(line: str) -> Optional[Command]:
|
||||||
|
"""
|
||||||
|
Decode command from Flipper Zero.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
line: Raw command line (without newline)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed Command object or None if invalid
|
||||||
|
"""
|
||||||
|
line = line.strip()
|
||||||
|
|
||||||
|
if not line:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# INIT:<device>
|
||||||
|
if line.startswith("INIT:"):
|
||||||
|
return Command(CommandType.INIT, line[5:])
|
||||||
|
|
||||||
|
# STOP:<device>
|
||||||
|
if line.startswith("STOP:"):
|
||||||
|
return Command(CommandType.STOP, line[5:])
|
||||||
|
|
||||||
|
# CMD:NAV:next / CMD:NAV:prev
|
||||||
|
if line == "CMD:NAV:next":
|
||||||
|
return Command(CommandType.NAV_NEXT)
|
||||||
|
|
||||||
|
if line == "CMD:NAV:prev":
|
||||||
|
return Command(CommandType.NAV_PREV)
|
||||||
|
|
||||||
|
# CMD:SELECT:<index>
|
||||||
|
if line.startswith("CMD:SELECT:"):
|
||||||
|
return Command(CommandType.SELECT, line[11:])
|
||||||
|
|
||||||
|
# CMD:CONFIRM
|
||||||
|
if line == "CMD:CONFIRM":
|
||||||
|
return Command(CommandType.CONFIRM)
|
||||||
|
|
||||||
|
# CMD:CANCEL
|
||||||
|
if line == "CMD:CANCEL":
|
||||||
|
return Command(CommandType.CANCEL)
|
||||||
|
|
||||||
|
# CMD:REFRESH
|
||||||
|
if line == "CMD:REFRESH":
|
||||||
|
return Command(CommandType.REFRESH)
|
||||||
|
|
||||||
|
return None
|
||||||
18
can_sniffer/src/flipper/providers/__init__.py
Normal file
18
can_sniffer/src/flipper/providers/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""
|
||||||
|
Data Providers for Flipper Zero Pages.
|
||||||
|
|
||||||
|
Providers abstract data sources (UPS, system metrics, CAN stats)
|
||||||
|
from the pages that display them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flipper.providers.base import BaseProvider
|
||||||
|
from flipper.providers.ups_provider import UPSProvider
|
||||||
|
from flipper.providers.system_provider import SystemProvider
|
||||||
|
from flipper.providers.can_provider import CANProvider
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BaseProvider",
|
||||||
|
"UPSProvider",
|
||||||
|
"SystemProvider",
|
||||||
|
"CANProvider",
|
||||||
|
]
|
||||||
119
can_sniffer/src/flipper/providers/base.py
Normal file
119
can_sniffer/src/flipper/providers/base.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""
|
||||||
|
Base Provider Interface.
|
||||||
|
|
||||||
|
Providers are singleton data sources that pages can query.
|
||||||
|
They handle caching and thread-safe data access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class BaseProvider(ABC):
|
||||||
|
"""
|
||||||
|
Abstract base class for data providers.
|
||||||
|
|
||||||
|
Providers:
|
||||||
|
- Are singletons (one instance per type)
|
||||||
|
- Cache data with configurable TTL
|
||||||
|
- Are thread-safe
|
||||||
|
- Handle errors gracefully
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: Provider identifier
|
||||||
|
cache_ttl: Cache time-to-live in seconds
|
||||||
|
"""
|
||||||
|
|
||||||
|
_instances: Dict[str, "BaseProvider"] = {}
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
def __new__(cls, *args, **kwargs):
|
||||||
|
"""Singleton pattern implementation."""
|
||||||
|
with cls._lock:
|
||||||
|
if cls.__name__ not in cls._instances:
|
||||||
|
instance = super().__new__(cls)
|
||||||
|
cls._instances[cls.__name__] = instance
|
||||||
|
return cls._instances[cls.__name__]
|
||||||
|
|
||||||
|
def __init__(self, name: str, cache_ttl: float = 1.0):
|
||||||
|
"""
|
||||||
|
Initialize provider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Provider identifier
|
||||||
|
cache_ttl: Cache TTL in seconds
|
||||||
|
"""
|
||||||
|
# Prevent re-initialization for singleton
|
||||||
|
if hasattr(self, "_initialized") and self._initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.name = name
|
||||||
|
self.cache_ttl = cache_ttl
|
||||||
|
self._cache: Dict[str, Any] = {}
|
||||||
|
self._cache_timestamps: Dict[str, float] = {}
|
||||||
|
self._data_lock = threading.Lock()
|
||||||
|
self._initialized = True
|
||||||
|
self._available = True
|
||||||
|
self._last_error: Optional[str] = None
|
||||||
|
|
||||||
|
def _get_cached(self, key: str) -> Optional[Any]:
|
||||||
|
"""
|
||||||
|
Get cached value if not expired.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Cache key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cached value or None if expired/missing
|
||||||
|
"""
|
||||||
|
with self._data_lock:
|
||||||
|
if key not in self._cache:
|
||||||
|
return None
|
||||||
|
|
||||||
|
timestamp = self._cache_timestamps.get(key, 0)
|
||||||
|
if time.time() - timestamp > self.cache_ttl:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._cache[key]
|
||||||
|
|
||||||
|
def _set_cached(self, key: str, value: Any) -> None:
|
||||||
|
"""
|
||||||
|
Set cached value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Cache key
|
||||||
|
value: Value to cache
|
||||||
|
"""
|
||||||
|
with self._data_lock:
|
||||||
|
self._cache[key] = value
|
||||||
|
self._cache_timestamps[key] = time.time()
|
||||||
|
|
||||||
|
def _clear_cache(self) -> None:
|
||||||
|
"""Clear all cached values."""
|
||||||
|
with self._data_lock:
|
||||||
|
self._cache.clear()
|
||||||
|
self._cache_timestamps.clear()
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def refresh(self) -> bool:
|
||||||
|
"""
|
||||||
|
Refresh provider data from source.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if refresh successful
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""Check if provider is available."""
|
||||||
|
return self._available
|
||||||
|
|
||||||
|
def get_last_error(self) -> Optional[str]:
|
||||||
|
"""Get last error message."""
|
||||||
|
return self._last_error
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""Cleanup provider resources."""
|
||||||
|
self._clear_cache()
|
||||||
135
can_sniffer/src/flipper/providers/can_provider.py
Normal file
135
can_sniffer/src/flipper/providers/can_provider.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"""
|
||||||
|
CAN Statistics Provider.
|
||||||
|
|
||||||
|
Provides CAN sniffer statistics from the message processor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Any, Optional, Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from flipper.providers.base import BaseProvider
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CANData:
|
||||||
|
"""CAN statistics data."""
|
||||||
|
|
||||||
|
total_frames: int = 0
|
||||||
|
pending_frames: int = 0
|
||||||
|
processed_frames: int = 0
|
||||||
|
dropped_frames: int = 0
|
||||||
|
queue_size: int = 0
|
||||||
|
queue_capacity: int = 100000
|
||||||
|
interfaces: Dict[str, int] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class CANProvider(BaseProvider):
|
||||||
|
"""
|
||||||
|
Provider for CAN sniffer statistics.
|
||||||
|
|
||||||
|
This provider is updated by the FlipperHandler when
|
||||||
|
CAN frames are processed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(name="can", cache_ttl=0.5)
|
||||||
|
self._data = CANData()
|
||||||
|
self._stats_callback: Optional[Callable[[], Dict[str, Any]]] = None
|
||||||
|
|
||||||
|
def set_stats_callback(self, callback: Callable[[], Dict[str, Any]]) -> None:
|
||||||
|
"""
|
||||||
|
Set callback to retrieve stats from message processor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Function that returns stats dictionary
|
||||||
|
"""
|
||||||
|
self._stats_callback = callback
|
||||||
|
|
||||||
|
def update_stats(
|
||||||
|
self,
|
||||||
|
total: int = 0,
|
||||||
|
pending: int = 0,
|
||||||
|
processed: int = 0,
|
||||||
|
dropped: int = 0,
|
||||||
|
queue_size: int = 0
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Update CAN statistics directly.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
total: Total frames received
|
||||||
|
pending: Pending frames in queue
|
||||||
|
processed: Processed frames
|
||||||
|
dropped: Dropped frames
|
||||||
|
queue_size: Current queue size
|
||||||
|
"""
|
||||||
|
self._data.total_frames = total
|
||||||
|
self._data.pending_frames = pending
|
||||||
|
self._data.processed_frames = processed
|
||||||
|
self._data.dropped_frames = dropped
|
||||||
|
self._data.queue_size = queue_size
|
||||||
|
self._set_cached("data", self._data)
|
||||||
|
|
||||||
|
def update_interface_stats(self, interface: str, count: int) -> None:
|
||||||
|
"""
|
||||||
|
Update per-interface statistics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
interface: Interface name (e.g., "can0")
|
||||||
|
count: Message count for this interface
|
||||||
|
"""
|
||||||
|
self._data.interfaces[interface] = count
|
||||||
|
|
||||||
|
def refresh(self) -> bool:
|
||||||
|
"""Refresh stats from callback if available."""
|
||||||
|
if self._stats_callback:
|
||||||
|
try:
|
||||||
|
stats = self._stats_callback()
|
||||||
|
self._data.total_frames = stats.get("total_frames", 0)
|
||||||
|
self._data.pending_frames = stats.get("pending_frames", 0)
|
||||||
|
self._data.processed_frames = stats.get("processed_frames", 0)
|
||||||
|
self._data.dropped_frames = stats.get("dropped_count", 0)
|
||||||
|
self._data.queue_size = stats.get("queue_size", 0)
|
||||||
|
self._set_cached("data", self._data)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self._last_error = str(e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_data(self) -> CANData:
|
||||||
|
"""Get current CAN statistics."""
|
||||||
|
cached = self._get_cached("data")
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
self.refresh()
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
def get_total_frames(self) -> int:
|
||||||
|
"""Get total frames received."""
|
||||||
|
return self.get_data().total_frames
|
||||||
|
|
||||||
|
def get_pending_frames(self) -> int:
|
||||||
|
"""Get pending frames in queue."""
|
||||||
|
return self.get_data().pending_frames
|
||||||
|
|
||||||
|
def get_processed_frames(self) -> int:
|
||||||
|
"""Get processed frames."""
|
||||||
|
return self.get_data().processed_frames
|
||||||
|
|
||||||
|
def get_dropped_frames(self) -> int:
|
||||||
|
"""Get dropped frames."""
|
||||||
|
return self.get_data().dropped_frames
|
||||||
|
|
||||||
|
def get_queue_fill_percent(self) -> float:
|
||||||
|
"""Get queue fill percentage."""
|
||||||
|
data = self.get_data()
|
||||||
|
if data.queue_capacity == 0:
|
||||||
|
return 0.0
|
||||||
|
return (data.queue_size / data.queue_capacity) * 100
|
||||||
|
|
||||||
|
def get_interface_count(self, interface: str) -> int:
|
||||||
|
"""Get message count for specific interface."""
|
||||||
|
return self.get_data().interfaces.get(interface, 0)
|
||||||
240
can_sniffer/src/flipper/providers/system_provider.py
Normal file
240
can_sniffer/src/flipper/providers/system_provider.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
"""
|
||||||
|
System Provider for Raspberry Pi metrics and control.
|
||||||
|
|
||||||
|
Provides CPU temperature, power consumption, fan RPM,
|
||||||
|
and system control actions (shutdown, reboot).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flipper.providers.base import BaseProvider
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SystemData:
|
||||||
|
"""System metrics data."""
|
||||||
|
|
||||||
|
cpu_temp: float = 0.0
|
||||||
|
cpu_volts: float = 0.0
|
||||||
|
cpu_amps: float = 0.0
|
||||||
|
power_watts: float = 0.0
|
||||||
|
fan_rpm: int = 0
|
||||||
|
input_voltage: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class SystemProvider(BaseProvider):
|
||||||
|
"""
|
||||||
|
Provider for Raspberry Pi system metrics.
|
||||||
|
|
||||||
|
Uses vcgencmd for hardware metrics and sysfs for fan speed.
|
||||||
|
Also provides system control actions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(name="system", cache_ttl=2.0)
|
||||||
|
self._data = SystemData()
|
||||||
|
|
||||||
|
def _read_metric(self, args: list, strip_chars: str) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Read a hardware metric using vcgencmd.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: Command arguments
|
||||||
|
strip_chars: Characters to strip from value
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Float value or None on error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
output = subprocess.check_output(args).decode("utf-8")
|
||||||
|
value_str = output.split("=")[1].strip().rstrip(strip_chars)
|
||||||
|
return float(value_str)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _read_cpu_temp(self) -> float:
|
||||||
|
"""Read CPU temperature in Celsius."""
|
||||||
|
result = self._read_metric(["vcgencmd", "measure_temp"], "'C")
|
||||||
|
return result if result is not None else 0.0
|
||||||
|
|
||||||
|
def _read_cpu_volts(self) -> float:
|
||||||
|
"""Read CPU core voltage."""
|
||||||
|
result = self._read_metric(["vcgencmd", "pmic_read_adc", "VDD_CORE_V"], "V")
|
||||||
|
return result if result is not None else 0.0
|
||||||
|
|
||||||
|
def _read_cpu_amps(self) -> float:
|
||||||
|
"""Read CPU core current."""
|
||||||
|
result = self._read_metric(["vcgencmd", "pmic_read_adc", "VDD_CORE_A"], "A")
|
||||||
|
return result if result is not None else 0.0
|
||||||
|
|
||||||
|
def _read_input_voltage(self) -> float:
|
||||||
|
"""Read external 5V input voltage."""
|
||||||
|
result = self._read_metric(["vcgencmd", "pmic_read_adc", "EXT5V_V"], "V")
|
||||||
|
return result if result is not None else 0.0
|
||||||
|
|
||||||
|
def _read_power_watts(self) -> float:
|
||||||
|
"""
|
||||||
|
Calculate total system power consumption.
|
||||||
|
|
||||||
|
Reads all PMIC ADC values and calculates wattage.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
output = subprocess.check_output(["vcgencmd", "pmic_read_adc"]).decode("utf-8")
|
||||||
|
lines = output.strip().split("\n")
|
||||||
|
|
||||||
|
amperages = {}
|
||||||
|
voltages = {}
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
label = parts[0]
|
||||||
|
value_str = parts[-1]
|
||||||
|
|
||||||
|
if "=" not in value_str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
val = float(value_str.split("=")[1][:-1])
|
||||||
|
short_label = label[:-2]
|
||||||
|
|
||||||
|
if label.endswith("A"):
|
||||||
|
amperages[short_label] = val
|
||||||
|
else:
|
||||||
|
voltages[short_label] = val
|
||||||
|
|
||||||
|
# Calculate total wattage
|
||||||
|
wattage = sum(
|
||||||
|
amperages[key] * voltages[key]
|
||||||
|
for key in amperages
|
||||||
|
if key in voltages
|
||||||
|
)
|
||||||
|
|
||||||
|
return wattage
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def _read_fan_rpm(self) -> int:
|
||||||
|
"""
|
||||||
|
Read fan RPM from sysfs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Fan speed in RPM or 0 if not available
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sys_path = Path("/sys/devices/platform/cooling_fan")
|
||||||
|
fan_files = list(sys_path.rglob("fan1_input"))
|
||||||
|
|
||||||
|
if not fan_files:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with open(fan_files[0], "r") as f:
|
||||||
|
rpm = f.read().strip()
|
||||||
|
|
||||||
|
return int(rpm)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def refresh(self) -> bool:
|
||||||
|
"""Refresh system metrics."""
|
||||||
|
try:
|
||||||
|
self._data = SystemData(
|
||||||
|
cpu_temp=self._read_cpu_temp(),
|
||||||
|
cpu_volts=self._read_cpu_volts(),
|
||||||
|
cpu_amps=self._read_cpu_amps(),
|
||||||
|
power_watts=self._read_power_watts(),
|
||||||
|
fan_rpm=self._read_fan_rpm(),
|
||||||
|
input_voltage=self._read_input_voltage()
|
||||||
|
)
|
||||||
|
|
||||||
|
self._set_cached("data", self._data)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._last_error = str(e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_data(self) -> SystemData:
|
||||||
|
"""Get current system data."""
|
||||||
|
cached = self._get_cached("data")
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
self.refresh()
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
def get_cpu_temp(self) -> float:
|
||||||
|
"""Get CPU temperature in Celsius."""
|
||||||
|
return self.get_data().cpu_temp
|
||||||
|
|
||||||
|
def get_power_watts(self) -> float:
|
||||||
|
"""Get total power consumption in Watts."""
|
||||||
|
return self.get_data().power_watts
|
||||||
|
|
||||||
|
def get_fan_rpm(self) -> int:
|
||||||
|
"""Get fan speed in RPM."""
|
||||||
|
return self.get_data().fan_rpm
|
||||||
|
|
||||||
|
def shutdown(self, delay_minutes: int = 0) -> bool:
|
||||||
|
"""
|
||||||
|
Shutdown the system.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
delay_minutes: Delay before shutdown (0 = immediate)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if command executed
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if delay_minutes > 0:
|
||||||
|
cmd = f"sudo shutdown -P +{delay_minutes}"
|
||||||
|
else:
|
||||||
|
cmd = "sudo shutdown -P now"
|
||||||
|
|
||||||
|
subprocess.Popen(cmd, shell=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._last_error = str(e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def reboot(self) -> bool:
|
||||||
|
"""
|
||||||
|
Reboot the system.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if command executed
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
subprocess.Popen("sudo reboot", shell=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._last_error = str(e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def cancel_shutdown(self) -> bool:
|
||||||
|
"""
|
||||||
|
Cancel pending shutdown.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if command executed
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
subprocess.call("sudo shutdown -c", shell=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._last_error = str(e)
|
||||||
|
return False
|
||||||
228
can_sniffer/src/flipper/providers/ups_provider.py
Normal file
228
can_sniffer/src/flipper/providers/ups_provider.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
"""
|
||||||
|
UPS Provider for X120x UPS on Raspberry Pi.
|
||||||
|
|
||||||
|
Reads battery status, voltage, and charging state via I2C (SMBus).
|
||||||
|
Based on https://github.com/suptronics/x120x
|
||||||
|
"""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from flipper.providers.base import BaseProvider
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UPSData:
|
||||||
|
"""UPS status data."""
|
||||||
|
|
||||||
|
voltage: float = 0.0
|
||||||
|
capacity: float = 0.0
|
||||||
|
is_charging: bool = False
|
||||||
|
power_loss: bool = False
|
||||||
|
input_voltage: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class UPSProvider(BaseProvider):
|
||||||
|
"""
|
||||||
|
Provider for X120x UPS data.
|
||||||
|
|
||||||
|
Reads from MAX17048 fuel gauge via I2C (address 0x36).
|
||||||
|
Also monitors power loss detection pin (GPIO 6).
|
||||||
|
|
||||||
|
Hardware requirements:
|
||||||
|
- X120x UPS HAT connected via I2C
|
||||||
|
- smbus2 library installed
|
||||||
|
- gpiozero for power loss detection
|
||||||
|
"""
|
||||||
|
|
||||||
|
# I2C address for MAX17048 fuel gauge
|
||||||
|
I2C_ADDRESS = 0x36
|
||||||
|
I2C_BUS = 1
|
||||||
|
|
||||||
|
# GPIO pins
|
||||||
|
CHG_ONOFF_PIN = 16
|
||||||
|
PLD_PIN = 6 # Power Loss Detection
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(name="ups", cache_ttl=2.0)
|
||||||
|
|
||||||
|
self._bus = None
|
||||||
|
self._pld_button = None
|
||||||
|
self._data = UPSData()
|
||||||
|
|
||||||
|
self._init_hardware()
|
||||||
|
|
||||||
|
def _init_hardware(self) -> None:
|
||||||
|
"""Initialize I2C and GPIO."""
|
||||||
|
try:
|
||||||
|
import smbus2
|
||||||
|
self._bus = smbus2.SMBus(self.I2C_BUS)
|
||||||
|
self._available = True
|
||||||
|
except ImportError:
|
||||||
|
self._last_error = "smbus2 not installed"
|
||||||
|
self._available = False
|
||||||
|
except Exception as e:
|
||||||
|
self._last_error = f"I2C init failed: {e}"
|
||||||
|
self._available = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from gpiozero import Button
|
||||||
|
self._pld_button = Button(self.PLD_PIN)
|
||||||
|
except ImportError:
|
||||||
|
pass # GPIO optional
|
||||||
|
except Exception:
|
||||||
|
pass # GPIO optional
|
||||||
|
|
||||||
|
def _read_voltage_and_capacity(self) -> Tuple[float, float]:
|
||||||
|
"""
|
||||||
|
Read voltage and capacity from MAX17048.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (voltage in V, capacity in %)
|
||||||
|
"""
|
||||||
|
if not self._bus:
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Read voltage register (0x02)
|
||||||
|
voltage_read = self._bus.read_word_data(self.I2C_ADDRESS, 0x02)
|
||||||
|
# Read capacity register (0x04)
|
||||||
|
capacity_read = self._bus.read_word_data(self.I2C_ADDRESS, 0x04)
|
||||||
|
|
||||||
|
# Swap bytes (little-endian to big-endian)
|
||||||
|
voltage_swapped = struct.unpack("<H", struct.pack(">H", voltage_read))[0]
|
||||||
|
capacity_swapped = struct.unpack("<H", struct.pack(">H", capacity_read))[0]
|
||||||
|
|
||||||
|
# Calculate actual values
|
||||||
|
voltage = voltage_swapped * 1.25 / 1000 / 16
|
||||||
|
capacity = capacity_swapped / 256
|
||||||
|
|
||||||
|
return voltage, capacity
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._last_error = f"Read error: {e}"
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
def _get_power_loss_state(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check power loss detection pin.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if power loss detected (running on battery)
|
||||||
|
"""
|
||||||
|
if self._pld_button is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Button pressed = power OK, not pressed = power loss
|
||||||
|
return not self._pld_button.is_pressed
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _read_input_voltage(self) -> float:
|
||||||
|
"""
|
||||||
|
Read input voltage using vcgencmd.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Input voltage in V
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from subprocess import check_output
|
||||||
|
output = check_output(["vcgencmd", "pmic_read_adc", "EXT5V_V"]).decode("utf-8")
|
||||||
|
value_str = output.split("=")[1].strip().rstrip("V")
|
||||||
|
return float(value_str)
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def refresh(self) -> bool:
|
||||||
|
"""Refresh UPS data from hardware."""
|
||||||
|
if not self._available:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
voltage, capacity = self._read_voltage_and_capacity()
|
||||||
|
power_loss = self._get_power_loss_state()
|
||||||
|
input_voltage = self._read_input_voltage()
|
||||||
|
|
||||||
|
# Determine charging state (charge disabled above 90%)
|
||||||
|
is_charging = capacity < 90 and not power_loss
|
||||||
|
|
||||||
|
self._data = UPSData(
|
||||||
|
voltage=voltage,
|
||||||
|
capacity=capacity,
|
||||||
|
is_charging=is_charging,
|
||||||
|
power_loss=power_loss,
|
||||||
|
input_voltage=input_voltage
|
||||||
|
)
|
||||||
|
|
||||||
|
self._set_cached("data", self._data)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._last_error = str(e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_data(self) -> UPSData:
|
||||||
|
"""
|
||||||
|
Get current UPS data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UPSData object with current status
|
||||||
|
"""
|
||||||
|
cached = self._get_cached("data")
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
self.refresh()
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
def get_voltage(self) -> float:
|
||||||
|
"""Get battery voltage in V."""
|
||||||
|
return self.get_data().voltage
|
||||||
|
|
||||||
|
def get_capacity(self) -> float:
|
||||||
|
"""Get battery capacity in %."""
|
||||||
|
return self.get_data().capacity
|
||||||
|
|
||||||
|
def is_charging(self) -> bool:
|
||||||
|
"""Check if battery is charging."""
|
||||||
|
return self.get_data().is_charging
|
||||||
|
|
||||||
|
def has_power_loss(self) -> bool:
|
||||||
|
"""Check if external power is lost."""
|
||||||
|
return self.get_data().power_loss
|
||||||
|
|
||||||
|
def get_input_voltage(self) -> float:
|
||||||
|
"""Get input voltage in V."""
|
||||||
|
return self.get_data().input_voltage
|
||||||
|
|
||||||
|
def get_status_string(self) -> str:
|
||||||
|
"""
|
||||||
|
Get human-readable status string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Status string like "OK", "Battery", "Charging"
|
||||||
|
"""
|
||||||
|
data = self.get_data()
|
||||||
|
|
||||||
|
if data.power_loss:
|
||||||
|
if data.capacity < 15:
|
||||||
|
return "CRITICAL"
|
||||||
|
elif data.capacity < 25:
|
||||||
|
return "LOW"
|
||||||
|
else:
|
||||||
|
return "Battery"
|
||||||
|
elif data.is_charging:
|
||||||
|
return "Charging"
|
||||||
|
else:
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""Cleanup resources."""
|
||||||
|
super().shutdown()
|
||||||
|
if self._bus:
|
||||||
|
try:
|
||||||
|
self._bus.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
"""
|
"""
|
||||||
Flipper Zero UART Handler with Handshake Protocol.
|
Flipper Zero Dynamic UI Handler.
|
||||||
|
|
||||||
Waits for INIT command from Flipper Zero before sending statistics.
|
Provides multi-page interface with bidirectional communication via UART.
|
||||||
Supports handshake protocol for secure connection establishment.
|
Supports pluggable pages for CAN stats, UPS status, system info, and actions.
|
||||||
|
|
||||||
Protocol:
|
Protocol:
|
||||||
1. RPI5 waits in passive mode, listening for commands
|
RPi -> Flipper:
|
||||||
2. Flipper sends: INIT:flipper\n
|
PAGE:<idx>/<total>|<type>|<title>|<lines>|<actions>|<selected>
|
||||||
3. RPI5 responds: ACK:rpi5,ip=x.x.x.x\n
|
ACK:<device>,ip=<ip>
|
||||||
4. RPI5 starts sending: STATS:ip=...,total=...,pending=...,processed=...\n
|
RESULT:<status>|<message>
|
||||||
5. Flipper sends: STOP:flipper\n to disconnect
|
|
||||||
|
Flipper -> RPi:
|
||||||
|
INIT:<device>
|
||||||
|
STOP:<device>
|
||||||
|
CMD:NAV:<next|prev>
|
||||||
|
CMD:SELECT:<index>
|
||||||
|
CMD:CONFIRM / CMD:CANCEL
|
||||||
|
CMD:REFRESH
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import socket
|
import socket
|
||||||
@@ -22,6 +29,10 @@ from can_frame import CANFrame
|
|||||||
from config import config
|
from config import config
|
||||||
from logger import get_logger
|
from logger import get_logger
|
||||||
|
|
||||||
|
from flipper.protocol import Protocol, Command, CommandType
|
||||||
|
from flipper.page_manager import PageManager
|
||||||
|
from flipper.pages import CANStatsPage, UPSStatusPage, SystemInfoPage, ActionsPage
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -57,16 +68,14 @@ class FlipperHandler(BaseHandler):
|
|||||||
"""
|
"""
|
||||||
Handler that communicates with Flipper Zero via UART.
|
Handler that communicates with Flipper Zero via UART.
|
||||||
|
|
||||||
Implements handshake protocol:
|
Provides dynamic multi-page interface:
|
||||||
- Waits for INIT:flipper command
|
- CAN Statistics
|
||||||
- Responds with ACK:rpi5,ip=x.x.x.x
|
- UPS Status (if available)
|
||||||
- Sends STATS periodically while connected
|
- System Information
|
||||||
- Stops on STOP:flipper command
|
- Actions Menu
|
||||||
|
|
||||||
UART Configuration:
|
Implements handshake protocol for connection management
|
||||||
- Device: /dev/ttyAMA0 (or configured device)
|
and bidirectional command processing.
|
||||||
- Baud: 115200
|
|
||||||
- Format: 8N1
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, enabled: Optional[bool] = None):
|
def __init__(self, enabled: Optional[bool] = None):
|
||||||
@@ -83,6 +92,7 @@ class FlipperHandler(BaseHandler):
|
|||||||
|
|
||||||
super().__init__(name="flipper_handler", enabled=enabled)
|
super().__init__(name="flipper_handler", enabled=enabled)
|
||||||
|
|
||||||
|
# Serial configuration
|
||||||
self.serial_port: Optional[Any] = None
|
self.serial_port: Optional[Any] = None
|
||||||
self.device = "/dev/ttyAMA0"
|
self.device = "/dev/ttyAMA0"
|
||||||
self.baudrate = 115200
|
self.baudrate = 115200
|
||||||
@@ -113,6 +123,39 @@ class FlipperHandler(BaseHandler):
|
|||||||
# IP address
|
# IP address
|
||||||
self._ip_address = "0.0.0.0"
|
self._ip_address = "0.0.0.0"
|
||||||
|
|
||||||
|
# Page manager
|
||||||
|
self._page_manager = PageManager()
|
||||||
|
self._setup_pages()
|
||||||
|
|
||||||
|
def _setup_pages(self) -> None:
|
||||||
|
"""Setup default pages."""
|
||||||
|
# CAN Statistics (always available)
|
||||||
|
can_page = CANStatsPage()
|
||||||
|
self._page_manager.register_page(can_page)
|
||||||
|
|
||||||
|
# Keep reference to CAN provider for stats updates
|
||||||
|
self._can_provider = can_page.get_provider()
|
||||||
|
|
||||||
|
# UPS Status (if available)
|
||||||
|
ups_page = UPSStatusPage()
|
||||||
|
self._page_manager.register_page(ups_page)
|
||||||
|
|
||||||
|
# System Information
|
||||||
|
system_page = SystemInfoPage()
|
||||||
|
self._page_manager.register_page(system_page)
|
||||||
|
|
||||||
|
# Actions Menu
|
||||||
|
actions_page = ActionsPage(on_result=self._on_action_result)
|
||||||
|
self._page_manager.register_page(actions_page)
|
||||||
|
|
||||||
|
def _on_action_result(self, result: str) -> None:
|
||||||
|
"""Handle action result from actions page."""
|
||||||
|
self.logger.info(f"Action result: {result}")
|
||||||
|
# Send result to Flipper
|
||||||
|
if self._connected:
|
||||||
|
msg = Protocol.encode_result(True, result)
|
||||||
|
self._send_raw(msg)
|
||||||
|
|
||||||
def initialize(self) -> bool:
|
def initialize(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Initialize UART connection.
|
Initialize UART connection.
|
||||||
@@ -162,13 +205,15 @@ class FlipperHandler(BaseHandler):
|
|||||||
)
|
)
|
||||||
self._rx_thread.start()
|
self._rx_thread.start()
|
||||||
|
|
||||||
# Start TX thread (sends stats when connected)
|
# Start TX thread (sends page content when connected)
|
||||||
self._tx_thread = threading.Thread(
|
self._tx_thread = threading.Thread(
|
||||||
target=self._tx_loop, name="FlipperTX", daemon=True
|
target=self._tx_loop, name="FlipperTX", daemon=True
|
||||||
)
|
)
|
||||||
self._tx_thread.start()
|
self._tx_thread.start()
|
||||||
|
|
||||||
self.logger.info("Flipper handler started, waiting for connection...")
|
self.logger.info(
|
||||||
|
f"Flipper handler started with {self._page_manager.get_page_count()} pages"
|
||||||
|
)
|
||||||
|
|
||||||
def _rx_loop(self) -> None:
|
def _rx_loop(self) -> None:
|
||||||
"""Receive loop - listens for commands from Flipper."""
|
"""Receive loop - listens for commands from Flipper."""
|
||||||
@@ -198,41 +243,61 @@ class FlipperHandler(BaseHandler):
|
|||||||
self.logger.debug(f"RX error: {e}")
|
self.logger.debug(f"RX error: {e}")
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
|
|
||||||
def _process_command(self, command: str) -> None:
|
def _process_command(self, raw_command: str) -> None:
|
||||||
"""
|
"""
|
||||||
Process received command from Flipper.
|
Process received command from Flipper.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
command: Received command string
|
raw_command: Raw command string
|
||||||
"""
|
"""
|
||||||
self.logger.info(f"Received command: {command}")
|
self.logger.debug(f"RX: {raw_command}")
|
||||||
|
|
||||||
if command.startswith("INIT:"):
|
# Handle handshake commands directly
|
||||||
# Handshake initiation
|
if raw_command.startswith("INIT:"):
|
||||||
client_id = command[5:].strip()
|
client_id = raw_command[5:].strip()
|
||||||
self.logger.info(f"Handshake request from: {client_id}")
|
self.logger.info(f"Handshake request from: {client_id}")
|
||||||
|
|
||||||
# Send ACK with IP address
|
# Update IP and send ACK
|
||||||
self._ip_address = get_ip_address()
|
self._ip_address = get_ip_address()
|
||||||
ack_msg = f"ACK:rpi5,ip={self._ip_address}\n"
|
ack_msg = Protocol.encode_ack("rpi5", self._ip_address)
|
||||||
self._send_raw(ack_msg)
|
self._send_raw(ack_msg)
|
||||||
|
|
||||||
self._connected = True
|
self._connected = True
|
||||||
self.logger.info(f"Connected to Flipper, IP: {self._ip_address}")
|
self.logger.info(f"Connected to Flipper, IP: {self._ip_address}")
|
||||||
|
|
||||||
elif command.startswith("STOP:"):
|
# Send initial page content
|
||||||
# Disconnect request
|
self._send_page_content()
|
||||||
client_id = command[5:].strip()
|
return
|
||||||
|
|
||||||
|
if raw_command.startswith("STOP:"):
|
||||||
|
client_id = raw_command[5:].strip()
|
||||||
self.logger.info(f"Disconnect request from: {client_id}")
|
self.logger.info(f"Disconnect request from: {client_id}")
|
||||||
self._connected = False
|
self._connected = False
|
||||||
self.logger.info("Disconnected from Flipper")
|
return
|
||||||
|
|
||||||
|
# Parse and process other commands
|
||||||
|
command = Protocol.decode_command(raw_command)
|
||||||
|
if command is None:
|
||||||
|
self.logger.debug(f"Unknown command: {raw_command}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Process command via page manager
|
||||||
|
result = self._page_manager.process_command(command)
|
||||||
|
|
||||||
|
# Send result if available
|
||||||
|
if result:
|
||||||
|
msg = Protocol.encode_result(True, result)
|
||||||
|
self._send_raw(msg)
|
||||||
|
|
||||||
|
# Always send updated page content after command
|
||||||
|
self._send_page_content()
|
||||||
|
|
||||||
def _tx_loop(self) -> None:
|
def _tx_loop(self) -> None:
|
||||||
"""Transmit loop - sends stats when connected."""
|
"""Transmit loop - sends page content periodically when connected."""
|
||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
if self._connected:
|
if self._connected:
|
||||||
self._send_stats()
|
self._send_page_content()
|
||||||
|
|
||||||
time.sleep(self.send_interval)
|
time.sleep(self.send_interval)
|
||||||
|
|
||||||
@@ -263,19 +328,14 @@ class FlipperHandler(BaseHandler):
|
|||||||
self.logger.debug(f"Send error: {e}")
|
self.logger.debug(f"Send error: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _send_stats(self) -> None:
|
def _send_page_content(self) -> None:
|
||||||
"""Send current statistics to Flipper Zero."""
|
"""Send current page content to Flipper Zero."""
|
||||||
if not self._connected:
|
if not self._connected:
|
||||||
return
|
return
|
||||||
|
|
||||||
with self._stats_lock:
|
content = self._page_manager.get_current_content()
|
||||||
total = self._total_frames
|
if content:
|
||||||
pending = self._pending_frames
|
if self._send_raw(content):
|
||||||
processed = self._processed_frames
|
|
||||||
|
|
||||||
message = f"STATS:ip={self._ip_address},total={total},pending={pending},processed={processed}\n"
|
|
||||||
|
|
||||||
if self._send_raw(message):
|
|
||||||
with self._stats_lock:
|
with self._stats_lock:
|
||||||
self._sent_count += 1
|
self._sent_count += 1
|
||||||
|
|
||||||
@@ -292,6 +352,14 @@ class FlipperHandler(BaseHandler):
|
|||||||
with self._stats_lock:
|
with self._stats_lock:
|
||||||
self._total_frames += 1
|
self._total_frames += 1
|
||||||
self._pending_frames += 1
|
self._pending_frames += 1
|
||||||
|
|
||||||
|
# Update CAN provider
|
||||||
|
self._can_provider.update_stats(
|
||||||
|
total=self._total_frames,
|
||||||
|
pending=self._pending_frames,
|
||||||
|
processed=self._processed_frames
|
||||||
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def handle_batch(self, frames: List[CANFrame]) -> int:
|
def handle_batch(self, frames: List[CANFrame]) -> int:
|
||||||
@@ -305,10 +373,19 @@ class FlipperHandler(BaseHandler):
|
|||||||
Number of frames processed
|
Number of frames processed
|
||||||
"""
|
"""
|
||||||
count = len(frames)
|
count = len(frames)
|
||||||
|
|
||||||
with self._stats_lock:
|
with self._stats_lock:
|
||||||
self._total_frames += count
|
self._total_frames += count
|
||||||
self._processed_frames += count
|
self._processed_frames += count
|
||||||
self._pending_frames = max(0, self._pending_frames - count)
|
self._pending_frames = max(0, self._pending_frames - count)
|
||||||
|
|
||||||
|
# Update CAN provider
|
||||||
|
self._can_provider.update_stats(
|
||||||
|
total=self._total_frames,
|
||||||
|
pending=self._pending_frames,
|
||||||
|
processed=self._processed_frames
|
||||||
|
)
|
||||||
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
def update_pending(self, pending_count: int) -> None:
|
def update_pending(self, pending_count: int) -> None:
|
||||||
@@ -321,11 +398,17 @@ class FlipperHandler(BaseHandler):
|
|||||||
with self._stats_lock:
|
with self._stats_lock:
|
||||||
self._pending_frames = pending_count
|
self._pending_frames = pending_count
|
||||||
|
|
||||||
|
self._can_provider.update_stats(
|
||||||
|
total=self._total_frames,
|
||||||
|
pending=self._pending_frames,
|
||||||
|
processed=self._processed_frames
|
||||||
|
)
|
||||||
|
|
||||||
def flush(self) -> None:
|
def flush(self) -> None:
|
||||||
"""Flush - send immediate stats if connected."""
|
"""Flush - send immediate page content if connected."""
|
||||||
if self._connected:
|
if self._connected:
|
||||||
try:
|
try:
|
||||||
self._send_stats()
|
self._send_page_content()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.debug(f"Flush error: {e}")
|
self.logger.debug(f"Flush error: {e}")
|
||||||
|
|
||||||
@@ -350,6 +433,9 @@ class FlipperHandler(BaseHandler):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.debug(f"Error closing serial port: {e}")
|
self.logger.debug(f"Error closing serial port: {e}")
|
||||||
|
|
||||||
|
# Shutdown page manager
|
||||||
|
self._page_manager.shutdown()
|
||||||
|
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
self.logger.info("Flipper handler stopped")
|
self.logger.info("Flipper handler stopped")
|
||||||
|
|
||||||
@@ -361,7 +447,7 @@ class FlipperHandler(BaseHandler):
|
|||||||
Dictionary with handler stats
|
Dictionary with handler stats
|
||||||
"""
|
"""
|
||||||
with self._stats_lock:
|
with self._stats_lock:
|
||||||
return {
|
stats = {
|
||||||
"total_frames": self._total_frames,
|
"total_frames": self._total_frames,
|
||||||
"pending_frames": self._pending_frames,
|
"pending_frames": self._pending_frames,
|
||||||
"processed_frames": self._processed_frames,
|
"processed_frames": self._processed_frames,
|
||||||
@@ -373,6 +459,15 @@ class FlipperHandler(BaseHandler):
|
|||||||
"ip_address": self._ip_address,
|
"ip_address": self._ip_address,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add page manager stats
|
||||||
|
stats.update(self._page_manager.get_stats())
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
def is_connected(self) -> bool:
|
def is_connected(self) -> bool:
|
||||||
"""Check if Flipper is connected."""
|
"""Check if Flipper is connected."""
|
||||||
return self._connected
|
return self._connected
|
||||||
|
|
||||||
|
def get_page_manager(self) -> PageManager:
|
||||||
|
"""Get page manager for external page registration."""
|
||||||
|
return self._page_manager
|
||||||
|
|||||||
@@ -1,27 +1,104 @@
|
|||||||
# CAN Monitor for Flipper Zero
|
# CAN Monitor for Flipper Zero
|
||||||
|
|
||||||
Flipper Zero application for monitoring CAN sniffer statistics from Raspberry Pi 5 via UART.
|
Dynamic multi-page monitor application for Raspberry Pi 5 via UART.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Real-time display of CAN sniffer statistics
|
- **Dynamic Page System** - Pages are fully controlled by RPi5
|
||||||
- Shows: IP address, total frames, pending frames, processed frames
|
- **Multiple Page Types**:
|
||||||
- Connection status indicator
|
- **Info** - Read-only status display
|
||||||
- Compatible with Unleashed firmware (0.84e and later)
|
- **Menu** - Selectable action items
|
||||||
|
- **Confirm** - Yes/No confirmation dialogs
|
||||||
|
- **Real-time Updates** - Content refreshes every second
|
||||||
|
- **Bidirectional Communication** - Send commands back to RPi5
|
||||||
|
- **Result Notifications** - Action results displayed as overlay
|
||||||
|
|
||||||
|
## Default Pages
|
||||||
|
|
||||||
|
When connected to the CAN Sniffer system:
|
||||||
|
|
||||||
|
| Page | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| CAN Statistics | Info | Frame counts, queue status, per-interface stats |
|
||||||
|
| UPS Status | Info | Battery level, voltage, charging state (X120x) |
|
||||||
|
| System Info | Info | CPU temperature, power consumption, fan RPM |
|
||||||
|
| Actions | Menu | Shutdown, Reboot, Cancel shutdown |
|
||||||
|
|
||||||
|
## Screen Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
128x64 pixels (Flipper Zero display)
|
||||||
|
|
||||||
|
+----------------------------------+
|
||||||
|
| [1/4] Page Title (*) | <- Header: page indicator, title, connection dot
|
||||||
|
+----------------------------------+
|
||||||
|
| |
|
||||||
|
| Content Line 1 | <- Content area: 4-5 lines
|
||||||
|
| Content Line 2 |
|
||||||
|
| Content Line 3 |
|
||||||
|
| Content Line 4 |
|
||||||
|
| |
|
||||||
|
+----------------------------------+
|
||||||
|
| < Hint Text > | <- Footer: nav arrows, action hint
|
||||||
|
+----------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Key | Info Page | Menu Page | Confirm Page |
|
||||||
|
|-----|-----------|-----------|--------------|
|
||||||
|
| **Left/Right** | Navigate pages | Navigate pages | Navigate pages |
|
||||||
|
| **Up/Down** | - | Select item | Switch Yes/No |
|
||||||
|
| **OK** | - | Execute action | Confirm selection |
|
||||||
|
| **Back** | Disconnect (pg 0) / Prev page | Same | Cancel dialog |
|
||||||
|
|
||||||
|
## Protocol
|
||||||
|
|
||||||
|
### RPi5 -> Flipper
|
||||||
|
|
||||||
|
```
|
||||||
|
PAGE:<idx>/<total>|<type>|<title>|<lines>|<actions>|<selected>
|
||||||
|
ACK:<device>,ip=<ip>
|
||||||
|
RESULT:<OK|ERROR>|<message>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```
|
||||||
|
PAGE:0/4|info|CAN Statistics|Total: 12.3K;Processed: 12.1K;Queue: 142||0
|
||||||
|
PAGE:1/4|info|UPS Status|Bat: 85.2% [====];Voltage: 4.12V;Status: Charging||0
|
||||||
|
PAGE:2/4|info|System Info|CPU Temp: 52.1C;Power: 4.2W;Fan: 3200 RPM||0
|
||||||
|
PAGE:3/4|menu|Actions|Shutdown;Reboot;Cancel Shutdown||0
|
||||||
|
PAGE:3/4|confirm|Confirm Shutdown?|Are you sure?|Yes;No|1
|
||||||
|
ACK:rpi5,ip=192.168.1.100
|
||||||
|
RESULT:OK|Shutdown in 1 min
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flipper -> RPi5
|
||||||
|
|
||||||
|
```
|
||||||
|
INIT:flipper - Start handshake
|
||||||
|
STOP:flipper - Disconnect
|
||||||
|
CMD:NAV:next - Next page
|
||||||
|
CMD:NAV:prev - Previous page
|
||||||
|
CMD:SELECT:<index> - Select menu item
|
||||||
|
CMD:CONFIRM - Confirm action
|
||||||
|
CMD:CANCEL - Cancel action
|
||||||
|
CMD:REFRESH - Request page update
|
||||||
|
```
|
||||||
|
|
||||||
## Hardware Connection
|
## Hardware Connection
|
||||||
|
|
||||||
### Wiring (RPI5 <-> Flipper Zero)
|
### Wiring Diagram
|
||||||
|
|
||||||
```
|
```
|
||||||
RPI5 GPIO Flipper Zero GPIO
|
Flipper Zero Raspberry Pi 5
|
||||||
----------- -----------------
|
----------- ---------------
|
||||||
TX (GPIO 14) --> RX (Pin 14)
|
Pin 13 (TX) ----> GPIO 15 (RX) - Pin 10
|
||||||
RX (GPIO 15) <-- TX (Pin 13)
|
Pin 14 (RX) <---- GPIO 14 (TX) - Pin 8
|
||||||
GND --> GND
|
Pin 8/11/18 (GND) ---- GND - Pin 6
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** Cross TX/RX connections (RPI TX -> Flipper RX, RPI RX -> Flipper TX)
|
**Note:** Cross TX/RX connections (Flipper TX -> RPi RX)
|
||||||
|
|
||||||
### Flipper Zero GPIO Pinout
|
### Flipper Zero GPIO Pinout
|
||||||
|
|
||||||
@@ -54,12 +131,6 @@ cd unleashed-firmware
|
|||||||
cp -r /path/to/carpibord/flip_monitor applications_user/can_monitor
|
cp -r /path/to/carpibord/flip_monitor applications_user/can_monitor
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Create icon (10x10 PNG, 1-bit):
|
|
||||||
```bash
|
|
||||||
# Create icons/can_monitor.png (10x10 pixels, black & white)
|
|
||||||
# You can use any image editor or online tool
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -83,23 +154,29 @@ SD Card/apps/GPIO/can_monitor.fap
|
|||||||
|
|
||||||
### 1. Enable UART
|
### 1. Enable UART
|
||||||
|
|
||||||
Add to `/boot/config.txt`:
|
Add to `/boot/firmware/config.txt`:
|
||||||
```
|
```
|
||||||
enable_uart=1
|
enable_uart=1
|
||||||
dtoverlay=uart0
|
dtoverlay=uart0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Disable serial console:
|
||||||
|
```bash
|
||||||
|
sudo raspi-config
|
||||||
|
# Interface Options -> Serial Port -> No (login shell) -> Yes (hardware)
|
||||||
|
```
|
||||||
|
|
||||||
Reboot after changes.
|
Reboot after changes.
|
||||||
|
|
||||||
### 2. Install pyserial
|
### 2. Install Dependencies
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install pyserial
|
pip install pyserial smbus2 gpiozero
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Configure CAN Sniffer
|
### 3. Configure CAN Sniffer
|
||||||
|
|
||||||
Add to `can_sniffer/config.json`:
|
Add to `can_sniffer/src/config.json`:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"flipper": {
|
"flipper": {
|
||||||
@@ -124,19 +201,38 @@ cd can_sniffer/src
|
|||||||
python main.py
|
python main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## Protocol
|
## Adding Custom Pages
|
||||||
|
|
||||||
The RPI5 sends text-based statistics over UART:
|
On RPi5 side, create a new page class:
|
||||||
|
|
||||||
```
|
```python
|
||||||
STATS:ip=192.168.1.100,total=12345,pending=100,processed=12245\n
|
from flipper.pages.base import InfoPage
|
||||||
|
|
||||||
|
class MyCustomPage(InfoPage):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="my_page",
|
||||||
|
title="My Page",
|
||||||
|
icon="custom"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_lines(self) -> list[str]:
|
||||||
|
return [
|
||||||
|
"Custom line 1",
|
||||||
|
"Custom line 2",
|
||||||
|
f"Value: {self.get_value()}"
|
||||||
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
Fields:
|
Register in FlipperHandler:
|
||||||
- `ip` - RPI5 IP address
|
|
||||||
- `total` - Total CAN frames received
|
```python
|
||||||
- `pending` - Frames in processing queue
|
# In flipper_handler.py
|
||||||
- `processed` - Successfully processed frames
|
from flipper.pages.my_custom import MyCustomPage
|
||||||
|
|
||||||
|
# In _setup_pages():
|
||||||
|
self._page_manager.register_page(MyCustomPage())
|
||||||
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
@@ -148,7 +244,7 @@ Fields:
|
|||||||
4. Test UART manually:
|
4. Test UART manually:
|
||||||
```bash
|
```bash
|
||||||
# On RPI5
|
# On RPI5
|
||||||
echo "STATS:ip=test,total=1,pending=0,processed=1" > /dev/ttyAMA0
|
echo "ACK:rpi5,ip=test" > /dev/ttyAMA0
|
||||||
```
|
```
|
||||||
|
|
||||||
### Permission denied on /dev/ttyAMA0
|
### Permission denied on /dev/ttyAMA0
|
||||||
@@ -159,11 +255,23 @@ sudo usermod -a -G dialout $USER
|
|||||||
# Then logout and login again
|
# Then logout and login again
|
||||||
```
|
```
|
||||||
|
|
||||||
### Flipper shows "Waiting..."
|
### Flipper shows "Waiting for data..."
|
||||||
|
|
||||||
- Stats are sent every 1 second (configurable)
|
- Data is sent every 1 second (configurable)
|
||||||
- Connection timeout is 5 seconds
|
- Connection timeout is 5 seconds
|
||||||
- Check if CAN sniffer is running
|
- Check if CAN sniffer is running
|
||||||
|
- Check UART logs: `CAN_SNIFFER_LOGGING__LEVEL=DEBUG python main.py`
|
||||||
|
|
||||||
|
### UPS page shows "not available"
|
||||||
|
|
||||||
|
- X120x UPS must be connected via I2C
|
||||||
|
- Install smbus2: `pip install smbus2`
|
||||||
|
- Check I2C: `i2cdetect -y 1` (should show device at 0x36)
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
|
||||||
|
- **v2.0** - Dynamic page system, bidirectional commands, UPS/System pages
|
||||||
|
- **v1.0** - Basic CAN statistics display
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ App(
|
|||||||
name="CAN Monitor",
|
name="CAN Monitor",
|
||||||
apptype=FlipperAppType.EXTERNAL,
|
apptype=FlipperAppType.EXTERNAL,
|
||||||
entry_point="can_monitor_app",
|
entry_point="can_monitor_app",
|
||||||
stack_size=2 * 1024,
|
stack_size=4 * 1024,
|
||||||
fap_category="GPIO",
|
fap_category="GPIO",
|
||||||
fap_author="carpibord",
|
fap_author="carpibord",
|
||||||
fap_version="1.0",
|
fap_version="2.0",
|
||||||
fap_description="CAN Sniffer monitor via UART from RPI5",
|
fap_description="Dynamic multi-page RPI5 monitor with CAN stats, UPS, system info and actions",
|
||||||
|
fap_icon="A:/icons/gpio_50.png",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* CAN Monitor for Flipper Zero
|
* CAN Monitor for Flipper Zero - Dynamic UI Version
|
||||||
*
|
*
|
||||||
* Multi-page application with handshake protocol.
|
* Multi-page application with dynamic content from RPI5.
|
||||||
*
|
*
|
||||||
* Handshake:
|
* Protocol:
|
||||||
* 1. User presses OK on welcome screen
|
* RPi -> Flipper:
|
||||||
* 2. Flipper sends: INIT:flipper\n
|
* PAGE:<idx>/<total>|<type>|<title>|<lines>|<actions>|<selected>
|
||||||
* 3. RPI5 responds: ACK:rpi5,ip=x.x.x.x\n
|
* ACK:<device>,ip=<ip>
|
||||||
* 4. Flipper allows navigation to stats page
|
* RESULT:<status>|<message>
|
||||||
* 5. RPI5 starts sending: STATS:ip=...,total=...,pending=...,processed=...\n
|
*
|
||||||
|
* Flipper -> RPi:
|
||||||
|
* INIT:flipper
|
||||||
|
* STOP:flipper
|
||||||
|
* CMD:NAV:next / CMD:NAV:prev
|
||||||
|
* CMD:SELECT:<index>
|
||||||
|
* CMD:CONFIRM / CMD:CANCEL
|
||||||
|
* CMD:REFRESH
|
||||||
*
|
*
|
||||||
* Wiring:
|
* Wiring:
|
||||||
* RPI5 TX (GPIO14, Pin 8) -> Flipper RX (Pin 14)
|
* RPI5 TX (GPIO14, Pin 8) -> Flipper RX (Pin 14)
|
||||||
@@ -20,6 +27,7 @@
|
|||||||
#include <furi_hal.h>
|
#include <furi_hal.h>
|
||||||
#include <gui/gui.h>
|
#include <gui/gui.h>
|
||||||
#include <gui/view_port.h>
|
#include <gui/view_port.h>
|
||||||
|
#include <gui/elements.h>
|
||||||
#include <expansion/expansion.h>
|
#include <expansion/expansion.h>
|
||||||
|
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
@@ -27,8 +35,11 @@
|
|||||||
|
|
||||||
#define TAG "CANMonitor"
|
#define TAG "CANMonitor"
|
||||||
#define UART_BAUD 115200
|
#define UART_BAUD 115200
|
||||||
#define RX_BUFFER_SIZE 256
|
#define RX_BUFFER_SIZE 512
|
||||||
#define IP_BUFFER_SIZE 32
|
#define MAX_LINES 5
|
||||||
|
#define MAX_ACTIONS 4
|
||||||
|
#define MAX_LINE_LENGTH 32
|
||||||
|
#define MAX_TITLE_LENGTH 24
|
||||||
|
|
||||||
// Connection state
|
// Connection state
|
||||||
typedef enum {
|
typedef enum {
|
||||||
@@ -37,21 +48,35 @@ typedef enum {
|
|||||||
StateConnected,
|
StateConnected,
|
||||||
} ConnectionState;
|
} ConnectionState;
|
||||||
|
|
||||||
// Pages
|
// Page types
|
||||||
typedef enum {
|
typedef enum {
|
||||||
PageWelcome,
|
PageTypeInfo,
|
||||||
PageStats,
|
PageTypeMenu,
|
||||||
} AppPage;
|
PageTypeConfirm,
|
||||||
|
} PageType;
|
||||||
|
|
||||||
// Statistics
|
// Page content received from RPi
|
||||||
typedef struct {
|
typedef struct {
|
||||||
char ip_address[IP_BUFFER_SIZE];
|
uint8_t page_index;
|
||||||
uint32_t total_frames;
|
uint8_t total_pages;
|
||||||
uint32_t pending_frames;
|
PageType page_type;
|
||||||
uint32_t processed_frames;
|
char title[MAX_TITLE_LENGTH];
|
||||||
bool data_received;
|
char lines[MAX_LINES][MAX_LINE_LENGTH];
|
||||||
|
uint8_t line_count;
|
||||||
|
char actions[MAX_ACTIONS][MAX_LINE_LENGTH];
|
||||||
|
uint8_t action_count;
|
||||||
|
uint8_t selected_index;
|
||||||
|
bool data_valid;
|
||||||
uint32_t last_update_tick;
|
uint32_t last_update_tick;
|
||||||
} CanStats;
|
} PageContent;
|
||||||
|
|
||||||
|
// Result message
|
||||||
|
typedef struct {
|
||||||
|
bool has_result;
|
||||||
|
bool success;
|
||||||
|
char message[MAX_LINE_LENGTH];
|
||||||
|
uint32_t show_until_tick;
|
||||||
|
} ResultMessage;
|
||||||
|
|
||||||
// App context
|
// App context
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -63,13 +88,19 @@ typedef struct {
|
|||||||
FuriStreamBuffer* rx_stream;
|
FuriStreamBuffer* rx_stream;
|
||||||
FuriThread* worker_thread;
|
FuriThread* worker_thread;
|
||||||
|
|
||||||
CanStats stats;
|
PageContent page;
|
||||||
AppPage current_page;
|
ResultMessage result;
|
||||||
ConnectionState conn_state;
|
ConnectionState conn_state;
|
||||||
|
char ip_address[32];
|
||||||
|
|
||||||
volatile bool running;
|
volatile bool running;
|
||||||
volatile bool send_init;
|
volatile bool send_init;
|
||||||
} CanMonitorApp;
|
} CanMonitorApp;
|
||||||
|
|
||||||
|
// Forward declarations
|
||||||
|
static void draw_callback(Canvas* canvas, void* ctx);
|
||||||
|
static void input_callback(InputEvent* event, void* ctx);
|
||||||
|
|
||||||
// Send data via UART
|
// Send data via UART
|
||||||
static void uart_send(CanMonitorApp* app, const char* data) {
|
static void uart_send(CanMonitorApp* app, const char* data) {
|
||||||
if(app->serial) {
|
if(app->serial) {
|
||||||
@@ -78,77 +109,200 @@ static void uart_send(CanMonitorApp* app, const char* data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse ACK response: ACK:rpi5,ip=x.x.x.x
|
// Clear page content
|
||||||
static bool parse_ack(const char* line, CanStats* stats) {
|
static void clear_page_content(PageContent* page) {
|
||||||
|
page->page_index = 0;
|
||||||
|
page->total_pages = 0;
|
||||||
|
page->page_type = PageTypeInfo;
|
||||||
|
page->title[0] = '\0';
|
||||||
|
page->line_count = 0;
|
||||||
|
page->action_count = 0;
|
||||||
|
page->selected_index = 0;
|
||||||
|
page->data_valid = false;
|
||||||
|
|
||||||
|
for(int i = 0; i < MAX_LINES; i++) {
|
||||||
|
page->lines[i][0] = '\0';
|
||||||
|
}
|
||||||
|
for(int i = 0; i < MAX_ACTIONS; i++) {
|
||||||
|
page->actions[i][0] = '\0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse semicolon-separated items into array
|
||||||
|
static uint8_t parse_items(const char* str, char items[][MAX_LINE_LENGTH], uint8_t max_items) {
|
||||||
|
uint8_t count = 0;
|
||||||
|
|
||||||
|
if(!str || str[0] == '\0') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* start = str;
|
||||||
|
const char* end;
|
||||||
|
|
||||||
|
while(count < max_items && start && *start) {
|
||||||
|
end = strchr(start, ';');
|
||||||
|
|
||||||
|
size_t len;
|
||||||
|
if(end) {
|
||||||
|
len = end - start;
|
||||||
|
} else {
|
||||||
|
len = strlen(start);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(len >= MAX_LINE_LENGTH) {
|
||||||
|
len = MAX_LINE_LENGTH - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(len > 0) {
|
||||||
|
memcpy(items[count], start, len);
|
||||||
|
items[count][len] = '\0';
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(end) {
|
||||||
|
start = end + 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse PAGE message
|
||||||
|
// Format: PAGE:<idx>/<total>|<type>|<title>|<lines>|<actions>|<selected>
|
||||||
|
static bool parse_page(const char* line, PageContent* page) {
|
||||||
|
if(strncmp(line, "PAGE:", 5) != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* p = line + 5;
|
||||||
|
char* end;
|
||||||
|
|
||||||
|
// Parse page index and total
|
||||||
|
page->page_index = (uint8_t)strtoul(p, &end, 10);
|
||||||
|
if(*end != '/') return false;
|
||||||
|
p = end + 1;
|
||||||
|
|
||||||
|
page->total_pages = (uint8_t)strtoul(p, &end, 10);
|
||||||
|
if(*end != '|') return false;
|
||||||
|
p = end + 1;
|
||||||
|
|
||||||
|
// Parse page type
|
||||||
|
const char* type_end = strchr(p, '|');
|
||||||
|
if(!type_end) return false;
|
||||||
|
|
||||||
|
if(strncmp(p, "info", type_end - p) == 0) {
|
||||||
|
page->page_type = PageTypeInfo;
|
||||||
|
} else if(strncmp(p, "menu", type_end - p) == 0) {
|
||||||
|
page->page_type = PageTypeMenu;
|
||||||
|
} else if(strncmp(p, "confirm", type_end - p) == 0) {
|
||||||
|
page->page_type = PageTypeConfirm;
|
||||||
|
} else {
|
||||||
|
page->page_type = PageTypeInfo;
|
||||||
|
}
|
||||||
|
p = type_end + 1;
|
||||||
|
|
||||||
|
// Parse title
|
||||||
|
const char* title_end = strchr(p, '|');
|
||||||
|
if(!title_end) return false;
|
||||||
|
|
||||||
|
size_t title_len = title_end - p;
|
||||||
|
if(title_len >= MAX_TITLE_LENGTH) {
|
||||||
|
title_len = MAX_TITLE_LENGTH - 1;
|
||||||
|
}
|
||||||
|
memcpy(page->title, p, title_len);
|
||||||
|
page->title[title_len] = '\0';
|
||||||
|
p = title_end + 1;
|
||||||
|
|
||||||
|
// Parse lines
|
||||||
|
const char* lines_end = strchr(p, '|');
|
||||||
|
if(!lines_end) return false;
|
||||||
|
|
||||||
|
char lines_buf[256];
|
||||||
|
size_t lines_len = lines_end - p;
|
||||||
|
if(lines_len >= sizeof(lines_buf)) {
|
||||||
|
lines_len = sizeof(lines_buf) - 1;
|
||||||
|
}
|
||||||
|
memcpy(lines_buf, p, lines_len);
|
||||||
|
lines_buf[lines_len] = '\0';
|
||||||
|
page->line_count = parse_items(lines_buf, page->lines, MAX_LINES);
|
||||||
|
p = lines_end + 1;
|
||||||
|
|
||||||
|
// Parse actions
|
||||||
|
const char* actions_end = strchr(p, '|');
|
||||||
|
if(!actions_end) return false;
|
||||||
|
|
||||||
|
char actions_buf[128];
|
||||||
|
size_t actions_len = actions_end - p;
|
||||||
|
if(actions_len >= sizeof(actions_buf)) {
|
||||||
|
actions_len = sizeof(actions_buf) - 1;
|
||||||
|
}
|
||||||
|
memcpy(actions_buf, p, actions_len);
|
||||||
|
actions_buf[actions_len] = '\0';
|
||||||
|
page->action_count = parse_items(actions_buf, page->actions, MAX_ACTIONS);
|
||||||
|
p = actions_end + 1;
|
||||||
|
|
||||||
|
// Parse selected index
|
||||||
|
page->selected_index = (uint8_t)strtoul(p, NULL, 10);
|
||||||
|
|
||||||
|
page->data_valid = true;
|
||||||
|
page->last_update_tick = furi_get_tick();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse ACK message
|
||||||
|
static bool parse_ack(const char* line, char* ip_out, size_t ip_size) {
|
||||||
if(strncmp(line, "ACK:", 4) != 0) {
|
if(strncmp(line, "ACK:", 4) != 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const char* p = line + 4;
|
const char* ip = strstr(line, "ip=");
|
||||||
|
|
||||||
// Check for rpi5 identifier
|
|
||||||
if(strncmp(p, "rpi5", 4) != 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse ip=
|
|
||||||
const char* ip = strstr(p, "ip=");
|
|
||||||
if(ip) {
|
if(ip) {
|
||||||
ip += 3;
|
ip += 3;
|
||||||
// Find end (comma, newline, or end of string)
|
|
||||||
size_t len = 0;
|
size_t len = 0;
|
||||||
while(ip[len] && ip[len] != ',' && ip[len] != '\n' && ip[len] != '\r') {
|
while(ip[len] && ip[len] != ',' && ip[len] != '\n' && ip[len] != '\r') {
|
||||||
len++;
|
len++;
|
||||||
}
|
}
|
||||||
if(len > 0 && len < IP_BUFFER_SIZE) {
|
if(len > 0 && len < ip_size) {
|
||||||
memcpy(stats->ip_address, ip, len);
|
memcpy(ip_out, ip, len);
|
||||||
stats->ip_address[len] = '\0';
|
ip_out[len] = '\0';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse STATS: STATS:ip=x.x.x.x,total=N,pending=N,processed=N
|
// Parse RESULT message
|
||||||
static bool parse_stats(const char* line, CanStats* stats) {
|
static bool parse_result(const char* line, ResultMessage* result) {
|
||||||
if(strncmp(line, "STATS:", 6) != 0) {
|
if(strncmp(line, "RESULT:", 7) != 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const char* p = line + 6;
|
const char* p = line + 7;
|
||||||
|
|
||||||
// Parse ip=
|
// Parse status
|
||||||
const char* ip = strstr(p, "ip=");
|
if(strncmp(p, "OK|", 3) == 0) {
|
||||||
if(ip) {
|
result->success = true;
|
||||||
ip += 3;
|
p += 3;
|
||||||
const char* end = strchr(ip, ',');
|
} else if(strncmp(p, "ERROR|", 6) == 0) {
|
||||||
if(end && (size_t)(end - ip) < IP_BUFFER_SIZE) {
|
result->success = false;
|
||||||
size_t len = end - ip;
|
p += 6;
|
||||||
memcpy(stats->ip_address, ip, len);
|
} else {
|
||||||
stats->ip_address[len] = '\0';
|
return false;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse total=
|
// Parse message
|
||||||
const char* total = strstr(p, "total=");
|
size_t len = strlen(p);
|
||||||
if(total) {
|
if(len >= MAX_LINE_LENGTH) {
|
||||||
stats->total_frames = strtoul(total + 6, NULL, 10);
|
len = MAX_LINE_LENGTH - 1;
|
||||||
}
|
}
|
||||||
|
memcpy(result->message, p, len);
|
||||||
|
result->message[len] = '\0';
|
||||||
|
|
||||||
// Parse pending=
|
result->has_result = true;
|
||||||
const char* pending = strstr(p, "pending=");
|
result->show_until_tick = furi_get_tick() + 3000; // Show for 3 seconds
|
||||||
if(pending) {
|
|
||||||
stats->pending_frames = strtoul(pending + 8, NULL, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse processed=
|
|
||||||
const char* processed = strstr(p, "processed=");
|
|
||||||
if(processed) {
|
|
||||||
stats->processed_frames = strtoul(processed + 10, NULL, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
stats->data_received = true;
|
|
||||||
stats->last_update_tick = furi_get_tick();
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -161,13 +315,17 @@ static void process_line(CanMonitorApp* app, const char* line) {
|
|||||||
|
|
||||||
if(app->conn_state == StateConnecting) {
|
if(app->conn_state == StateConnecting) {
|
||||||
// Waiting for ACK
|
// Waiting for ACK
|
||||||
if(parse_ack(line, &app->stats)) {
|
if(parse_ack(line, app->ip_address, sizeof(app->ip_address))) {
|
||||||
FURI_LOG_I(TAG, "ACK received, IP: %s", app->stats.ip_address);
|
FURI_LOG_I(TAG, "ACK received, IP: %s", app->ip_address);
|
||||||
app->conn_state = StateConnected;
|
app->conn_state = StateConnected;
|
||||||
}
|
}
|
||||||
} else if(app->conn_state == StateConnected) {
|
} else if(app->conn_state == StateConnected) {
|
||||||
// Parse stats
|
// Parse PAGE or RESULT
|
||||||
parse_stats(line, &app->stats);
|
if(line[0] == 'P') {
|
||||||
|
parse_page(line, &app->page);
|
||||||
|
} else if(line[0] == 'R') {
|
||||||
|
parse_result(line, &app->result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
furi_mutex_release(app->mutex);
|
furi_mutex_release(app->mutex);
|
||||||
@@ -201,6 +359,7 @@ static int32_t worker_thread(void* ctx) {
|
|||||||
|
|
||||||
furi_mutex_acquire(app->mutex, FuriWaitForever);
|
furi_mutex_acquire(app->mutex, FuriWaitForever);
|
||||||
app->conn_state = StateConnecting;
|
app->conn_state = StateConnecting;
|
||||||
|
clear_page_content(&app->page);
|
||||||
furi_mutex_release(app->mutex);
|
furi_mutex_release(app->mutex);
|
||||||
view_port_update(app->view_port);
|
view_port_update(app->view_port);
|
||||||
}
|
}
|
||||||
@@ -232,12 +391,18 @@ static int32_t worker_thread(void* ctx) {
|
|||||||
|
|
||||||
// Check data timeout (5 sec) - only when connected
|
// Check data timeout (5 sec) - only when connected
|
||||||
furi_mutex_acquire(app->mutex, FuriWaitForever);
|
furi_mutex_acquire(app->mutex, FuriWaitForever);
|
||||||
if(app->conn_state == StateConnected && app->stats.data_received) {
|
if(app->conn_state == StateConnected && app->page.data_valid) {
|
||||||
if((furi_get_tick() - app->stats.last_update_tick) > 5000) {
|
if((furi_get_tick() - app->page.last_update_tick) > 5000) {
|
||||||
app->stats.data_received = false;
|
app->page.data_valid = false;
|
||||||
view_port_update(app->view_port);
|
view_port_update(app->view_port);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear result message after timeout
|
||||||
|
if(app->result.has_result && furi_get_tick() > app->result.show_until_tick) {
|
||||||
|
app->result.has_result = false;
|
||||||
|
view_port_update(app->view_port);
|
||||||
|
}
|
||||||
furi_mutex_release(app->mutex);
|
furi_mutex_release(app->mutex);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,24 +410,166 @@ static int32_t worker_thread(void* ctx) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw welcome page
|
// Draw header with page indicator
|
||||||
|
static void draw_header(Canvas* canvas, CanMonitorApp* app) {
|
||||||
|
// Page indicator
|
||||||
|
char page_str[16];
|
||||||
|
snprintf(
|
||||||
|
page_str,
|
||||||
|
sizeof(page_str),
|
||||||
|
"[%d/%d]",
|
||||||
|
app->page.page_index + 1,
|
||||||
|
app->page.total_pages);
|
||||||
|
canvas_draw_str(canvas, 0, 8, page_str);
|
||||||
|
|
||||||
|
// Title (centered)
|
||||||
|
uint16_t title_width = canvas_string_width(canvas, app->page.title);
|
||||||
|
uint16_t title_x = (128 - title_width) / 2;
|
||||||
|
canvas_draw_str(canvas, title_x, 8, app->page.title);
|
||||||
|
|
||||||
|
// Connection indicator (right side)
|
||||||
|
if(app->page.data_valid) {
|
||||||
|
canvas_draw_disc(canvas, 122, 4, 3);
|
||||||
|
} else {
|
||||||
|
canvas_draw_circle(canvas, 122, 4, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separator line
|
||||||
|
canvas_draw_line(canvas, 0, 11, 128, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw footer with navigation hints
|
||||||
|
static void draw_footer(Canvas* canvas, PageType page_type, uint8_t total_pages, uint8_t current_page) {
|
||||||
|
canvas_draw_line(canvas, 0, 54, 128, 54);
|
||||||
|
|
||||||
|
canvas_set_font(canvas, FontSecondary);
|
||||||
|
|
||||||
|
// Left arrow (if not first page)
|
||||||
|
if(current_page > 0) {
|
||||||
|
canvas_draw_str(canvas, 2, 63, "<");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center action hint
|
||||||
|
const char* hint = "";
|
||||||
|
switch(page_type) {
|
||||||
|
case PageTypeInfo:
|
||||||
|
hint = "";
|
||||||
|
break;
|
||||||
|
case PageTypeMenu:
|
||||||
|
hint = "OK=Select";
|
||||||
|
break;
|
||||||
|
case PageTypeConfirm:
|
||||||
|
hint = "OK=Yes Back=No";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
uint16_t hint_width = canvas_string_width(canvas, hint);
|
||||||
|
canvas_draw_str(canvas, (128 - hint_width) / 2, 63, hint);
|
||||||
|
|
||||||
|
// Right arrow (if not last page)
|
||||||
|
if(current_page < total_pages - 1) {
|
||||||
|
canvas_draw_str(canvas, 122, 63, ">");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw info page
|
||||||
|
static void draw_info_page(Canvas* canvas, CanMonitorApp* app) {
|
||||||
|
canvas_set_font(canvas, FontSecondary);
|
||||||
|
|
||||||
|
uint8_t y = 22;
|
||||||
|
for(uint8_t i = 0; i < app->page.line_count && i < MAX_LINES; i++) {
|
||||||
|
canvas_draw_str(canvas, 2, y, app->page.lines[i]);
|
||||||
|
y += 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw menu page
|
||||||
|
static void draw_menu_page(Canvas* canvas, CanMonitorApp* app) {
|
||||||
|
canvas_set_font(canvas, FontSecondary);
|
||||||
|
|
||||||
|
uint8_t y = 22;
|
||||||
|
for(uint8_t i = 0; i < app->page.action_count && i < MAX_ACTIONS; i++) {
|
||||||
|
// Selection indicator
|
||||||
|
if(i == app->page.selected_index) {
|
||||||
|
canvas_draw_box(canvas, 0, y - 8, 128, 10);
|
||||||
|
canvas_set_color(canvas, ColorWhite);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas_draw_str(canvas, 4, y, app->page.actions[i]);
|
||||||
|
|
||||||
|
if(i == app->page.selected_index) {
|
||||||
|
canvas_set_color(canvas, ColorBlack);
|
||||||
|
}
|
||||||
|
|
||||||
|
y += 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw confirm page
|
||||||
|
static void draw_confirm_page(Canvas* canvas, CanMonitorApp* app) {
|
||||||
|
canvas_set_font(canvas, FontSecondary);
|
||||||
|
|
||||||
|
// Message (centered)
|
||||||
|
if(app->page.line_count > 0) {
|
||||||
|
uint16_t msg_width = canvas_string_width(canvas, app->page.lines[0]);
|
||||||
|
canvas_draw_str(canvas, (128 - msg_width) / 2, 30, app->page.lines[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yes/No options
|
||||||
|
uint8_t btn_y = 44;
|
||||||
|
uint8_t yes_x = 32;
|
||||||
|
uint8_t no_x = 80;
|
||||||
|
|
||||||
|
// Draw Yes
|
||||||
|
if(app->page.selected_index == 0) {
|
||||||
|
canvas_draw_box(canvas, yes_x - 2, btn_y - 8, 24, 12);
|
||||||
|
canvas_set_color(canvas, ColorWhite);
|
||||||
|
}
|
||||||
|
canvas_draw_str(canvas, yes_x, btn_y, "Yes");
|
||||||
|
if(app->page.selected_index == 0) {
|
||||||
|
canvas_set_color(canvas, ColorBlack);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw No
|
||||||
|
if(app->page.selected_index == 1) {
|
||||||
|
canvas_draw_box(canvas, no_x - 2, btn_y - 8, 20, 12);
|
||||||
|
canvas_set_color(canvas, ColorWhite);
|
||||||
|
}
|
||||||
|
canvas_draw_str(canvas, no_x, btn_y, "No");
|
||||||
|
if(app->page.selected_index == 1) {
|
||||||
|
canvas_set_color(canvas, ColorBlack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw result overlay
|
||||||
|
static void draw_result_overlay(Canvas* canvas, ResultMessage* result) {
|
||||||
|
// Semi-transparent background
|
||||||
|
canvas_set_color(canvas, ColorWhite);
|
||||||
|
canvas_draw_box(canvas, 10, 20, 108, 28);
|
||||||
|
canvas_set_color(canvas, ColorBlack);
|
||||||
|
canvas_draw_frame(canvas, 10, 20, 108, 28);
|
||||||
|
|
||||||
|
canvas_set_font(canvas, FontPrimary);
|
||||||
|
const char* status = result->success ? "OK" : "Error";
|
||||||
|
canvas_draw_str(canvas, 14, 32, status);
|
||||||
|
|
||||||
|
canvas_set_font(canvas, FontSecondary);
|
||||||
|
canvas_draw_str(canvas, 14, 44, result->message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw welcome/connecting screen
|
||||||
static void draw_welcome(Canvas* canvas, CanMonitorApp* app) {
|
static void draw_welcome(Canvas* canvas, CanMonitorApp* app) {
|
||||||
canvas_clear(canvas);
|
canvas_clear(canvas);
|
||||||
|
|
||||||
// Title
|
|
||||||
canvas_set_font(canvas, FontPrimary);
|
canvas_set_font(canvas, FontPrimary);
|
||||||
canvas_draw_str_aligned(canvas, 64, 8, AlignCenter, AlignCenter, "CAN Monitor");
|
canvas_draw_str_aligned(canvas, 64, 8, AlignCenter, AlignCenter, "CAN Monitor");
|
||||||
|
|
||||||
canvas_set_font(canvas, FontSecondary);
|
canvas_set_font(canvas, FontSecondary);
|
||||||
|
canvas_draw_str_aligned(canvas, 64, 22, AlignCenter, AlignCenter, "RPI5 Dynamic UI");
|
||||||
|
|
||||||
// Subtitle
|
|
||||||
canvas_draw_str_aligned(canvas, 64, 22, AlignCenter, AlignCenter, "RPI5 CAN Sniffer");
|
|
||||||
|
|
||||||
// Status based on connection state
|
|
||||||
furi_mutex_acquire(app->mutex, FuriWaitForever);
|
furi_mutex_acquire(app->mutex, FuriWaitForever);
|
||||||
ConnectionState state = app->conn_state;
|
ConnectionState state = app->conn_state;
|
||||||
char ip_buf[IP_BUFFER_SIZE];
|
char ip_buf[32];
|
||||||
strncpy(ip_buf, app->stats.ip_address, IP_BUFFER_SIZE);
|
strncpy(ip_buf, app->ip_address, sizeof(ip_buf));
|
||||||
furi_mutex_release(app->mutex);
|
furi_mutex_release(app->mutex);
|
||||||
|
|
||||||
switch(state) {
|
switch(state) {
|
||||||
@@ -283,139 +590,167 @@ static void draw_welcome(Canvas* canvas, CanMonitorApp* app) {
|
|||||||
snprintf(buf, sizeof(buf), "RPI5: %s", ip_buf);
|
snprintf(buf, sizeof(buf), "RPI5: %s", ip_buf);
|
||||||
canvas_draw_str_aligned(canvas, 64, 46, AlignCenter, AlignCenter, buf);
|
canvas_draw_str_aligned(canvas, 64, 46, AlignCenter, AlignCenter, buf);
|
||||||
}
|
}
|
||||||
canvas_draw_str_aligned(canvas, 64, 58, AlignCenter, AlignCenter, "Press RIGHT >");
|
canvas_draw_str_aligned(canvas, 64, 58, AlignCenter, AlignCenter, "Waiting for data...");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw stats page
|
|
||||||
static void draw_stats(Canvas* canvas, CanMonitorApp* app) {
|
|
||||||
char buf[64];
|
|
||||||
|
|
||||||
canvas_clear(canvas);
|
|
||||||
|
|
||||||
// Header
|
|
||||||
canvas_set_font(canvas, FontPrimary);
|
|
||||||
canvas_draw_str(canvas, 10, 10, "CAN Monitor");
|
|
||||||
|
|
||||||
// Navigation hint
|
|
||||||
canvas_draw_str(canvas, 0, 10, "<");
|
|
||||||
|
|
||||||
// Connection indicator
|
|
||||||
furi_mutex_acquire(app->mutex, FuriWaitForever);
|
|
||||||
bool data_ok = app->stats.data_received;
|
|
||||||
furi_mutex_release(app->mutex);
|
|
||||||
|
|
||||||
if(data_ok) {
|
|
||||||
canvas_draw_disc(canvas, 120, 6, 4);
|
|
||||||
} else {
|
|
||||||
canvas_draw_circle(canvas, 120, 6, 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separator
|
|
||||||
canvas_draw_line(canvas, 0, 14, 128, 14);
|
|
||||||
|
|
||||||
canvas_set_font(canvas, FontSecondary);
|
|
||||||
|
|
||||||
furi_mutex_acquire(app->mutex, FuriWaitForever);
|
|
||||||
|
|
||||||
// IP Address
|
|
||||||
if(strlen(app->stats.ip_address) > 0) {
|
|
||||||
snprintf(buf, sizeof(buf), "IP: %s", app->stats.ip_address);
|
|
||||||
} else {
|
|
||||||
snprintf(buf, sizeof(buf), "IP: ---");
|
|
||||||
}
|
|
||||||
canvas_draw_str(canvas, 0, 26, buf);
|
|
||||||
|
|
||||||
// Total frames
|
|
||||||
snprintf(buf, sizeof(buf), "Total frames: %lu", (unsigned long)app->stats.total_frames);
|
|
||||||
canvas_draw_str(canvas, 0, 38, buf);
|
|
||||||
|
|
||||||
// Pending frames
|
|
||||||
snprintf(buf, sizeof(buf), "Pending: %lu", (unsigned long)app->stats.pending_frames);
|
|
||||||
canvas_draw_str(canvas, 0, 50, buf);
|
|
||||||
|
|
||||||
// Processed frames
|
|
||||||
snprintf(buf, sizeof(buf), "Processed: %lu", (unsigned long)app->stats.processed_frames);
|
|
||||||
canvas_draw_str(canvas, 4, 62, buf);
|
|
||||||
|
|
||||||
furi_mutex_release(app->mutex);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw callback
|
// Draw callback
|
||||||
static void draw_callback(Canvas* canvas, void* ctx) {
|
static void draw_callback(Canvas* canvas, void* ctx) {
|
||||||
CanMonitorApp* app = ctx;
|
CanMonitorApp* app = ctx;
|
||||||
|
|
||||||
switch(app->current_page) {
|
furi_mutex_acquire(app->mutex, FuriWaitForever);
|
||||||
case PageWelcome:
|
|
||||||
|
// Check if we should show welcome screen
|
||||||
|
if(app->conn_state != StateConnected || !app->page.data_valid) {
|
||||||
|
furi_mutex_release(app->mutex);
|
||||||
draw_welcome(canvas, app);
|
draw_welcome(canvas, app);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas_clear(canvas);
|
||||||
|
canvas_set_font(canvas, FontPrimary);
|
||||||
|
|
||||||
|
// Draw header
|
||||||
|
draw_header(canvas, app);
|
||||||
|
|
||||||
|
// Draw content based on page type
|
||||||
|
switch(app->page.page_type) {
|
||||||
|
case PageTypeInfo:
|
||||||
|
draw_info_page(canvas, app);
|
||||||
break;
|
break;
|
||||||
case PageStats:
|
case PageTypeMenu:
|
||||||
draw_stats(canvas, app);
|
draw_menu_page(canvas, app);
|
||||||
|
break;
|
||||||
|
case PageTypeConfirm:
|
||||||
|
draw_confirm_page(canvas, app);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw footer
|
||||||
|
draw_footer(canvas, app->page.page_type, app->page.total_pages, app->page.page_index);
|
||||||
|
|
||||||
|
// Draw result overlay if present
|
||||||
|
if(app->result.has_result) {
|
||||||
|
draw_result_overlay(canvas, &app->result);
|
||||||
|
}
|
||||||
|
|
||||||
|
furi_mutex_release(app->mutex);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input callback
|
// Input callback
|
||||||
static void input_callback(InputEvent* event, void* ctx) {
|
static void input_callback(InputEvent* event, void* ctx) {
|
||||||
CanMonitorApp* app = ctx;
|
CanMonitorApp* app = ctx;
|
||||||
|
|
||||||
if(event->type == InputTypeShort) {
|
if(event->type != InputTypeShort && event->type != InputTypeRepeat) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
furi_mutex_acquire(app->mutex, FuriWaitForever);
|
furi_mutex_acquire(app->mutex, FuriWaitForever);
|
||||||
|
|
||||||
switch(event->key) {
|
// Handle welcome screen input
|
||||||
case InputKeyOk:
|
if(app->conn_state == StateDisconnected) {
|
||||||
if(app->current_page == PageWelcome && app->conn_state == StateDisconnected) {
|
if(event->key == InputKeyOk) {
|
||||||
// Start handshake
|
|
||||||
app->send_init = true;
|
app->send_init = true;
|
||||||
|
} else if(event->key == InputKeyBack) {
|
||||||
|
app->running = false;
|
||||||
|
}
|
||||||
|
furi_mutex_release(app->mutex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle connected state input
|
||||||
|
if(app->conn_state == StateConnected && app->page.data_valid) {
|
||||||
|
switch(event->key) {
|
||||||
|
case InputKeyUp:
|
||||||
|
if(app->page.page_type == PageTypeMenu && app->page.selected_index > 0) {
|
||||||
|
app->page.selected_index--;
|
||||||
|
view_port_update(app->view_port);
|
||||||
|
} else if(app->page.page_type == PageTypeConfirm) {
|
||||||
|
app->page.selected_index = 0; // Yes
|
||||||
|
view_port_update(app->view_port);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case InputKeyRight:
|
case InputKeyDown:
|
||||||
// Only allow if connected
|
if(app->page.page_type == PageTypeMenu &&
|
||||||
if(app->current_page == PageWelcome && app->conn_state == StateConnected) {
|
app->page.selected_index < app->page.action_count - 1) {
|
||||||
app->current_page = PageStats;
|
app->page.selected_index++;
|
||||||
|
view_port_update(app->view_port);
|
||||||
|
} else if(app->page.page_type == PageTypeConfirm) {
|
||||||
|
app->page.selected_index = 1; // No
|
||||||
view_port_update(app->view_port);
|
view_port_update(app->view_port);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case InputKeyLeft:
|
case InputKeyLeft:
|
||||||
if(app->current_page == PageStats) {
|
// Navigate to previous page
|
||||||
app->current_page = PageWelcome;
|
uart_send(app, "CMD:NAV:prev\n");
|
||||||
view_port_update(app->view_port);
|
break;
|
||||||
|
|
||||||
|
case InputKeyRight:
|
||||||
|
// Navigate to next page
|
||||||
|
uart_send(app, "CMD:NAV:next\n");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case InputKeyOk:
|
||||||
|
if(app->page.page_type == PageTypeMenu) {
|
||||||
|
// Send selection
|
||||||
|
char cmd[32];
|
||||||
|
snprintf(cmd, sizeof(cmd), "CMD:SELECT:%d\n", app->page.selected_index);
|
||||||
|
uart_send(app, cmd);
|
||||||
|
} else if(app->page.page_type == PageTypeConfirm) {
|
||||||
|
if(app->page.selected_index == 0) {
|
||||||
|
uart_send(app, "CMD:CONFIRM\n");
|
||||||
|
} else {
|
||||||
|
uart_send(app, "CMD:CANCEL\n");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case InputKeyBack:
|
case InputKeyBack:
|
||||||
if(app->current_page == PageWelcome) {
|
if(app->page.page_type == PageTypeConfirm) {
|
||||||
app->running = false;
|
// Cancel confirmation
|
||||||
} else {
|
uart_send(app, "CMD:CANCEL\n");
|
||||||
app->current_page = PageWelcome;
|
} else if(app->page.page_index == 0) {
|
||||||
|
// On first page, disconnect
|
||||||
|
uart_send(app, "STOP:flipper\n");
|
||||||
|
app->conn_state = StateDisconnected;
|
||||||
|
clear_page_content(&app->page);
|
||||||
view_port_update(app->view_port);
|
view_port_update(app->view_port);
|
||||||
|
} else {
|
||||||
|
// Go to previous page
|
||||||
|
uart_send(app, "CMD:NAV:prev\n");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
} else if(event->key == InputKeyBack) {
|
||||||
|
// Disconnect and exit
|
||||||
|
if(app->conn_state != StateDisconnected) {
|
||||||
|
uart_send(app, "STOP:flipper\n");
|
||||||
|
}
|
||||||
|
app->running = false;
|
||||||
|
}
|
||||||
|
|
||||||
furi_mutex_release(app->mutex);
|
furi_mutex_release(app->mutex);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Main entry point
|
// Main entry point
|
||||||
int32_t can_monitor_app(void* p) {
|
int32_t can_monitor_app(void* p) {
|
||||||
UNUSED(p);
|
UNUSED(p);
|
||||||
|
|
||||||
FURI_LOG_I(TAG, "Starting CAN Monitor");
|
FURI_LOG_I(TAG, "Starting CAN Monitor (Dynamic UI)");
|
||||||
|
|
||||||
// Allocate app
|
// Allocate app
|
||||||
CanMonitorApp* app = malloc(sizeof(CanMonitorApp));
|
CanMonitorApp* app = malloc(sizeof(CanMonitorApp));
|
||||||
memset(app, 0, sizeof(CanMonitorApp));
|
memset(app, 0, sizeof(CanMonitorApp));
|
||||||
app->running = true;
|
app->running = true;
|
||||||
app->current_page = PageWelcome;
|
|
||||||
app->conn_state = StateDisconnected;
|
app->conn_state = StateDisconnected;
|
||||||
app->send_init = false;
|
app->send_init = false;
|
||||||
|
clear_page_content(&app->page);
|
||||||
|
|
||||||
// Mutex
|
// Mutex
|
||||||
app->mutex = furi_mutex_alloc(FuriMutexTypeNormal);
|
app->mutex = furi_mutex_alloc(FuriMutexTypeNormal);
|
||||||
@@ -440,7 +775,7 @@ int32_t can_monitor_app(void* p) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start worker
|
// Start worker
|
||||||
app->worker_thread = furi_thread_alloc_ex("CanMonitorWorker", 1024, worker_thread, app);
|
app->worker_thread = furi_thread_alloc_ex("CanMonitorWorker", 2048, worker_thread, app);
|
||||||
furi_thread_start(app->worker_thread);
|
furi_thread_start(app->worker_thread);
|
||||||
|
|
||||||
// Init GUI
|
// Init GUI
|
||||||
@@ -450,7 +785,7 @@ int32_t can_monitor_app(void* p) {
|
|||||||
view_port_input_callback_set(app->view_port, input_callback, app);
|
view_port_input_callback_set(app->view_port, input_callback, app);
|
||||||
gui_add_view_port(app->gui, app->view_port, GuiLayerFullscreen);
|
gui_add_view_port(app->gui, app->view_port, GuiLayerFullscreen);
|
||||||
|
|
||||||
FURI_LOG_I(TAG, "GUI initialized, waiting for user input");
|
FURI_LOG_I(TAG, "GUI initialized");
|
||||||
|
|
||||||
// Main loop
|
// Main loop
|
||||||
while(app->running) {
|
while(app->running) {
|
||||||
@@ -460,7 +795,7 @@ int32_t can_monitor_app(void* p) {
|
|||||||
FURI_LOG_I(TAG, "Shutting down");
|
FURI_LOG_I(TAG, "Shutting down");
|
||||||
|
|
||||||
// Send disconnect signal
|
// Send disconnect signal
|
||||||
if(app->conn_state == StateConnected) {
|
if(app->conn_state != StateDisconnected) {
|
||||||
uart_send(app, "STOP:flipper\n");
|
uart_send(app, "STOP:flipper\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user