From a96328d4e6ddbfaa4f1a30d44b7cb9d48031c94b Mon Sep 17 00:00:00 2001 From: Alexander Poletaev Date: Thu, 29 Jan 2026 23:36:36 +0300 Subject: [PATCH] update code and add flipper zero integration --- .gitignore | 91 +++ flip_monitor/README.md | 278 +++++++++ flip_monitor/application.fam | 11 + flip_monitor/can_monitor.c | 895 ++++++++++++++++++++++++++++ obd2_client/README.md | 44 +- obd2_client/requirements.txt | 1 + obd2_client/src/flipper/__init__.py | 7 + obd2_client/src/flipper/pages.py | 357 +++++++++++ obd2_client/src/flipper/protocol.py | 152 +++++ obd2_client/src/flipper/server.py | 415 +++++++++++++ obd2_client/src/main.py | 61 +- 11 files changed, 2307 insertions(+), 5 deletions(-) create mode 100644 .gitignore create mode 100644 flip_monitor/README.md create mode 100644 flip_monitor/application.fam create mode 100644 flip_monitor/can_monitor.c create mode 100644 obd2_client/src/flipper/__init__.py create mode 100644 obd2_client/src/flipper/pages.py create mode 100644 obd2_client/src/flipper/protocol.py create mode 100644 obd2_client/src/flipper/server.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f9dacd --- /dev/null +++ b/.gitignore @@ -0,0 +1,91 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual Environments +venv/ +env/ +ENV/ +env.bak/ +venv.bak/ +.venv/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Environment variables +.env +.env.local +.env.*.local + +# Logs +*.log +logs/ + +# Database files (SQLite) +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite3 + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.hypothesis/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# Project specific +can_offline.db* +can_edge.log* +config.json + +.cursor/ +CLAUDE.md +unleashed* diff --git a/flip_monitor/README.md b/flip_monitor/README.md new file mode 100644 index 0000000..81ea835 --- /dev/null +++ b/flip_monitor/README.md @@ -0,0 +1,278 @@ +# CAN Monitor for Flipper Zero + +Dynamic multi-page monitor application for Raspberry Pi 5 via UART. + +## Features + +- **Dynamic Page System** - Pages are fully controlled by RPi5 +- **Multiple Page Types**: + - **Info** - Read-only status display + - **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:/|||<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 + +### Wiring Diagram + +``` +Flipper Zero Raspberry Pi 5 +----------- --------------- +Pin 13 (TX) ----> GPIO 15 (RX) - Pin 10 +Pin 14 (RX) <---- GPIO 14 (TX) - Pin 8 +Pin 8/11/18 (GND) ---- GND - Pin 6 +``` + +**Note:** Cross TX/RX connections (Flipper TX -> RPi RX) + +### Flipper Zero GPIO Pinout + +``` +Pin 13 = TX (USART) +Pin 14 = RX (USART) +Pin 8/11/18 = GND +``` + +### RPI5 GPIO (using /dev/ttyAMA0) + +``` +GPIO 14 = TX (Pin 8) +GPIO 15 = RX (Pin 10) +GND = Pin 6, 9, 14, 20, 25, 30, 34, 39 +``` + +## Building the Application + +### Prerequisites + +1. Clone Flipper Zero firmware: +```bash +git clone --recursive https://github.com/DarkFlippers/unleashed-firmware.git +cd unleashed-firmware +``` + +2. Copy the `flip_monitor` folder to `applications_user/`: +```bash +cp -r /path/to/carpibord/flip_monitor applications_user/can_monitor +``` + +### Build + +```bash +# Build the FAP +./fbt fap_can_monitor + +# Or build all external apps +./fbt fap_dist +``` + +The compiled `.fap` file will be in `build/f7-firmware-D/.extapps/can_monitor.fap` + +### Install + +Copy the `.fap` file to your Flipper Zero SD card: +``` +SD Card/apps/GPIO/can_monitor.fap +``` + +## RPI5 Configuration + +### 1. Enable UART + +Add to `/boot/firmware/config.txt`: +``` +enable_uart=1 +dtoverlay=uart0 +``` + +Disable serial console: +```bash +sudo raspi-config +# Interface Options -> Serial Port -> No (login shell) -> Yes (hardware) +``` + +Reboot after changes. + +### 2. Install Dependencies + +```bash +pip install pyserial smbus2 gpiozero +``` + +### 3. Configure CAN Sniffer + +Add to `can_sniffer/src/config.json`: +```json +{ + "flipper": { + "enabled": true, + "device": "/dev/ttyAMA0", + "baudrate": 115200, + "send_interval": 1.0 + } +} +``` + +Or use environment variables: +```bash +export CAN_SNIFFER_FLIPPER__ENABLED=true +export CAN_SNIFFER_FLIPPER__DEVICE=/dev/ttyAMA0 +``` + +### 4. Run CAN Sniffer + +```bash +cd can_sniffer/src +python main.py +``` + +## Adding Custom Pages + +On RPi5 side, create a new page class: + +```python +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()}" + ] +``` + +Register in FlipperHandler: + +```python +# In flipper_handler.py +from flipper.pages.my_custom import MyCustomPage + +# In _setup_pages(): +self._page_manager.register_page(MyCustomPage()) +``` + +## Troubleshooting + +### No connection + +1. Check wiring (TX/RX crossed correctly) +2. Verify UART is enabled on RPI5: `ls -la /dev/ttyAMA0` +3. Check config: `flipper.enabled = true` +4. Test UART manually: +```bash +# On RPI5 +echo "ACK:rpi5,ip=test" > /dev/ttyAMA0 +``` + +### Permission denied on /dev/ttyAMA0 + +Add user to dialout group: +```bash +sudo usermod -a -G dialout $USER +# Then logout and login again +``` + +### Flipper shows "Waiting for data..." + +- Data is sent every 1 second (configurable) +- Connection timeout is 5 seconds +- 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 + +MIT License diff --git a/flip_monitor/application.fam b/flip_monitor/application.fam new file mode 100644 index 0000000..e7fbdbe --- /dev/null +++ b/flip_monitor/application.fam @@ -0,0 +1,11 @@ +App( + appid="can_monitor", + name="CAN Monitor", + apptype=FlipperAppType.EXTERNAL, + entry_point="can_monitor_app", + stack_size=4 * 1024, + fap_category="GPIO", + fap_author="carpibord", + fap_version="2.0", + fap_description="Dynamic multi-page RPI5 monitor with CAN stats, UPS, system info and actions", +) diff --git a/flip_monitor/can_monitor.c b/flip_monitor/can_monitor.c new file mode 100644 index 0000000..1f50071 --- /dev/null +++ b/flip_monitor/can_monitor.c @@ -0,0 +1,895 @@ +/** + * CAN Monitor for Flipper Zero - Dynamic UI Version + * + * Multi-page application with dynamic content from RPI5. + * + * Protocol: + * RPi -> Flipper: + * PAGE:<idx>/<total>|<type>|<title>|<lines>|<actions>|<selected> + * ACK:<device>,ip=<ip> + * RESULT:<status>|<message> + * + * Flipper -> RPi: + * INIT:flipper + * STOP:flipper + * CMD:NAV:next / CMD:NAV:prev + * CMD:SELECT:<index> + * CMD:CONFIRM / CMD:CANCEL + * CMD:REFRESH + * + * Wiring: + * RPI5 TX (GPIO14, Pin 8) -> Flipper RX (Pin 14) + * RPI5 RX (GPIO15, Pin 10) <- Flipper TX (Pin 13) + * RPI5 GND (Pin 6) -> Flipper GND (Pin 8/11/18) + */ + +#include <furi.h> +#include <furi_hal.h> +#include <gui/gui.h> +#include <gui/view_port.h> +#include <gui/elements.h> +#include <expansion/expansion.h> + +#include <stdlib.h> +#include <string.h> + +#define TAG "CANMonitor" +#define UART_BAUD 115200 +#define RX_BUFFER_SIZE 512 +#define MAX_LINES 5 +#define MAX_ACTIONS 4 +#define MAX_LINE_LENGTH 32 +#define MAX_TITLE_LENGTH 24 + +// Connection state +typedef enum { + StateDisconnected, + StateConnecting, + StateConnected, +} ConnectionState; + +// Page types +typedef enum { + PageTypeInfo, + PageTypeMenu, + PageTypeConfirm, +} PageType; + +// Page content received from RPi +typedef struct { + uint8_t page_index; + uint8_t total_pages; + PageType page_type; + char title[MAX_TITLE_LENGTH]; + 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; +} PageContent; + +// Result message +typedef struct { + bool has_result; + bool success; + char message[MAX_LINE_LENGTH]; + uint32_t show_until_tick; +} ResultMessage; + +// App context +typedef struct { + Gui* gui; + ViewPort* view_port; + FuriMutex* mutex; + + FuriHalSerialHandle* serial; + FuriStreamBuffer* rx_stream; + FuriThread* worker_thread; + + PageContent page; + ResultMessage result; + ConnectionState conn_state; + char ip_address[32]; + + uint32_t connecting_start_tick; // Time when INIT was sent or ACK received + + volatile bool running; + volatile bool send_init; +} CanMonitorApp; + +// Forward declarations +static void draw_callback(Canvas* canvas, void* ctx); +static void input_callback(InputEvent* event, void* ctx); + +// Send data via UART +static void uart_send(CanMonitorApp* app, const char* data) { + if(app->serial) { + furi_hal_serial_tx(app->serial, (uint8_t*)data, strlen(data)); + FURI_LOG_I(TAG, "TX: %s", data); + } +} + +// Clear page content +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> +// Returns true if page changed (different page_index or page_type) +static bool parse_page(const char* line, PageContent* page, bool* page_changed) { + if(strncmp(line, "PAGE:", 5) != 0) { + return false; + } + + const char* p = line + 5; + char* end; + + // Save old values to detect page change + uint8_t old_page_index = page->page_index; + PageType old_page_type = page->page_type; + bool was_valid = page->data_valid; + + // 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; + + PageType new_page_type; + if(strncmp(p, "info", type_end - p) == 0) { + new_page_type = PageTypeInfo; + } else if(strncmp(p, "menu", type_end - p) == 0) { + new_page_type = PageTypeMenu; + } else if(strncmp(p, "confirm", type_end - p) == 0) { + new_page_type = PageTypeConfirm; + } else { + new_page_type = PageTypeInfo; + } + page->page_type = new_page_type; + p = type_end + 1; + + // Detect if page changed (index OR type changed) + bool is_page_change = !was_valid || + (page->page_index != old_page_index) || + (new_page_type != old_page_type); + if(page_changed) { + *page_changed = is_page_change; + } + + // 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 from server + uint8_t server_selected = (uint8_t)strtoul(p, NULL, 10); + + // IMPORTANT: Only update selected_index on page change + // For menu/confirm pages, preserve local selection during updates + if(is_page_change) { + page->selected_index = server_selected; + } else if(page->page_type == PageTypeInfo) { + // Info pages don't have selection, always use server value + page->selected_index = server_selected; + } + // For menu/confirm on same page: keep old_selected (local user choice) + + 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) { + return false; + } + + const char* ip = strstr(line, "ip="); + if(ip) { + ip += 3; + size_t len = 0; + while(ip[len] && ip[len] != ',' && ip[len] != '\n' && ip[len] != '\r') { + len++; + } + if(len > 0 && len < ip_size) { + memcpy(ip_out, ip, len); + ip_out[len] = '\0'; + } + } + + return true; +} + +// Parse RESULT message +static bool parse_result(const char* line, ResultMessage* result) { + if(strncmp(line, "RESULT:", 7) != 0) { + return false; + } + + const char* p = line + 7; + + // Parse status + if(strncmp(p, "OK|", 3) == 0) { + result->success = true; + p += 3; + } else if(strncmp(p, "ERROR|", 6) == 0) { + result->success = false; + p += 6; + } else { + return false; + } + + // Parse message + size_t len = strlen(p); + if(len >= MAX_LINE_LENGTH) { + len = MAX_LINE_LENGTH - 1; + } + memcpy(result->message, p, len); + result->message[len] = '\0'; + + result->has_result = true; + result->show_until_tick = furi_get_tick() + 3000; // Show for 3 seconds + + return true; +} + +// Process received line +static void process_line(CanMonitorApp* app, const char* line) { + FURI_LOG_I(TAG, "RX: %s", line); + + furi_mutex_acquire(app->mutex, FuriWaitForever); + + if(app->conn_state == StateConnecting) { + // Waiting for ACK + if(parse_ack(line, app->ip_address, sizeof(app->ip_address))) { + FURI_LOG_I(TAG, "ACK received, IP: %s", app->ip_address); + app->conn_state = StateConnected; + // Reset tick for data waiting timeout + app->connecting_start_tick = furi_get_tick(); + } + } else if(app->conn_state == StateConnected) { + // Parse PAGE or RESULT + if(line[0] == 'P') { + bool page_changed = false; + parse_page(line, &app->page, &page_changed); + if(page_changed) { + FURI_LOG_I(TAG, "Page changed to %d", app->page.page_index); + } + } else if(line[0] == 'R') { + parse_result(line, &app->result); + } + } + + furi_mutex_release(app->mutex); + view_port_update(app->view_port); +} + +// UART RX callback +static void uart_callback(FuriHalSerialHandle* handle, FuriHalSerialRxEvent event, void* ctx) { + CanMonitorApp* app = ctx; + + if(event == FuriHalSerialRxEventData) { + uint8_t byte = furi_hal_serial_async_rx(handle); + furi_stream_buffer_send(app->rx_stream, &byte, 1, 0); + } +} + +// Worker thread +static int32_t worker_thread(void* ctx) { + CanMonitorApp* app = ctx; + char buffer[RX_BUFFER_SIZE]; + size_t idx = 0; + uint32_t last_rx_tick = 0; + + FURI_LOG_I(TAG, "Worker started"); + + while(app->running) { + // Check if we need to send INIT + if(app->send_init) { + app->send_init = false; + uart_send(app, "INIT:flipper\n"); + + furi_mutex_acquire(app->mutex, FuriWaitForever); + app->conn_state = StateConnecting; + app->connecting_start_tick = furi_get_tick(); + clear_page_content(&app->page); + furi_mutex_release(app->mutex); + view_port_update(app->view_port); + } + + // Receive data + uint8_t byte; + size_t len = furi_stream_buffer_receive(app->rx_stream, &byte, 1, 50); + + if(len > 0) { + last_rx_tick = furi_get_tick(); + + if(byte == '\n' || byte == '\r') { + if(idx > 0) { + buffer[idx] = '\0'; + process_line(app, buffer); + idx = 0; + } + } else if(idx < RX_BUFFER_SIZE - 1) { + buffer[idx++] = byte; + } + } else { + // Timeout: parse partial data after 500ms + if(idx > 0 && (furi_get_tick() - last_rx_tick) > 500) { + buffer[idx] = '\0'; + process_line(app, buffer); + idx = 0; + } + } + + // Check data timeout (5 sec) - only when connected + furi_mutex_acquire(app->mutex, FuriWaitForever); + if(app->conn_state == StateConnected && app->page.data_valid) { + if((furi_get_tick() - app->page.last_update_tick) > 5000) { + // Data timeout - RPi likely restarted, go back to disconnected state + // This allows user to reconnect by pressing OK + FURI_LOG_W(TAG, "Data timeout, disconnecting"); + app->conn_state = StateDisconnected; + app->page.data_valid = false; + clear_page_content(&app->page); + view_port_update(app->view_port); + } + } + + // Check connecting timeout (3 sec) - ACK not received + if(app->conn_state == StateConnecting) { + if((furi_get_tick() - app->connecting_start_tick) > 3000) { + // Connection timeout - RPi not responding, go back to disconnected + FURI_LOG_W(TAG, "Connection timeout, no ACK received"); + app->conn_state = StateDisconnected; + view_port_update(app->view_port); + } + } + + // Check initial data timeout (5 sec) - ACK received but no PAGE data yet + if(app->conn_state == StateConnected && !app->page.data_valid) { + if((furi_get_tick() - app->connecting_start_tick) > 5000) { + // No data received after ACK - RPi not sending data + FURI_LOG_W(TAG, "Data wait timeout, no PAGE received after ACK"); + app->conn_state = StateDisconnected; + 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_LOG_I(TAG, "Worker stopped"); + return 0; +} + +// 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 = "Up/Dn=Scroll"; + break; + case PageTypeMenu: + hint = "Up/Dn OK=Select"; + break; + case PageTypeConfirm: + hint = "Up/Dn OK 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) { + canvas_clear(canvas); + + canvas_set_font(canvas, FontPrimary); + canvas_draw_str_aligned(canvas, 64, 8, AlignCenter, AlignCenter, "CAN Monitor"); + + canvas_set_font(canvas, FontSecondary); + canvas_draw_str_aligned(canvas, 64, 22, AlignCenter, AlignCenter, "RPI5 Dynamic UI"); + + furi_mutex_acquire(app->mutex, FuriWaitForever); + ConnectionState state = app->conn_state; + char ip_buf[32]; + strncpy(ip_buf, app->ip_address, sizeof(ip_buf)); + furi_mutex_release(app->mutex); + + switch(state) { + case StateDisconnected: + canvas_draw_str_aligned(canvas, 64, 38, AlignCenter, AlignCenter, "Press OK to connect"); + canvas_draw_str_aligned(canvas, 64, 50, AlignCenter, AlignCenter, "[OK]"); + break; + + case StateConnecting: + canvas_draw_str_aligned(canvas, 64, 38, AlignCenter, AlignCenter, "Connecting..."); + canvas_draw_str_aligned(canvas, 64, 50, AlignCenter, AlignCenter, "Waiting for RPI5"); + break; + + case StateConnected: + canvas_draw_str_aligned(canvas, 64, 34, AlignCenter, AlignCenter, "Connected!"); + if(strlen(ip_buf) > 0) { + char buf[48]; + snprintf(buf, sizeof(buf), "RPI5: %s", ip_buf); + canvas_draw_str_aligned(canvas, 64, 46, AlignCenter, AlignCenter, buf); + } + canvas_draw_str_aligned(canvas, 64, 58, AlignCenter, AlignCenter, "Waiting for data..."); + break; + } +} + +// Draw callback +static void draw_callback(Canvas* canvas, void* ctx) { + CanMonitorApp* app = ctx; + + furi_mutex_acquire(app->mutex, FuriWaitForever); + + // 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); + 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; + case PageTypeMenu: + draw_menu_page(canvas, app); + break; + case PageTypeConfirm: + draw_confirm_page(canvas, app); + 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 +static void input_callback(InputEvent* event, void* ctx) { + CanMonitorApp* app = ctx; + + if(event->type != InputTypeShort && event->type != InputTypeRepeat) { + return; + } + + furi_mutex_acquire(app->mutex, FuriWaitForever); + + // Handle welcome screen input + if(app->conn_state == StateDisconnected) { + if(event->key == InputKeyOk) { + 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 == PageTypeInfo) { + // Scroll up on info pages + uart_send(app, "CMD:SCROLL:up\n"); + } else 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; + + case InputKeyDown: + if(app->page.page_type == PageTypeInfo) { + // Scroll down on info pages + uart_send(app, "CMD:SCROLL:down\n"); + } else if(app->page.page_type == PageTypeMenu && + app->page.selected_index < app->page.action_count - 1) { + 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); + } + break; + + case InputKeyLeft: + // Navigate to previous page + uart_send(app, "CMD:NAV:prev\n"); + 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; + + case InputKeyBack: + if(app->page.page_type == PageTypeConfirm) { + // Cancel confirmation + uart_send(app, "CMD:CANCEL\n"); + } 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); + } else { + // Go to previous page + uart_send(app, "CMD:NAV:prev\n"); + } + break; + + default: + 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); +} + +// Main entry point +int32_t can_monitor_app(void* p) { + UNUSED(p); + + FURI_LOG_I(TAG, "Starting CAN Monitor (Dynamic UI)"); + + // Allocate app + CanMonitorApp* app = malloc(sizeof(CanMonitorApp)); + memset(app, 0, sizeof(CanMonitorApp)); + app->running = true; + app->conn_state = StateDisconnected; + app->send_init = false; + clear_page_content(&app->page); + + // Mutex + app->mutex = furi_mutex_alloc(FuriMutexTypeNormal); + + // Disable expansion protocol + Expansion* expansion = furi_record_open(RECORD_EXPANSION); + expansion_disable(expansion); + furi_record_close(RECORD_EXPANSION); + + FURI_LOG_I(TAG, "Expansion disabled"); + + // Init UART + app->rx_stream = furi_stream_buffer_alloc(RX_BUFFER_SIZE, 1); + app->serial = furi_hal_serial_control_acquire(FuriHalSerialIdUsart); + + if(app->serial) { + furi_hal_serial_init(app->serial, UART_BAUD); + furi_hal_serial_async_rx_start(app->serial, uart_callback, app, false); + FURI_LOG_I(TAG, "UART initialized at %d baud", UART_BAUD); + } else { + FURI_LOG_E(TAG, "Failed to acquire UART"); + } + + // Start worker + app->worker_thread = furi_thread_alloc_ex("CanMonitorWorker", 2048, worker_thread, app); + furi_thread_start(app->worker_thread); + + // Init GUI + app->gui = furi_record_open(RECORD_GUI); + app->view_port = view_port_alloc(); + view_port_draw_callback_set(app->view_port, draw_callback, app); + view_port_input_callback_set(app->view_port, input_callback, app); + gui_add_view_port(app->gui, app->view_port, GuiLayerFullscreen); + + FURI_LOG_I(TAG, "GUI initialized"); + + // Main loop + while(app->running) { + furi_delay_ms(100); + } + + FURI_LOG_I(TAG, "Shutting down"); + + // Send disconnect signal + if(app->conn_state != StateDisconnected) { + uart_send(app, "STOP:flipper\n"); + } + + // Cleanup + furi_thread_join(app->worker_thread); + furi_thread_free(app->worker_thread); + + if(app->serial) { + furi_hal_serial_async_rx_stop(app->serial); + furi_hal_serial_deinit(app->serial); + furi_hal_serial_control_release(app->serial); + } + furi_stream_buffer_free(app->rx_stream); + + // Re-enable expansion + expansion = furi_record_open(RECORD_EXPANSION); + expansion_enable(expansion); + furi_record_close(RECORD_EXPANSION); + + view_port_enabled_set(app->view_port, false); + gui_remove_view_port(app->gui, app->view_port); + view_port_free(app->view_port); + furi_record_close(RECORD_GUI); + + furi_mutex_free(app->mutex); + free(app); + + FURI_LOG_I(TAG, "Bye!"); + + return 0; +} diff --git a/obd2_client/README.md b/obd2_client/README.md index 695dcc6..c59a159 100644 --- a/obd2_client/README.md +++ b/obd2_client/README.md @@ -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 diff --git a/obd2_client/requirements.txt b/obd2_client/requirements.txt index 9fbe0e7..892c2f5 100644 --- a/obd2_client/requirements.txt +++ b/obd2_client/requirements.txt @@ -1 +1,2 @@ python-can>=4.0.0 +pyserial>=3.5 diff --git a/obd2_client/src/flipper/__init__.py b/obd2_client/src/flipper/__init__.py new file mode 100644 index 0000000..fd8503c --- /dev/null +++ b/obd2_client/src/flipper/__init__.py @@ -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"] diff --git a/obd2_client/src/flipper/pages.py b/obd2_client/src/flipper/pages.py new file mode 100644 index 0000000..701112c --- /dev/null +++ b/obd2_client/src/flipper/pages.py @@ -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 diff --git a/obd2_client/src/flipper/protocol.py b/obd2_client/src/flipper/protocol.py new file mode 100644 index 0000000..de8c782 --- /dev/null +++ b/obd2_client/src/flipper/protocol.py @@ -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 diff --git a/obd2_client/src/flipper/server.py b/obd2_client/src/flipper/server.py new file mode 100644 index 0000000..33a3e1f --- /dev/null +++ b/obd2_client/src/flipper/server.py @@ -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 diff --git a/obd2_client/src/main.py b/obd2_client/src/main.py index dcd6a35..0de1d96 100644 --- a/obd2_client/src/main.py +++ b/obd2_client/src/main.py @@ -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)