/** * CAN Monitor for Flipper Zero - Dynamic UI Version * * Multi-page application with dynamic content from RPI5. * * Protocol: * RPi -> Flipper: * PAGE:/|||<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]; 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; } } 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; 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) { app->page.data_valid = false; 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 = ""; break; case PageTypeMenu: hint = "OK=Select"; break; case PageTypeConfirm: hint = "OK=Yes 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 == 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 == 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; }