update code and add flipper zero integration

This commit is contained in:
2026-01-29 23:36:36 +03:00
parent d4ffce28d5
commit a96328d4e6
11 changed files with 2307 additions and 5 deletions

View File

@@ -0,0 +1,7 @@
"""Flipper Zero UART communication module."""
from .protocol import FlipperProtocol, PageType, Page
from .server import FlipperServer
from .pages import PageManager
__all__ = ["FlipperProtocol", "PageType", "Page", "FlipperServer", "PageManager"]

View File

@@ -0,0 +1,357 @@
"""Page definitions and dynamic content generators."""
from typing import List, Dict, Callable, Optional, Any
from dataclasses import dataclass
from enum import Enum
import socket
import os
from .protocol import Page, PageType
class ActionID(Enum):
"""Action identifiers for menu items."""
RECONNECT_OBD = "reconnect_obd"
RESTART_SERVICE = "restart_service"
REBOOT_SYSTEM = "reboot_system"
SHUTDOWN_SYSTEM = "shutdown_system"
CLEAR_CACHE = "clear_cache"
TOGGLE_DEBUG = "toggle_debug"
@dataclass
class PageDefinition:
"""Page definition with content generator."""
page_type: PageType
title: str
generator: Callable[["PageManager"], Page]
actions: Optional[List[ActionID]] = None
class PageManager:
"""Manages page content and navigation."""
def __init__(self):
self._pages: List[PageDefinition] = []
self._current_index: int = 0
self._pending_action: Optional[ActionID] = None
self._data_providers: Dict[str, Callable[[], Any]] = {}
self._action_handlers: Dict[ActionID, Callable[[], bool]] = {}
self._debug_enabled: bool = False
self._register_default_pages()
def _register_default_pages(self) -> None:
"""Register default page definitions."""
# Page 0: Live Vehicle Data
self._pages.append(PageDefinition(
page_type=PageType.INFO,
title="Live Data",
generator=self._generate_live_data_page,
))
# Page 1: Statistics
self._pages.append(PageDefinition(
page_type=PageType.INFO,
title="Statistics",
generator=self._generate_stats_page,
))
# Page 2: System Info
self._pages.append(PageDefinition(
page_type=PageType.INFO,
title="System Info",
generator=self._generate_system_page,
))
# Page 3: Actions Menu
self._pages.append(PageDefinition(
page_type=PageType.MENU,
title="Actions",
generator=self._generate_actions_page,
actions=[
ActionID.RECONNECT_OBD,
ActionID.CLEAR_CACHE,
ActionID.REBOOT_SYSTEM,
ActionID.SHUTDOWN_SYSTEM,
],
))
# Page 4: Confirm (dynamic)
self._pages.append(PageDefinition(
page_type=PageType.CONFIRM,
title="Confirm",
generator=self._generate_confirm_page,
))
@property
def total_pages(self) -> int:
"""Total number of pages (excluding hidden confirm page)."""
return len(self._pages) - 1
@property
def current_index(self) -> int:
"""Current page index."""
return self._current_index
def set_data_provider(self, name: str, provider: Callable[[], Any]) -> None:
"""Register a data provider function.
Args:
name: Provider name (e.g., 'vehicle_state', 'poller_stats')
provider: Callable that returns current data
"""
self._data_providers[name] = provider
def set_action_handler(self, action_id: ActionID, handler: Callable[[], bool]) -> None:
"""Register an action handler.
Args:
action_id: Action identifier
handler: Callable that executes action, returns True on success
"""
self._action_handlers[action_id] = handler
def get_data(self, name: str, default: Any = None) -> Any:
"""Get data from a provider.
Args:
name: Provider name
default: Default value if provider not found
Returns:
Data from provider or default
"""
provider = self._data_providers.get(name)
if provider:
try:
return provider()
except Exception:
pass
return default
def get_current_page(self) -> Page:
"""Get current page content."""
if 0 <= self._current_index < len(self._pages):
page_def = self._pages[self._current_index]
return page_def.generator(self)
return Page(PageType.INFO, "Error", ["Invalid page index"])
def navigate_next(self) -> bool:
"""Navigate to next page.
Returns:
True if navigation occurred
"""
if self._current_index < self.total_pages - 1:
self._current_index += 1
return True
return False
def navigate_prev(self) -> bool:
"""Navigate to previous page.
Returns:
True if navigation occurred
"""
if self._current_index > 0:
self._current_index -= 1
return True
return False
def select_action(self, index: int) -> Optional[ActionID]:
"""Select a menu action by index.
Args:
index: Action index (0-3)
Returns:
ActionID if requires confirmation, None otherwise
"""
page_def = self._pages[self._current_index]
if page_def.page_type != PageType.MENU or not page_def.actions:
return None
if 0 <= index < len(page_def.actions):
action_id = page_def.actions[index]
# Actions requiring confirmation
if action_id in (ActionID.REBOOT_SYSTEM, ActionID.SHUTDOWN_SYSTEM):
self._pending_action = action_id
self._current_index = len(self._pages) - 1 # Go to confirm page
return action_id
# Execute directly
return action_id
return None
def execute_action(self, action_id: ActionID) -> tuple[bool, str]:
"""Execute an action.
Args:
action_id: Action to execute
Returns:
Tuple of (success, message)
"""
handler = self._action_handlers.get(action_id)
if handler:
try:
success = handler()
if success:
return True, self._get_action_success_message(action_id)
else:
return False, "Action failed"
except Exception as e:
return False, str(e)[:32]
return False, "No handler"
def confirm_action(self) -> tuple[bool, str]:
"""Confirm pending action.
Returns:
Tuple of (success, message)
"""
if self._pending_action:
action = self._pending_action
self._pending_action = None
self._current_index = 3 # Back to actions menu
return self.execute_action(action)
return False, "No pending action"
def cancel_action(self) -> None:
"""Cancel pending action."""
self._pending_action = None
self._current_index = 3 # Back to actions menu
def _get_action_success_message(self, action_id: ActionID) -> str:
"""Get success message for action."""
messages = {
ActionID.RECONNECT_OBD: "OBD2 reconnected",
ActionID.RESTART_SERVICE: "Service restarted",
ActionID.REBOOT_SYSTEM: "Rebooting...",
ActionID.SHUTDOWN_SYSTEM: "Shutting down...",
ActionID.CLEAR_CACHE: "Cache cleared",
ActionID.TOGGLE_DEBUG: "Debug toggled",
}
return messages.get(action_id, "Done")
# Page generators
def _generate_live_data_page(self, mgr: "PageManager") -> Page:
"""Generate live vehicle data page."""
state = mgr.get_data("vehicle_state")
lines = []
if state:
rpm = state.rpm
speed = state.speed
coolant = state.coolant_temp
throttle = state.throttle
fuel = state.fuel_level
lines.append(f"RPM: {rpm:.0f}" if rpm is not None else "RPM: ---")
lines.append(f"Speed: {speed:.0f} km/h" if speed is not None else "Speed: --- km/h")
lines.append(f"Coolant: {coolant:.0f} C" if coolant is not None else "Coolant: --- C")
lines.append(f"Throttle: {throttle:.1f}%" if throttle is not None else "Throttle: ---%")
lines.append(f"Fuel: {fuel:.1f}%" if fuel is not None else "Fuel: ---%")
else:
lines = ["No connection", "to OBD2", "", "Check CAN bus", "connection"]
return Page(PageType.INFO, "Live Data", lines)
def _generate_stats_page(self, mgr: "PageManager") -> Page:
"""Generate statistics page."""
stats = mgr.get_data("poller_stats", {})
uptime = mgr.get_data("uptime", 0)
queries = stats.get("queries", 0)
successes = stats.get("successes", 0)
rate = (successes / queries * 100) if queries > 0 else 0
hours = int(uptime // 3600)
minutes = int((uptime % 3600) // 60)
lines = [
f"Queries: {queries}",
f"Success: {successes}",
f"Rate: {rate:.1f}%",
f"Failures: {stats.get('failures', 0)}",
f"Uptime: {hours}h {minutes}m",
]
return Page(PageType.INFO, "Statistics", lines)
def _generate_system_page(self, mgr: "PageManager") -> Page:
"""Generate system info page."""
ip = self._get_ip_address()
cpu_temp = self._get_cpu_temp()
mem = self._get_memory_usage()
lines = [
f"IP: {ip}",
f"CPU: {cpu_temp:.1f} C" if cpu_temp else "CPU: --- C",
f"Mem: {mem:.1f}%" if mem else "Mem: ---%",
f"CAN: {mgr.get_data('can_interface', 'can0')}",
f"Debug: {'ON' if mgr._debug_enabled else 'OFF'}",
]
return Page(PageType.INFO, "System Info", lines)
def _generate_actions_page(self, mgr: "PageManager") -> Page:
"""Generate actions menu page."""
actions = [
"Reconnect OBD2",
"Clear PID Cache",
"Reboot System",
"Shutdown System",
]
return Page(PageType.MENU, "Actions", actions=actions)
def _generate_confirm_page(self, mgr: "PageManager") -> Page:
"""Generate confirmation page."""
if mgr._pending_action == ActionID.REBOOT_SYSTEM:
lines = ["Reboot system?", "", "All data will", "be lost"]
title = "Confirm Reboot"
elif mgr._pending_action == ActionID.SHUTDOWN_SYSTEM:
lines = ["Shutdown system?", "", "Manual restart", "required"]
title = "Confirm Shutdown"
else:
lines = ["Confirm action?"]
title = "Confirm"
return Page(PageType.CONFIRM, title, lines)
@staticmethod
def _get_ip_address() -> str:
"""Get local IP address."""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return "Unknown"
@staticmethod
def _get_cpu_temp() -> Optional[float]:
"""Get CPU temperature (Linux only)."""
try:
with open("/sys/class/thermal/thermal_zone0/temp", "r") as f:
return int(f.read().strip()) / 1000.0
except Exception:
return None
@staticmethod
def _get_memory_usage() -> Optional[float]:
"""Get memory usage percentage."""
try:
with open("/proc/meminfo", "r") as f:
lines = f.readlines()
total = int(lines[0].split()[1])
available = int(lines[2].split()[1])
return (1 - available / total) * 100
except Exception:
return None

View File

@@ -0,0 +1,152 @@
"""Flipper Zero UART protocol definitions."""
from dataclasses import dataclass, field
from enum import Enum
from typing import List, Optional, Callable
import re
class PageType(Enum):
"""Page type enum matching Flipper side."""
INFO = "info"
MENU = "menu"
CONFIRM = "confirm"
@dataclass
class Page:
"""Page content structure.
Attributes:
page_type: Type of page (info, menu, confirm)
title: Page title (max 24 chars)
lines: Content lines for info pages (max 5 lines, 32 chars each)
actions: Menu actions for menu pages (max 4 actions, 32 chars each)
selected_index: Currently selected item index
"""
page_type: PageType
title: str
lines: List[str] = field(default_factory=list)
actions: List[str] = field(default_factory=list)
selected_index: int = 0
def __post_init__(self):
"""Validate and truncate fields."""
self.title = self.title[:24]
self.lines = [line[:32] for line in self.lines[:5]]
self.actions = [action[:32] for action in self.actions[:4]]
class CommandType(Enum):
"""Command types from Flipper."""
INIT = "INIT"
STOP = "STOP"
NAV_NEXT = "NAV_NEXT"
NAV_PREV = "NAV_PREV"
SELECT = "SELECT"
CONFIRM = "CONFIRM"
CANCEL = "CANCEL"
SCROLL_UP = "SCROLL_UP"
SCROLL_DOWN = "SCROLL_DOWN"
REFRESH = "REFRESH"
@dataclass
class Command:
"""Parsed command from Flipper."""
cmd_type: CommandType
param: Optional[str] = None
class FlipperProtocol:
"""UART protocol encoder/decoder for Flipper communication."""
# Command patterns
CMD_PATTERNS = {
r"^INIT:flipper$": CommandType.INIT,
r"^STOP:flipper$": CommandType.STOP,
r"^CMD:NAV:next$": CommandType.NAV_NEXT,
r"^CMD:NAV:prev$": CommandType.NAV_PREV,
r"^CMD:SELECT:(\d+)$": CommandType.SELECT,
r"^CMD:CONFIRM$": CommandType.CONFIRM,
r"^CMD:CANCEL$": CommandType.CANCEL,
r"^CMD:SCROLL:up$": CommandType.SCROLL_UP,
r"^CMD:SCROLL:down$": CommandType.SCROLL_DOWN,
r"^CMD:REFRESH$": CommandType.REFRESH,
}
@staticmethod
def encode_page(page: Page, index: int, total: int) -> str:
"""Encode page content to UART message.
Format: PAGE:<idx>/<total>|<type>|<title>|<lines>|<actions>|<selected>
Args:
page: Page content
index: Current page index (0-based)
total: Total number of pages
Returns:
Encoded string ready for UART transmission
"""
lines_str = ";".join(page.lines) if page.lines else ""
actions_str = ";".join(page.actions) if page.actions else ""
msg = (
f"PAGE:{index}/{total}|"
f"{page.page_type.value}|"
f"{page.title}|"
f"{lines_str}|"
f"{actions_str}|"
f"{page.selected_index}"
)
return msg + "\n"
@staticmethod
def encode_ack(device_name: str, ip_address: str) -> str:
"""Encode ACK response.
Args:
device_name: Device identifier
ip_address: IP address to display
Returns:
Encoded ACK string
"""
return f"ACK:{device_name},ip={ip_address}\n"
@staticmethod
def encode_result(success: bool, message: str) -> str:
"""Encode result message.
Args:
success: True for OK, False for ERROR
message: Result message (max 32 chars)
Returns:
Encoded result string
"""
status = "OK" if success else "ERROR"
return f"RESULT:{status}|{message[:32]}\n"
@classmethod
def parse_command(cls, line: str) -> Optional[Command]:
"""Parse incoming command from Flipper.
Args:
line: Raw line received via UART
Returns:
Parsed Command or None if invalid
"""
line = line.strip()
if not line:
return None
for pattern, cmd_type in cls.CMD_PATTERNS.items():
match = re.match(pattern, line)
if match:
param = match.group(1) if match.lastindex else None
return Command(cmd_type=cmd_type, param=param)
return None

View File

@@ -0,0 +1,415 @@
"""Flipper Zero UART server with handshake support."""
import threading
import time
import subprocess
from typing import Optional, Callable
from pathlib import Path
from enum import Enum
try:
import serial
except ImportError:
serial = None
from .protocol import FlipperProtocol, CommandType, Command, Page, PageType
from .pages import PageManager, ActionID
from ..logger import get_logger
class ServerState(Enum):
"""Server connection state."""
IDLE = "idle" # Waiting for INIT
CONNECTED = "connected" # INIT received, ACK sent, sending PAGE updates
DISCONNECTING = "disconnecting" # STOP received, cleaning up
class FlipperServer:
"""UART server for Flipper Zero communication with handshake support.
Handshake flow:
1. Flipper sends INIT:flipper
2. Server responds with ACK:<device>,ip=<ip>
3. Server starts sending PAGE updates every 500ms
4. Flipper can reconnect anytime with new INIT
5. Flipper sends STOP:flipper to disconnect gracefully
Timeout handling:
- Flipper expects ACK within 3 seconds of INIT
- Flipper expects PAGE within 5 seconds of ACK
- Flipper expects PAGE updates every 5 seconds
- Server detects stale connection if no commands for 10 seconds
"""
DEFAULT_BAUDRATE = 115200
DEFAULT_PORT = "/dev/serial0" # RPi5 GPIO UART
# Timing constants
PAGE_UPDATE_INTERVAL = 0.5 # Send PAGE every 500ms
CONNECTION_TIMEOUT = 10.0 # Consider disconnected after 10s of silence
def __init__(
self,
port: str = DEFAULT_PORT,
baudrate: int = DEFAULT_BAUDRATE,
device_name: str = "rpi5",
):
"""Initialize Flipper server.
Args:
port: Serial port path
baudrate: UART baudrate (default: 115200)
device_name: Device name for ACK messages
"""
self.port = port
self.baudrate = baudrate
self.device_name = device_name
self._serial: Optional["serial.Serial"] = None
self._running = False
self._state = ServerState.IDLE
self._thread: Optional[threading.Thread] = None
self._lock = threading.Lock()
self._page_manager = PageManager()
self._last_page_sent = 0.0
self._last_command_received = 0.0
self._logger = get_logger("obd2_client.flipper")
self._ip_address = "0.0.0.0"
self._register_default_actions()
@property
def page_manager(self) -> PageManager:
"""Get page manager for configuration."""
return self._page_manager
@property
def is_connected(self) -> bool:
"""Check if Flipper is connected."""
return self._state == ServerState.CONNECTED
def set_ip_address(self, ip: str) -> None:
"""Set IP address for ACK messages."""
self._ip_address = ip
def _register_default_actions(self) -> None:
"""Register default action handlers."""
self._page_manager.set_action_handler(
ActionID.REBOOT_SYSTEM,
self._action_reboot,
)
self._page_manager.set_action_handler(
ActionID.SHUTDOWN_SYSTEM,
self._action_shutdown,
)
self._page_manager.set_action_handler(
ActionID.CLEAR_CACHE,
self._action_clear_cache,
)
def start(self) -> None:
"""Start the UART server."""
if serial is None:
raise RuntimeError(
"pyserial is not installed. Run: pip install pyserial"
)
if self._thread is not None and self._thread.is_alive():
self._logger.warning("Server already running")
return
self._running = True
self._state = ServerState.IDLE
self._thread = threading.Thread(target=self._server_loop, daemon=True)
self._thread.start()
self._logger.info(f"Flipper server started on {self.port}")
def stop(self, timeout: float = 2.0) -> None:
"""Stop the UART server."""
self._running = False
if self._thread is not None:
self._thread.join(timeout=timeout)
self._thread = None
self._close_serial()
self._logger.info("Flipper server stopped")
def _server_loop(self) -> None:
"""Main server loop."""
while self._running:
try:
if not self._open_serial():
time.sleep(1.0)
continue
# Process incoming commands
self._process_incoming()
# Send periodic PAGE updates if connected
if self._state == ServerState.CONNECTED:
self._send_page_updates()
self._check_connection_timeout()
except Exception as e:
self._logger.error(f"Server error: {e}")
self._close_serial()
self._state = ServerState.IDLE
time.sleep(1.0)
def _open_serial(self) -> bool:
"""Open serial port."""
if self._serial is not None and self._serial.is_open:
return True
try:
self._serial = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=0.05, # Short timeout for non-blocking reads
write_timeout=1.0,
)
self._logger.debug(f"Serial port {self.port} opened")
return True
except Exception as e:
self._logger.debug(f"Failed to open serial port: {e}")
return False
def _close_serial(self) -> None:
"""Close serial port."""
if self._serial is not None:
try:
self._serial.close()
except Exception:
pass
self._serial = None
self._state = ServerState.IDLE
def _process_incoming(self) -> None:
"""Process incoming UART data."""
if self._serial is None or not self._serial.is_open:
return
try:
if self._serial.in_waiting > 0:
line = self._serial.readline().decode("utf-8", errors="ignore")
if line:
self._handle_line(line)
except Exception as e:
self._logger.debug(f"Read error: {e}")
def _handle_line(self, line: str) -> None:
"""Handle received line."""
cmd = FlipperProtocol.parse_command(line)
if cmd is None:
self._logger.debug(f"Unknown command: {line.strip()}")
return
self._last_command_received = time.time()
self._logger.debug(f"Received: {cmd.cmd_type.name}")
# INIT can be received at any time (reconnection support)
if cmd.cmd_type == CommandType.INIT:
self._handle_init()
return
# STOP can be received at any time
if cmd.cmd_type == CommandType.STOP:
self._handle_stop()
return
# Other commands require connected state
if self._state != ServerState.CONNECTED:
self._logger.debug(f"Ignoring {cmd.cmd_type.name} - not connected")
return
if cmd.cmd_type == CommandType.NAV_NEXT:
self._handle_nav_next()
elif cmd.cmd_type == CommandType.NAV_PREV:
self._handle_nav_prev()
elif cmd.cmd_type == CommandType.SELECT:
self._handle_select(cmd.param)
elif cmd.cmd_type == CommandType.CONFIRM:
self._handle_confirm()
elif cmd.cmd_type == CommandType.CANCEL:
self._handle_cancel()
elif cmd.cmd_type == CommandType.SCROLL_UP:
self._handle_scroll_up()
elif cmd.cmd_type == CommandType.SCROLL_DOWN:
self._handle_scroll_down()
elif cmd.cmd_type == CommandType.REFRESH:
self._handle_refresh()
def _handle_init(self) -> None:
"""Handle INIT command - start/restart connection.
This can be called multiple times (reconnection).
Always responds with ACK and starts sending PAGE updates.
"""
self._logger.info("Flipper connected (INIT received)")
# Reset state for fresh connection
self._state = ServerState.CONNECTED
self._last_command_received = time.time()
self._last_page_sent = 0.0 # Force immediate PAGE send
# Reset page manager to first page
self._page_manager._current_index = 0
# Send ACK immediately
ack = FlipperProtocol.encode_ack(self.device_name, self._ip_address)
self._send(ack)
# Send initial PAGE immediately after ACK
self._send_current_page()
def _handle_stop(self) -> None:
"""Handle STOP command - graceful disconnect."""
self._logger.info("Flipper disconnected (STOP received)")
self._state = ServerState.IDLE
def _handle_nav_next(self) -> None:
"""Handle navigation to next page."""
if self._page_manager.navigate_next():
self._send_current_page()
def _handle_nav_prev(self) -> None:
"""Handle navigation to previous page."""
if self._page_manager.navigate_prev():
self._send_current_page()
def _handle_select(self, param: Optional[str]) -> None:
"""Handle menu selection."""
if param is None:
return
try:
index = int(param)
except ValueError:
return
action_id = self._page_manager.select_action(index)
if action_id is None:
return
# If action requires confirmation, send confirm page
if action_id in (ActionID.REBOOT_SYSTEM, ActionID.SHUTDOWN_SYSTEM):
self._send_current_page()
else:
# Execute immediately
success, message = self._page_manager.execute_action(action_id)
self._send_result(success, message)
def _handle_confirm(self) -> None:
"""Handle confirmation."""
success, message = self._page_manager.confirm_action()
self._send_result(success, message)
self._send_current_page()
def _handle_cancel(self) -> None:
"""Handle cancel."""
self._page_manager.cancel_action()
self._send_current_page()
def _handle_scroll_up(self) -> None:
"""Handle scroll up on info pages."""
# Currently info pages don't have scrolling, but protocol supports it
self._logger.debug("Scroll up (not implemented)")
def _handle_scroll_down(self) -> None:
"""Handle scroll down on info pages."""
# Currently info pages don't have scrolling, but protocol supports it
self._logger.debug("Scroll down (not implemented)")
def _handle_refresh(self) -> None:
"""Handle refresh request."""
self._send_current_page()
def _send_page_updates(self) -> None:
"""Send periodic page updates to keep Flipper alive."""
current_time = time.time()
if (current_time - self._last_page_sent) >= self.PAGE_UPDATE_INTERVAL:
self._send_current_page()
def _check_connection_timeout(self) -> None:
"""Check if connection has timed out (no commands from Flipper)."""
if self._last_command_received == 0:
return
elapsed = time.time() - self._last_command_received
if elapsed > self.CONNECTION_TIMEOUT:
self._logger.warning(f"Connection timeout ({elapsed:.1f}s since last command)")
self._state = ServerState.IDLE
self._last_command_received = 0
def _send_current_page(self) -> None:
"""Send current page content."""
page = self._page_manager.get_current_page()
# Adjust total pages for confirm page (hidden from navigation)
total = self._page_manager.total_pages
index = self._page_manager.current_index
# If on confirm page, still show correct index
if index >= total:
# Confirm page - show as special page
pass
msg = FlipperProtocol.encode_page(page, index, total)
if self._send(msg):
self._last_page_sent = time.time()
def _send_result(self, success: bool, message: str) -> None:
"""Send result message."""
msg = FlipperProtocol.encode_result(success, message)
self._send(msg)
def _send(self, data: str) -> bool:
"""Send data over UART."""
if self._serial is None or not self._serial.is_open:
return False
try:
with self._lock:
self._serial.write(data.encode("utf-8"))
self._serial.flush()
self._logger.debug(f"TX: {data.strip()}")
return True
except Exception as e:
self._logger.error(f"Send error: {e}")
return False
# Action handlers
def _action_reboot(self) -> bool:
"""Reboot system action."""
self._logger.info("Rebooting system...")
try:
subprocess.Popen(["sudo", "reboot"])
return True
except Exception as e:
self._logger.error(f"Reboot failed: {e}")
return False
def _action_shutdown(self) -> bool:
"""Shutdown system action."""
self._logger.info("Shutting down system...")
try:
subprocess.Popen(["sudo", "shutdown", "-h", "now"])
return True
except Exception as e:
self._logger.error(f"Shutdown failed: {e}")
return False
def _action_clear_cache(self) -> bool:
"""Clear PID cache action."""
cache_file = Path(__file__).parent.parent.parent / "pid_cache.json"
try:
if cache_file.exists():
cache_file.unlink()
return True
except Exception as e:
self._logger.error(f"Clear cache failed: {e}")
return False

View File

@@ -14,15 +14,18 @@ from .obd2.protocol import OBD2Protocol
from .obd2.scanner import OBD2Scanner
from .vehicle.state import VehicleState
from .vehicle.poller import VehiclePoller
from .flipper.server import FlipperServer
from .flipper.pages import ActionID
class OBD2Client:
"""Main OBD2 client application."""
def __init__(self, config: Config):
def __init__(self, config: Config, flipper_port: str = None):
self.config = config
self._logger = get_logger("obd2_client")
self._running = False
self._start_time = time.time()
self.can_interface = CANInterface(
interface=config.can.interface,
@@ -51,6 +54,38 @@ class OBD2Client:
slow_pids=config.polling.slow_pids,
)
# Flipper Zero server
self.flipper_server = None
if flipper_port:
self.flipper_server = FlipperServer(port=flipper_port)
self._setup_flipper_integration()
def _setup_flipper_integration(self) -> None:
"""Set up Flipper Zero data providers and action handlers."""
pm = self.flipper_server.page_manager
# Data providers
pm.set_data_provider("vehicle_state", lambda: self.state)
pm.set_data_provider("poller_stats", lambda: self.poller.get_stats())
pm.set_data_provider("uptime", lambda: time.time() - self._start_time)
pm.set_data_provider("can_interface", lambda: self.config.can.interface)
# Action handlers
pm.set_action_handler(ActionID.RECONNECT_OBD, self._action_reconnect_obd)
def _action_reconnect_obd(self) -> bool:
"""Reconnect to OBD2."""
try:
self.poller.stop()
self.can_interface.disconnect()
time.sleep(0.5)
self.can_interface.connect()
self.poller.start()
return True
except Exception as e:
self._logger.error(f"Reconnect failed: {e}")
return False
def run(self, scan_only: bool = False, monitor: bool = True) -> None:
"""Run the OBD2 client.
@@ -59,11 +94,17 @@ class OBD2Client:
monitor: If True, continuously monitor and display values
"""
self._running = True
self._start_time = time.time()
signal.signal(signal.SIGINT, self._signal_handler)
signal.signal(signal.SIGTERM, self._signal_handler)
try:
# Start Flipper server (if configured)
if self.flipper_server:
self._logger.info("Starting Flipper Zero server...")
self.flipper_server.start()
self._logger.info(f"Connecting to {self.config.can.interface}...")
self.can_interface.connect()
@@ -72,6 +113,12 @@ class OBD2Client:
if not supported:
self._logger.error("No PIDs supported or no response from ECU")
if not self.flipper_server:
return
# Continue running for Flipper even without OBD2
self._logger.info("Running in Flipper-only mode...")
while self._running:
time.sleep(1.0)
return
self.scanner.print_supported_pids()
@@ -183,6 +230,9 @@ class OBD2Client:
"""Clean shutdown."""
self._logger.info("Shutting down...")
if self.flipper_server:
self.flipper_server.stop()
if self.poller.is_running:
self.poller.stop()
@@ -223,6 +273,13 @@ def main():
help="Only scan for supported PIDs and exit",
)
parser.add_argument(
"--flipper",
default=None,
metavar="PORT",
help="Enable Flipper Zero server on specified serial port (e.g., /dev/serial0)",
)
parser.add_argument(
"--debug",
action="store_true",
@@ -242,7 +299,7 @@ def main():
if args.virtual:
config.can.virtual = True
client = OBD2Client(config)
client = OBD2Client(config, flipper_port=args.flipper)
client.run(scan_only=args.scan_only)