update code and add flipper zero integration
This commit is contained in:
@@ -53,8 +53,42 @@ python -m src.main --interface vcan0 --virtual
|
||||
| `-c, --config` | Путь к config.json |
|
||||
| `-v, --virtual` | Использовать виртуальный CAN |
|
||||
| `--scan-only` | Только сканировать PID |
|
||||
| `--flipper PORT` | Включить Flipper Zero сервер на указанном порту |
|
||||
| `--debug` | Включить отладочный вывод |
|
||||
|
||||
## Интеграция с Flipper Zero
|
||||
|
||||
### Подключение
|
||||
|
||||
```
|
||||
RPi5 Flipper Zero
|
||||
GPIO14 (TX) --------> RX (pin 14)
|
||||
GPIO15 (RX) <-------- TX (pin 13)
|
||||
GND ---------- GND (pin 18)
|
||||
```
|
||||
|
||||
### Запуск с Flipper
|
||||
|
||||
```bash
|
||||
python -m src.main --interface can0 --flipper /dev/serial0
|
||||
```
|
||||
|
||||
### Страницы на Flipper
|
||||
|
||||
| Страница | Тип | Описание |
|
||||
|----------|-----|----------|
|
||||
| Live Data | Info | RPM, Speed, Coolant, Throttle, Fuel |
|
||||
| Statistics | Info | Queries, Success rate, Uptime |
|
||||
| System Info | Info | IP, CPU temp, Memory, CAN interface |
|
||||
| Actions | Menu | Reconnect, Clear cache, Reboot, Shutdown |
|
||||
|
||||
### Управление
|
||||
|
||||
- **←/→** - переключение страниц
|
||||
- **↑/↓** - выбор пункта меню / прокрутка
|
||||
- **OK** - подтверждение действия
|
||||
- **Back** - отмена / возврат
|
||||
|
||||
## Поддерживаемые PID
|
||||
|
||||
| PID | Параметр | Единицы |
|
||||
@@ -118,9 +152,13 @@ obd2_client/
|
||||
│ │ ├── pids.py # Определения PID
|
||||
│ │ ├── protocol.py # OBD2 запросы/ответы
|
||||
│ │ └── scanner.py # Автодетект PID
|
||||
│ └── vehicle/
|
||||
│ ├── state.py # Состояние авто
|
||||
│ └── poller.py # Циклический опрос
|
||||
│ ├── vehicle/
|
||||
│ │ ├── state.py # Состояние авто
|
||||
│ │ └── poller.py # Циклический опрос
|
||||
│ └── flipper/
|
||||
│ ├── protocol.py # UART протокол
|
||||
│ ├── pages.py # Генераторы страниц
|
||||
│ └── server.py # UART сервер
|
||||
├── config.json
|
||||
├── requirements.txt
|
||||
└── README.md
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
python-can>=4.0.0
|
||||
pyserial>=3.5
|
||||
|
||||
7
obd2_client/src/flipper/__init__.py
Normal file
7
obd2_client/src/flipper/__init__.py
Normal 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"]
|
||||
357
obd2_client/src/flipper/pages.py
Normal file
357
obd2_client/src/flipper/pages.py
Normal 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
|
||||
152
obd2_client/src/flipper/protocol.py
Normal file
152
obd2_client/src/flipper/protocol.py
Normal 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
|
||||
415
obd2_client/src/flipper/server.py
Normal file
415
obd2_client/src/flipper/server.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user