update code and add flipper zero integration

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

91
.gitignore vendored Normal file
View File

@@ -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*

278
flip_monitor/README.md Normal file
View File

@@ -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:<idx>/<total>|<type>|<title>|<lines>|<actions>|<selected>
ACK:<device>,ip=<ip>
RESULT:<OK|ERROR>|<message>
```
**Examples:**
```
PAGE:0/4|info|CAN Statistics|Total: 12.3K;Processed: 12.1K;Queue: 142||0
PAGE:1/4|info|UPS Status|Bat: 85.2% [====];Voltage: 4.12V;Status: Charging||0
PAGE:2/4|info|System Info|CPU Temp: 52.1C;Power: 4.2W;Fan: 3200 RPM||0
PAGE:3/4|menu|Actions|Shutdown;Reboot;Cancel Shutdown||0
PAGE:3/4|confirm|Confirm Shutdown?|Are you sure?|Yes;No|1
ACK:rpi5,ip=192.168.1.100
RESULT:OK|Shutdown in 1 min
```
### Flipper -> RPi5
```
INIT:flipper - Start handshake
STOP:flipper - Disconnect
CMD:NAV:next - Next page
CMD:NAV:prev - Previous page
CMD:SELECT:<index> - Select menu item
CMD:CONFIRM - Confirm action
CMD:CANCEL - Cancel action
CMD:REFRESH - Request page update
```
## Hardware Connection
### 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

View File

@@ -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",
)

895
flip_monitor/can_monitor.c Normal file
View File

@@ -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;
}

View File

@@ -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

View File

@@ -1 +1,2 @@
python-can>=4.0.0
pyserial>=3.5

View File

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

View File

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

View File

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

View File

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

View File

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