update code and add flipper zero integration
This commit is contained in:
91
.gitignore
vendored
Normal file
91
.gitignore
vendored
Normal 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
278
flip_monitor/README.md
Normal 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
|
||||||
11
flip_monitor/application.fam
Normal file
11
flip_monitor/application.fam
Normal 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
895
flip_monitor/can_monitor.c
Normal 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;
|
||||||
|
}
|
||||||
@@ -53,8 +53,42 @@ python -m src.main --interface vcan0 --virtual
|
|||||||
| `-c, --config` | Путь к config.json |
|
| `-c, --config` | Путь к config.json |
|
||||||
| `-v, --virtual` | Использовать виртуальный CAN |
|
| `-v, --virtual` | Использовать виртуальный CAN |
|
||||||
| `--scan-only` | Только сканировать PID |
|
| `--scan-only` | Только сканировать PID |
|
||||||
|
| `--flipper PORT` | Включить Flipper Zero сервер на указанном порту |
|
||||||
| `--debug` | Включить отладочный вывод |
|
| `--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
|
||||||
|
|
||||||
| PID | Параметр | Единицы |
|
| PID | Параметр | Единицы |
|
||||||
@@ -118,9 +152,13 @@ obd2_client/
|
|||||||
│ │ ├── pids.py # Определения PID
|
│ │ ├── pids.py # Определения PID
|
||||||
│ │ ├── protocol.py # OBD2 запросы/ответы
|
│ │ ├── protocol.py # OBD2 запросы/ответы
|
||||||
│ │ └── scanner.py # Автодетект PID
|
│ │ └── scanner.py # Автодетект PID
|
||||||
│ └── vehicle/
|
│ ├── vehicle/
|
||||||
│ ├── state.py # Состояние авто
|
│ │ ├── state.py # Состояние авто
|
||||||
│ └── poller.py # Циклический опрос
|
│ │ └── poller.py # Циклический опрос
|
||||||
|
│ └── flipper/
|
||||||
|
│ ├── protocol.py # UART протокол
|
||||||
|
│ ├── pages.py # Генераторы страниц
|
||||||
|
│ └── server.py # UART сервер
|
||||||
├── config.json
|
├── config.json
|
||||||
├── requirements.txt
|
├── requirements.txt
|
||||||
└── README.md
|
└── README.md
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
python-can>=4.0.0
|
python-can>=4.0.0
|
||||||
|
pyserial>=3.5
|
||||||
|
|||||||
7
obd2_client/src/flipper/__init__.py
Normal file
7
obd2_client/src/flipper/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""Flipper Zero UART communication module."""
|
||||||
|
|
||||||
|
from .protocol import FlipperProtocol, PageType, Page
|
||||||
|
from .server import FlipperServer
|
||||||
|
from .pages import PageManager
|
||||||
|
|
||||||
|
__all__ = ["FlipperProtocol", "PageType", "Page", "FlipperServer", "PageManager"]
|
||||||
357
obd2_client/src/flipper/pages.py
Normal file
357
obd2_client/src/flipper/pages.py
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
"""Page definitions and dynamic content generators."""
|
||||||
|
|
||||||
|
from typing import List, Dict, Callable, Optional, Any
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
import socket
|
||||||
|
import os
|
||||||
|
|
||||||
|
from .protocol import Page, PageType
|
||||||
|
|
||||||
|
|
||||||
|
class ActionID(Enum):
|
||||||
|
"""Action identifiers for menu items."""
|
||||||
|
RECONNECT_OBD = "reconnect_obd"
|
||||||
|
RESTART_SERVICE = "restart_service"
|
||||||
|
REBOOT_SYSTEM = "reboot_system"
|
||||||
|
SHUTDOWN_SYSTEM = "shutdown_system"
|
||||||
|
CLEAR_CACHE = "clear_cache"
|
||||||
|
TOGGLE_DEBUG = "toggle_debug"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PageDefinition:
|
||||||
|
"""Page definition with content generator."""
|
||||||
|
page_type: PageType
|
||||||
|
title: str
|
||||||
|
generator: Callable[["PageManager"], Page]
|
||||||
|
actions: Optional[List[ActionID]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PageManager:
|
||||||
|
"""Manages page content and navigation."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._pages: List[PageDefinition] = []
|
||||||
|
self._current_index: int = 0
|
||||||
|
self._pending_action: Optional[ActionID] = None
|
||||||
|
self._data_providers: Dict[str, Callable[[], Any]] = {}
|
||||||
|
self._action_handlers: Dict[ActionID, Callable[[], bool]] = {}
|
||||||
|
self._debug_enabled: bool = False
|
||||||
|
|
||||||
|
self._register_default_pages()
|
||||||
|
|
||||||
|
def _register_default_pages(self) -> None:
|
||||||
|
"""Register default page definitions."""
|
||||||
|
|
||||||
|
# Page 0: Live Vehicle Data
|
||||||
|
self._pages.append(PageDefinition(
|
||||||
|
page_type=PageType.INFO,
|
||||||
|
title="Live Data",
|
||||||
|
generator=self._generate_live_data_page,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Page 1: Statistics
|
||||||
|
self._pages.append(PageDefinition(
|
||||||
|
page_type=PageType.INFO,
|
||||||
|
title="Statistics",
|
||||||
|
generator=self._generate_stats_page,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Page 2: System Info
|
||||||
|
self._pages.append(PageDefinition(
|
||||||
|
page_type=PageType.INFO,
|
||||||
|
title="System Info",
|
||||||
|
generator=self._generate_system_page,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Page 3: Actions Menu
|
||||||
|
self._pages.append(PageDefinition(
|
||||||
|
page_type=PageType.MENU,
|
||||||
|
title="Actions",
|
||||||
|
generator=self._generate_actions_page,
|
||||||
|
actions=[
|
||||||
|
ActionID.RECONNECT_OBD,
|
||||||
|
ActionID.CLEAR_CACHE,
|
||||||
|
ActionID.REBOOT_SYSTEM,
|
||||||
|
ActionID.SHUTDOWN_SYSTEM,
|
||||||
|
],
|
||||||
|
))
|
||||||
|
|
||||||
|
# Page 4: Confirm (dynamic)
|
||||||
|
self._pages.append(PageDefinition(
|
||||||
|
page_type=PageType.CONFIRM,
|
||||||
|
title="Confirm",
|
||||||
|
generator=self._generate_confirm_page,
|
||||||
|
))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_pages(self) -> int:
|
||||||
|
"""Total number of pages (excluding hidden confirm page)."""
|
||||||
|
return len(self._pages) - 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_index(self) -> int:
|
||||||
|
"""Current page index."""
|
||||||
|
return self._current_index
|
||||||
|
|
||||||
|
def set_data_provider(self, name: str, provider: Callable[[], Any]) -> None:
|
||||||
|
"""Register a data provider function.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Provider name (e.g., 'vehicle_state', 'poller_stats')
|
||||||
|
provider: Callable that returns current data
|
||||||
|
"""
|
||||||
|
self._data_providers[name] = provider
|
||||||
|
|
||||||
|
def set_action_handler(self, action_id: ActionID, handler: Callable[[], bool]) -> None:
|
||||||
|
"""Register an action handler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
action_id: Action identifier
|
||||||
|
handler: Callable that executes action, returns True on success
|
||||||
|
"""
|
||||||
|
self._action_handlers[action_id] = handler
|
||||||
|
|
||||||
|
def get_data(self, name: str, default: Any = None) -> Any:
|
||||||
|
"""Get data from a provider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Provider name
|
||||||
|
default: Default value if provider not found
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Data from provider or default
|
||||||
|
"""
|
||||||
|
provider = self._data_providers.get(name)
|
||||||
|
if provider:
|
||||||
|
try:
|
||||||
|
return provider()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
|
||||||
|
def get_current_page(self) -> Page:
|
||||||
|
"""Get current page content."""
|
||||||
|
if 0 <= self._current_index < len(self._pages):
|
||||||
|
page_def = self._pages[self._current_index]
|
||||||
|
return page_def.generator(self)
|
||||||
|
return Page(PageType.INFO, "Error", ["Invalid page index"])
|
||||||
|
|
||||||
|
def navigate_next(self) -> bool:
|
||||||
|
"""Navigate to next page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if navigation occurred
|
||||||
|
"""
|
||||||
|
if self._current_index < self.total_pages - 1:
|
||||||
|
self._current_index += 1
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def navigate_prev(self) -> bool:
|
||||||
|
"""Navigate to previous page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if navigation occurred
|
||||||
|
"""
|
||||||
|
if self._current_index > 0:
|
||||||
|
self._current_index -= 1
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def select_action(self, index: int) -> Optional[ActionID]:
|
||||||
|
"""Select a menu action by index.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
index: Action index (0-3)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ActionID if requires confirmation, None otherwise
|
||||||
|
"""
|
||||||
|
page_def = self._pages[self._current_index]
|
||||||
|
if page_def.page_type != PageType.MENU or not page_def.actions:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if 0 <= index < len(page_def.actions):
|
||||||
|
action_id = page_def.actions[index]
|
||||||
|
|
||||||
|
# Actions requiring confirmation
|
||||||
|
if action_id in (ActionID.REBOOT_SYSTEM, ActionID.SHUTDOWN_SYSTEM):
|
||||||
|
self._pending_action = action_id
|
||||||
|
self._current_index = len(self._pages) - 1 # Go to confirm page
|
||||||
|
return action_id
|
||||||
|
|
||||||
|
# Execute directly
|
||||||
|
return action_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
def execute_action(self, action_id: ActionID) -> tuple[bool, str]:
|
||||||
|
"""Execute an action.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
action_id: Action to execute
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message)
|
||||||
|
"""
|
||||||
|
handler = self._action_handlers.get(action_id)
|
||||||
|
if handler:
|
||||||
|
try:
|
||||||
|
success = handler()
|
||||||
|
if success:
|
||||||
|
return True, self._get_action_success_message(action_id)
|
||||||
|
else:
|
||||||
|
return False, "Action failed"
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)[:32]
|
||||||
|
return False, "No handler"
|
||||||
|
|
||||||
|
def confirm_action(self) -> tuple[bool, str]:
|
||||||
|
"""Confirm pending action.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message)
|
||||||
|
"""
|
||||||
|
if self._pending_action:
|
||||||
|
action = self._pending_action
|
||||||
|
self._pending_action = None
|
||||||
|
self._current_index = 3 # Back to actions menu
|
||||||
|
return self.execute_action(action)
|
||||||
|
return False, "No pending action"
|
||||||
|
|
||||||
|
def cancel_action(self) -> None:
|
||||||
|
"""Cancel pending action."""
|
||||||
|
self._pending_action = None
|
||||||
|
self._current_index = 3 # Back to actions menu
|
||||||
|
|
||||||
|
def _get_action_success_message(self, action_id: ActionID) -> str:
|
||||||
|
"""Get success message for action."""
|
||||||
|
messages = {
|
||||||
|
ActionID.RECONNECT_OBD: "OBD2 reconnected",
|
||||||
|
ActionID.RESTART_SERVICE: "Service restarted",
|
||||||
|
ActionID.REBOOT_SYSTEM: "Rebooting...",
|
||||||
|
ActionID.SHUTDOWN_SYSTEM: "Shutting down...",
|
||||||
|
ActionID.CLEAR_CACHE: "Cache cleared",
|
||||||
|
ActionID.TOGGLE_DEBUG: "Debug toggled",
|
||||||
|
}
|
||||||
|
return messages.get(action_id, "Done")
|
||||||
|
|
||||||
|
# Page generators
|
||||||
|
|
||||||
|
def _generate_live_data_page(self, mgr: "PageManager") -> Page:
|
||||||
|
"""Generate live vehicle data page."""
|
||||||
|
state = mgr.get_data("vehicle_state")
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
if state:
|
||||||
|
rpm = state.rpm
|
||||||
|
speed = state.speed
|
||||||
|
coolant = state.coolant_temp
|
||||||
|
throttle = state.throttle
|
||||||
|
fuel = state.fuel_level
|
||||||
|
|
||||||
|
lines.append(f"RPM: {rpm:.0f}" if rpm is not None else "RPM: ---")
|
||||||
|
lines.append(f"Speed: {speed:.0f} km/h" if speed is not None else "Speed: --- km/h")
|
||||||
|
lines.append(f"Coolant: {coolant:.0f} C" if coolant is not None else "Coolant: --- C")
|
||||||
|
lines.append(f"Throttle: {throttle:.1f}%" if throttle is not None else "Throttle: ---%")
|
||||||
|
lines.append(f"Fuel: {fuel:.1f}%" if fuel is not None else "Fuel: ---%")
|
||||||
|
else:
|
||||||
|
lines = ["No connection", "to OBD2", "", "Check CAN bus", "connection"]
|
||||||
|
|
||||||
|
return Page(PageType.INFO, "Live Data", lines)
|
||||||
|
|
||||||
|
def _generate_stats_page(self, mgr: "PageManager") -> Page:
|
||||||
|
"""Generate statistics page."""
|
||||||
|
stats = mgr.get_data("poller_stats", {})
|
||||||
|
uptime = mgr.get_data("uptime", 0)
|
||||||
|
|
||||||
|
queries = stats.get("queries", 0)
|
||||||
|
successes = stats.get("successes", 0)
|
||||||
|
rate = (successes / queries * 100) if queries > 0 else 0
|
||||||
|
|
||||||
|
hours = int(uptime // 3600)
|
||||||
|
minutes = int((uptime % 3600) // 60)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"Queries: {queries}",
|
||||||
|
f"Success: {successes}",
|
||||||
|
f"Rate: {rate:.1f}%",
|
||||||
|
f"Failures: {stats.get('failures', 0)}",
|
||||||
|
f"Uptime: {hours}h {minutes}m",
|
||||||
|
]
|
||||||
|
|
||||||
|
return Page(PageType.INFO, "Statistics", lines)
|
||||||
|
|
||||||
|
def _generate_system_page(self, mgr: "PageManager") -> Page:
|
||||||
|
"""Generate system info page."""
|
||||||
|
ip = self._get_ip_address()
|
||||||
|
cpu_temp = self._get_cpu_temp()
|
||||||
|
mem = self._get_memory_usage()
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"IP: {ip}",
|
||||||
|
f"CPU: {cpu_temp:.1f} C" if cpu_temp else "CPU: --- C",
|
||||||
|
f"Mem: {mem:.1f}%" if mem else "Mem: ---%",
|
||||||
|
f"CAN: {mgr.get_data('can_interface', 'can0')}",
|
||||||
|
f"Debug: {'ON' if mgr._debug_enabled else 'OFF'}",
|
||||||
|
]
|
||||||
|
|
||||||
|
return Page(PageType.INFO, "System Info", lines)
|
||||||
|
|
||||||
|
def _generate_actions_page(self, mgr: "PageManager") -> Page:
|
||||||
|
"""Generate actions menu page."""
|
||||||
|
actions = [
|
||||||
|
"Reconnect OBD2",
|
||||||
|
"Clear PID Cache",
|
||||||
|
"Reboot System",
|
||||||
|
"Shutdown System",
|
||||||
|
]
|
||||||
|
return Page(PageType.MENU, "Actions", actions=actions)
|
||||||
|
|
||||||
|
def _generate_confirm_page(self, mgr: "PageManager") -> Page:
|
||||||
|
"""Generate confirmation page."""
|
||||||
|
if mgr._pending_action == ActionID.REBOOT_SYSTEM:
|
||||||
|
lines = ["Reboot system?", "", "All data will", "be lost"]
|
||||||
|
title = "Confirm Reboot"
|
||||||
|
elif mgr._pending_action == ActionID.SHUTDOWN_SYSTEM:
|
||||||
|
lines = ["Shutdown system?", "", "Manual restart", "required"]
|
||||||
|
title = "Confirm Shutdown"
|
||||||
|
else:
|
||||||
|
lines = ["Confirm action?"]
|
||||||
|
title = "Confirm"
|
||||||
|
|
||||||
|
return Page(PageType.CONFIRM, title, lines)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_ip_address() -> str:
|
||||||
|
"""Get local IP address."""
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
ip = s.getsockname()[0]
|
||||||
|
s.close()
|
||||||
|
return ip
|
||||||
|
except Exception:
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_cpu_temp() -> Optional[float]:
|
||||||
|
"""Get CPU temperature (Linux only)."""
|
||||||
|
try:
|
||||||
|
with open("/sys/class/thermal/thermal_zone0/temp", "r") as f:
|
||||||
|
return int(f.read().strip()) / 1000.0
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_memory_usage() -> Optional[float]:
|
||||||
|
"""Get memory usage percentage."""
|
||||||
|
try:
|
||||||
|
with open("/proc/meminfo", "r") as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
total = int(lines[0].split()[1])
|
||||||
|
available = int(lines[2].split()[1])
|
||||||
|
return (1 - available / total) * 100
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
152
obd2_client/src/flipper/protocol.py
Normal file
152
obd2_client/src/flipper/protocol.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""Flipper Zero UART protocol definitions."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import List, Optional, Callable
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
class PageType(Enum):
|
||||||
|
"""Page type enum matching Flipper side."""
|
||||||
|
INFO = "info"
|
||||||
|
MENU = "menu"
|
||||||
|
CONFIRM = "confirm"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Page:
|
||||||
|
"""Page content structure.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
page_type: Type of page (info, menu, confirm)
|
||||||
|
title: Page title (max 24 chars)
|
||||||
|
lines: Content lines for info pages (max 5 lines, 32 chars each)
|
||||||
|
actions: Menu actions for menu pages (max 4 actions, 32 chars each)
|
||||||
|
selected_index: Currently selected item index
|
||||||
|
"""
|
||||||
|
page_type: PageType
|
||||||
|
title: str
|
||||||
|
lines: List[str] = field(default_factory=list)
|
||||||
|
actions: List[str] = field(default_factory=list)
|
||||||
|
selected_index: int = 0
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
"""Validate and truncate fields."""
|
||||||
|
self.title = self.title[:24]
|
||||||
|
self.lines = [line[:32] for line in self.lines[:5]]
|
||||||
|
self.actions = [action[:32] for action in self.actions[:4]]
|
||||||
|
|
||||||
|
|
||||||
|
class CommandType(Enum):
|
||||||
|
"""Command types from Flipper."""
|
||||||
|
INIT = "INIT"
|
||||||
|
STOP = "STOP"
|
||||||
|
NAV_NEXT = "NAV_NEXT"
|
||||||
|
NAV_PREV = "NAV_PREV"
|
||||||
|
SELECT = "SELECT"
|
||||||
|
CONFIRM = "CONFIRM"
|
||||||
|
CANCEL = "CANCEL"
|
||||||
|
SCROLL_UP = "SCROLL_UP"
|
||||||
|
SCROLL_DOWN = "SCROLL_DOWN"
|
||||||
|
REFRESH = "REFRESH"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Command:
|
||||||
|
"""Parsed command from Flipper."""
|
||||||
|
cmd_type: CommandType
|
||||||
|
param: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FlipperProtocol:
|
||||||
|
"""UART protocol encoder/decoder for Flipper communication."""
|
||||||
|
|
||||||
|
# Command patterns
|
||||||
|
CMD_PATTERNS = {
|
||||||
|
r"^INIT:flipper$": CommandType.INIT,
|
||||||
|
r"^STOP:flipper$": CommandType.STOP,
|
||||||
|
r"^CMD:NAV:next$": CommandType.NAV_NEXT,
|
||||||
|
r"^CMD:NAV:prev$": CommandType.NAV_PREV,
|
||||||
|
r"^CMD:SELECT:(\d+)$": CommandType.SELECT,
|
||||||
|
r"^CMD:CONFIRM$": CommandType.CONFIRM,
|
||||||
|
r"^CMD:CANCEL$": CommandType.CANCEL,
|
||||||
|
r"^CMD:SCROLL:up$": CommandType.SCROLL_UP,
|
||||||
|
r"^CMD:SCROLL:down$": CommandType.SCROLL_DOWN,
|
||||||
|
r"^CMD:REFRESH$": CommandType.REFRESH,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def encode_page(page: Page, index: int, total: int) -> str:
|
||||||
|
"""Encode page content to UART message.
|
||||||
|
|
||||||
|
Format: PAGE:<idx>/<total>|<type>|<title>|<lines>|<actions>|<selected>
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: Page content
|
||||||
|
index: Current page index (0-based)
|
||||||
|
total: Total number of pages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Encoded string ready for UART transmission
|
||||||
|
"""
|
||||||
|
lines_str = ";".join(page.lines) if page.lines else ""
|
||||||
|
actions_str = ";".join(page.actions) if page.actions else ""
|
||||||
|
|
||||||
|
msg = (
|
||||||
|
f"PAGE:{index}/{total}|"
|
||||||
|
f"{page.page_type.value}|"
|
||||||
|
f"{page.title}|"
|
||||||
|
f"{lines_str}|"
|
||||||
|
f"{actions_str}|"
|
||||||
|
f"{page.selected_index}"
|
||||||
|
)
|
||||||
|
return msg + "\n"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def encode_ack(device_name: str, ip_address: str) -> str:
|
||||||
|
"""Encode ACK response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_name: Device identifier
|
||||||
|
ip_address: IP address to display
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Encoded ACK string
|
||||||
|
"""
|
||||||
|
return f"ACK:{device_name},ip={ip_address}\n"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def encode_result(success: bool, message: str) -> str:
|
||||||
|
"""Encode result message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
success: True for OK, False for ERROR
|
||||||
|
message: Result message (max 32 chars)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Encoded result string
|
||||||
|
"""
|
||||||
|
status = "OK" if success else "ERROR"
|
||||||
|
return f"RESULT:{status}|{message[:32]}\n"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse_command(cls, line: str) -> Optional[Command]:
|
||||||
|
"""Parse incoming command from Flipper.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
line: Raw line received via UART
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed Command or None if invalid
|
||||||
|
"""
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for pattern, cmd_type in cls.CMD_PATTERNS.items():
|
||||||
|
match = re.match(pattern, line)
|
||||||
|
if match:
|
||||||
|
param = match.group(1) if match.lastindex else None
|
||||||
|
return Command(cmd_type=cmd_type, param=param)
|
||||||
|
|
||||||
|
return None
|
||||||
415
obd2_client/src/flipper/server.py
Normal file
415
obd2_client/src/flipper/server.py
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
"""Flipper Zero UART server with handshake support."""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional, Callable
|
||||||
|
from pathlib import Path
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
try:
|
||||||
|
import serial
|
||||||
|
except ImportError:
|
||||||
|
serial = None
|
||||||
|
|
||||||
|
from .protocol import FlipperProtocol, CommandType, Command, Page, PageType
|
||||||
|
from .pages import PageManager, ActionID
|
||||||
|
from ..logger import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
class ServerState(Enum):
|
||||||
|
"""Server connection state."""
|
||||||
|
IDLE = "idle" # Waiting for INIT
|
||||||
|
CONNECTED = "connected" # INIT received, ACK sent, sending PAGE updates
|
||||||
|
DISCONNECTING = "disconnecting" # STOP received, cleaning up
|
||||||
|
|
||||||
|
|
||||||
|
class FlipperServer:
|
||||||
|
"""UART server for Flipper Zero communication with handshake support.
|
||||||
|
|
||||||
|
Handshake flow:
|
||||||
|
1. Flipper sends INIT:flipper
|
||||||
|
2. Server responds with ACK:<device>,ip=<ip>
|
||||||
|
3. Server starts sending PAGE updates every 500ms
|
||||||
|
4. Flipper can reconnect anytime with new INIT
|
||||||
|
5. Flipper sends STOP:flipper to disconnect gracefully
|
||||||
|
|
||||||
|
Timeout handling:
|
||||||
|
- Flipper expects ACK within 3 seconds of INIT
|
||||||
|
- Flipper expects PAGE within 5 seconds of ACK
|
||||||
|
- Flipper expects PAGE updates every 5 seconds
|
||||||
|
- Server detects stale connection if no commands for 10 seconds
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEFAULT_BAUDRATE = 115200
|
||||||
|
DEFAULT_PORT = "/dev/serial0" # RPi5 GPIO UART
|
||||||
|
|
||||||
|
# Timing constants
|
||||||
|
PAGE_UPDATE_INTERVAL = 0.5 # Send PAGE every 500ms
|
||||||
|
CONNECTION_TIMEOUT = 10.0 # Consider disconnected after 10s of silence
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
port: str = DEFAULT_PORT,
|
||||||
|
baudrate: int = DEFAULT_BAUDRATE,
|
||||||
|
device_name: str = "rpi5",
|
||||||
|
):
|
||||||
|
"""Initialize Flipper server.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
port: Serial port path
|
||||||
|
baudrate: UART baudrate (default: 115200)
|
||||||
|
device_name: Device name for ACK messages
|
||||||
|
"""
|
||||||
|
self.port = port
|
||||||
|
self.baudrate = baudrate
|
||||||
|
self.device_name = device_name
|
||||||
|
|
||||||
|
self._serial: Optional["serial.Serial"] = None
|
||||||
|
self._running = False
|
||||||
|
self._state = ServerState.IDLE
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
self._page_manager = PageManager()
|
||||||
|
self._last_page_sent = 0.0
|
||||||
|
self._last_command_received = 0.0
|
||||||
|
|
||||||
|
self._logger = get_logger("obd2_client.flipper")
|
||||||
|
self._ip_address = "0.0.0.0"
|
||||||
|
|
||||||
|
self._register_default_actions()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def page_manager(self) -> PageManager:
|
||||||
|
"""Get page manager for configuration."""
|
||||||
|
return self._page_manager
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
"""Check if Flipper is connected."""
|
||||||
|
return self._state == ServerState.CONNECTED
|
||||||
|
|
||||||
|
def set_ip_address(self, ip: str) -> None:
|
||||||
|
"""Set IP address for ACK messages."""
|
||||||
|
self._ip_address = ip
|
||||||
|
|
||||||
|
def _register_default_actions(self) -> None:
|
||||||
|
"""Register default action handlers."""
|
||||||
|
self._page_manager.set_action_handler(
|
||||||
|
ActionID.REBOOT_SYSTEM,
|
||||||
|
self._action_reboot,
|
||||||
|
)
|
||||||
|
self._page_manager.set_action_handler(
|
||||||
|
ActionID.SHUTDOWN_SYSTEM,
|
||||||
|
self._action_shutdown,
|
||||||
|
)
|
||||||
|
self._page_manager.set_action_handler(
|
||||||
|
ActionID.CLEAR_CACHE,
|
||||||
|
self._action_clear_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Start the UART server."""
|
||||||
|
if serial is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"pyserial is not installed. Run: pip install pyserial"
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._thread is not None and self._thread.is_alive():
|
||||||
|
self._logger.warning("Server already running")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._state = ServerState.IDLE
|
||||||
|
self._thread = threading.Thread(target=self._server_loop, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
self._logger.info(f"Flipper server started on {self.port}")
|
||||||
|
|
||||||
|
def stop(self, timeout: float = 2.0) -> None:
|
||||||
|
"""Stop the UART server."""
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
if self._thread is not None:
|
||||||
|
self._thread.join(timeout=timeout)
|
||||||
|
self._thread = None
|
||||||
|
|
||||||
|
self._close_serial()
|
||||||
|
self._logger.info("Flipper server stopped")
|
||||||
|
|
||||||
|
def _server_loop(self) -> None:
|
||||||
|
"""Main server loop."""
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
if not self._open_serial():
|
||||||
|
time.sleep(1.0)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Process incoming commands
|
||||||
|
self._process_incoming()
|
||||||
|
|
||||||
|
# Send periodic PAGE updates if connected
|
||||||
|
if self._state == ServerState.CONNECTED:
|
||||||
|
self._send_page_updates()
|
||||||
|
self._check_connection_timeout()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.error(f"Server error: {e}")
|
||||||
|
self._close_serial()
|
||||||
|
self._state = ServerState.IDLE
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
def _open_serial(self) -> bool:
|
||||||
|
"""Open serial port."""
|
||||||
|
if self._serial is not None and self._serial.is_open:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._serial = serial.Serial(
|
||||||
|
port=self.port,
|
||||||
|
baudrate=self.baudrate,
|
||||||
|
timeout=0.05, # Short timeout for non-blocking reads
|
||||||
|
write_timeout=1.0,
|
||||||
|
)
|
||||||
|
self._logger.debug(f"Serial port {self.port} opened")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.debug(f"Failed to open serial port: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _close_serial(self) -> None:
|
||||||
|
"""Close serial port."""
|
||||||
|
if self._serial is not None:
|
||||||
|
try:
|
||||||
|
self._serial.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._serial = None
|
||||||
|
self._state = ServerState.IDLE
|
||||||
|
|
||||||
|
def _process_incoming(self) -> None:
|
||||||
|
"""Process incoming UART data."""
|
||||||
|
if self._serial is None or not self._serial.is_open:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._serial.in_waiting > 0:
|
||||||
|
line = self._serial.readline().decode("utf-8", errors="ignore")
|
||||||
|
if line:
|
||||||
|
self._handle_line(line)
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.debug(f"Read error: {e}")
|
||||||
|
|
||||||
|
def _handle_line(self, line: str) -> None:
|
||||||
|
"""Handle received line."""
|
||||||
|
cmd = FlipperProtocol.parse_command(line)
|
||||||
|
if cmd is None:
|
||||||
|
self._logger.debug(f"Unknown command: {line.strip()}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._last_command_received = time.time()
|
||||||
|
self._logger.debug(f"Received: {cmd.cmd_type.name}")
|
||||||
|
|
||||||
|
# INIT can be received at any time (reconnection support)
|
||||||
|
if cmd.cmd_type == CommandType.INIT:
|
||||||
|
self._handle_init()
|
||||||
|
return
|
||||||
|
|
||||||
|
# STOP can be received at any time
|
||||||
|
if cmd.cmd_type == CommandType.STOP:
|
||||||
|
self._handle_stop()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Other commands require connected state
|
||||||
|
if self._state != ServerState.CONNECTED:
|
||||||
|
self._logger.debug(f"Ignoring {cmd.cmd_type.name} - not connected")
|
||||||
|
return
|
||||||
|
|
||||||
|
if cmd.cmd_type == CommandType.NAV_NEXT:
|
||||||
|
self._handle_nav_next()
|
||||||
|
elif cmd.cmd_type == CommandType.NAV_PREV:
|
||||||
|
self._handle_nav_prev()
|
||||||
|
elif cmd.cmd_type == CommandType.SELECT:
|
||||||
|
self._handle_select(cmd.param)
|
||||||
|
elif cmd.cmd_type == CommandType.CONFIRM:
|
||||||
|
self._handle_confirm()
|
||||||
|
elif cmd.cmd_type == CommandType.CANCEL:
|
||||||
|
self._handle_cancel()
|
||||||
|
elif cmd.cmd_type == CommandType.SCROLL_UP:
|
||||||
|
self._handle_scroll_up()
|
||||||
|
elif cmd.cmd_type == CommandType.SCROLL_DOWN:
|
||||||
|
self._handle_scroll_down()
|
||||||
|
elif cmd.cmd_type == CommandType.REFRESH:
|
||||||
|
self._handle_refresh()
|
||||||
|
|
||||||
|
def _handle_init(self) -> None:
|
||||||
|
"""Handle INIT command - start/restart connection.
|
||||||
|
|
||||||
|
This can be called multiple times (reconnection).
|
||||||
|
Always responds with ACK and starts sending PAGE updates.
|
||||||
|
"""
|
||||||
|
self._logger.info("Flipper connected (INIT received)")
|
||||||
|
|
||||||
|
# Reset state for fresh connection
|
||||||
|
self._state = ServerState.CONNECTED
|
||||||
|
self._last_command_received = time.time()
|
||||||
|
self._last_page_sent = 0.0 # Force immediate PAGE send
|
||||||
|
|
||||||
|
# Reset page manager to first page
|
||||||
|
self._page_manager._current_index = 0
|
||||||
|
|
||||||
|
# Send ACK immediately
|
||||||
|
ack = FlipperProtocol.encode_ack(self.device_name, self._ip_address)
|
||||||
|
self._send(ack)
|
||||||
|
|
||||||
|
# Send initial PAGE immediately after ACK
|
||||||
|
self._send_current_page()
|
||||||
|
|
||||||
|
def _handle_stop(self) -> None:
|
||||||
|
"""Handle STOP command - graceful disconnect."""
|
||||||
|
self._logger.info("Flipper disconnected (STOP received)")
|
||||||
|
self._state = ServerState.IDLE
|
||||||
|
|
||||||
|
def _handle_nav_next(self) -> None:
|
||||||
|
"""Handle navigation to next page."""
|
||||||
|
if self._page_manager.navigate_next():
|
||||||
|
self._send_current_page()
|
||||||
|
|
||||||
|
def _handle_nav_prev(self) -> None:
|
||||||
|
"""Handle navigation to previous page."""
|
||||||
|
if self._page_manager.navigate_prev():
|
||||||
|
self._send_current_page()
|
||||||
|
|
||||||
|
def _handle_select(self, param: Optional[str]) -> None:
|
||||||
|
"""Handle menu selection."""
|
||||||
|
if param is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
index = int(param)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
|
||||||
|
action_id = self._page_manager.select_action(index)
|
||||||
|
if action_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# If action requires confirmation, send confirm page
|
||||||
|
if action_id in (ActionID.REBOOT_SYSTEM, ActionID.SHUTDOWN_SYSTEM):
|
||||||
|
self._send_current_page()
|
||||||
|
else:
|
||||||
|
# Execute immediately
|
||||||
|
success, message = self._page_manager.execute_action(action_id)
|
||||||
|
self._send_result(success, message)
|
||||||
|
|
||||||
|
def _handle_confirm(self) -> None:
|
||||||
|
"""Handle confirmation."""
|
||||||
|
success, message = self._page_manager.confirm_action()
|
||||||
|
self._send_result(success, message)
|
||||||
|
self._send_current_page()
|
||||||
|
|
||||||
|
def _handle_cancel(self) -> None:
|
||||||
|
"""Handle cancel."""
|
||||||
|
self._page_manager.cancel_action()
|
||||||
|
self._send_current_page()
|
||||||
|
|
||||||
|
def _handle_scroll_up(self) -> None:
|
||||||
|
"""Handle scroll up on info pages."""
|
||||||
|
# Currently info pages don't have scrolling, but protocol supports it
|
||||||
|
self._logger.debug("Scroll up (not implemented)")
|
||||||
|
|
||||||
|
def _handle_scroll_down(self) -> None:
|
||||||
|
"""Handle scroll down on info pages."""
|
||||||
|
# Currently info pages don't have scrolling, but protocol supports it
|
||||||
|
self._logger.debug("Scroll down (not implemented)")
|
||||||
|
|
||||||
|
def _handle_refresh(self) -> None:
|
||||||
|
"""Handle refresh request."""
|
||||||
|
self._send_current_page()
|
||||||
|
|
||||||
|
def _send_page_updates(self) -> None:
|
||||||
|
"""Send periodic page updates to keep Flipper alive."""
|
||||||
|
current_time = time.time()
|
||||||
|
if (current_time - self._last_page_sent) >= self.PAGE_UPDATE_INTERVAL:
|
||||||
|
self._send_current_page()
|
||||||
|
|
||||||
|
def _check_connection_timeout(self) -> None:
|
||||||
|
"""Check if connection has timed out (no commands from Flipper)."""
|
||||||
|
if self._last_command_received == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
elapsed = time.time() - self._last_command_received
|
||||||
|
if elapsed > self.CONNECTION_TIMEOUT:
|
||||||
|
self._logger.warning(f"Connection timeout ({elapsed:.1f}s since last command)")
|
||||||
|
self._state = ServerState.IDLE
|
||||||
|
self._last_command_received = 0
|
||||||
|
|
||||||
|
def _send_current_page(self) -> None:
|
||||||
|
"""Send current page content."""
|
||||||
|
page = self._page_manager.get_current_page()
|
||||||
|
|
||||||
|
# Adjust total pages for confirm page (hidden from navigation)
|
||||||
|
total = self._page_manager.total_pages
|
||||||
|
index = self._page_manager.current_index
|
||||||
|
|
||||||
|
# If on confirm page, still show correct index
|
||||||
|
if index >= total:
|
||||||
|
# Confirm page - show as special page
|
||||||
|
pass
|
||||||
|
|
||||||
|
msg = FlipperProtocol.encode_page(page, index, total)
|
||||||
|
if self._send(msg):
|
||||||
|
self._last_page_sent = time.time()
|
||||||
|
|
||||||
|
def _send_result(self, success: bool, message: str) -> None:
|
||||||
|
"""Send result message."""
|
||||||
|
msg = FlipperProtocol.encode_result(success, message)
|
||||||
|
self._send(msg)
|
||||||
|
|
||||||
|
def _send(self, data: str) -> bool:
|
||||||
|
"""Send data over UART."""
|
||||||
|
if self._serial is None or not self._serial.is_open:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._lock:
|
||||||
|
self._serial.write(data.encode("utf-8"))
|
||||||
|
self._serial.flush()
|
||||||
|
self._logger.debug(f"TX: {data.strip()}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.error(f"Send error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Action handlers
|
||||||
|
|
||||||
|
def _action_reboot(self) -> bool:
|
||||||
|
"""Reboot system action."""
|
||||||
|
self._logger.info("Rebooting system...")
|
||||||
|
try:
|
||||||
|
subprocess.Popen(["sudo", "reboot"])
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.error(f"Reboot failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _action_shutdown(self) -> bool:
|
||||||
|
"""Shutdown system action."""
|
||||||
|
self._logger.info("Shutting down system...")
|
||||||
|
try:
|
||||||
|
subprocess.Popen(["sudo", "shutdown", "-h", "now"])
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.error(f"Shutdown failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _action_clear_cache(self) -> bool:
|
||||||
|
"""Clear PID cache action."""
|
||||||
|
cache_file = Path(__file__).parent.parent.parent / "pid_cache.json"
|
||||||
|
try:
|
||||||
|
if cache_file.exists():
|
||||||
|
cache_file.unlink()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.error(f"Clear cache failed: {e}")
|
||||||
|
return False
|
||||||
@@ -14,15 +14,18 @@ from .obd2.protocol import OBD2Protocol
|
|||||||
from .obd2.scanner import OBD2Scanner
|
from .obd2.scanner import OBD2Scanner
|
||||||
from .vehicle.state import VehicleState
|
from .vehicle.state import VehicleState
|
||||||
from .vehicle.poller import VehiclePoller
|
from .vehicle.poller import VehiclePoller
|
||||||
|
from .flipper.server import FlipperServer
|
||||||
|
from .flipper.pages import ActionID
|
||||||
|
|
||||||
|
|
||||||
class OBD2Client:
|
class OBD2Client:
|
||||||
"""Main OBD2 client application."""
|
"""Main OBD2 client application."""
|
||||||
|
|
||||||
def __init__(self, config: Config):
|
def __init__(self, config: Config, flipper_port: str = None):
|
||||||
self.config = config
|
self.config = config
|
||||||
self._logger = get_logger("obd2_client")
|
self._logger = get_logger("obd2_client")
|
||||||
self._running = False
|
self._running = False
|
||||||
|
self._start_time = time.time()
|
||||||
|
|
||||||
self.can_interface = CANInterface(
|
self.can_interface = CANInterface(
|
||||||
interface=config.can.interface,
|
interface=config.can.interface,
|
||||||
@@ -51,6 +54,38 @@ class OBD2Client:
|
|||||||
slow_pids=config.polling.slow_pids,
|
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:
|
def run(self, scan_only: bool = False, monitor: bool = True) -> None:
|
||||||
"""Run the OBD2 client.
|
"""Run the OBD2 client.
|
||||||
|
|
||||||
@@ -59,11 +94,17 @@ class OBD2Client:
|
|||||||
monitor: If True, continuously monitor and display values
|
monitor: If True, continuously monitor and display values
|
||||||
"""
|
"""
|
||||||
self._running = True
|
self._running = True
|
||||||
|
self._start_time = time.time()
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, self._signal_handler)
|
signal.signal(signal.SIGINT, self._signal_handler)
|
||||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||||
|
|
||||||
try:
|
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._logger.info(f"Connecting to {self.config.can.interface}...")
|
||||||
self.can_interface.connect()
|
self.can_interface.connect()
|
||||||
|
|
||||||
@@ -72,6 +113,12 @@ class OBD2Client:
|
|||||||
|
|
||||||
if not supported:
|
if not supported:
|
||||||
self._logger.error("No PIDs supported or no response from ECU")
|
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
|
return
|
||||||
|
|
||||||
self.scanner.print_supported_pids()
|
self.scanner.print_supported_pids()
|
||||||
@@ -183,6 +230,9 @@ class OBD2Client:
|
|||||||
"""Clean shutdown."""
|
"""Clean shutdown."""
|
||||||
self._logger.info("Shutting down...")
|
self._logger.info("Shutting down...")
|
||||||
|
|
||||||
|
if self.flipper_server:
|
||||||
|
self.flipper_server.stop()
|
||||||
|
|
||||||
if self.poller.is_running:
|
if self.poller.is_running:
|
||||||
self.poller.stop()
|
self.poller.stop()
|
||||||
|
|
||||||
@@ -223,6 +273,13 @@ def main():
|
|||||||
help="Only scan for supported PIDs and exit",
|
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(
|
parser.add_argument(
|
||||||
"--debug",
|
"--debug",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -242,7 +299,7 @@ def main():
|
|||||||
if args.virtual:
|
if args.virtual:
|
||||||
config.can.virtual = True
|
config.can.virtual = True
|
||||||
|
|
||||||
client = OBD2Client(config)
|
client = OBD2Client(config, flipper_port=args.flipper)
|
||||||
client.run(scan_only=args.scan_only)
|
client.run(scan_only=args.scan_only)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user