diff --git a/Applications/My_Creations/gpio_logic_analyzer/README.md b/Applications/My_Creations/gpio_logic_analyzer/README.md new file mode 100644 index 000000000..f5b7062ae --- /dev/null +++ b/Applications/My_Creations/gpio_logic_analyzer/README.md @@ -0,0 +1,176 @@ +# GPIO Logic Analyzer v2.01 for Flipper Zero + +By: UberGuidoZ | https://github.com/UberGuidoZ/Flipper + +Code: Applications/My_Creations/gpio_logic_analyzer + +A logic analyzer that monitors all 8 exposed GPIO pins on the Flipper Zero +18-pin header, draws live scrolling waveforms on the 128x64 OLED, and logs +every sample to a timestamped CSV file on the SD card, with instructions. + +--- + +## Quick start + +1. Build and side-load (see Building section below) or grab from App Store. +2. On the Flipper, navigate to **Apps > GPIO > GPIO Logic Analyzer**. +3. Select **Instructions** to review the voltage limits and control map. +4. Select **Start Analyzer** to begin capture. +5. CSV logs are written automatically to `/ext/gpio_analyzer/` while capturing. + +--- + +## Main Menu + +| Item | Action | +|-------------------|-----------------------------------------| +| Start Analyzer | Opens the live capture screen | +| Instructions | Four-page on-screen reference guide | +| About | Version, author, and project URL | + +--- + +## Analyzer Screen + +``` +GPIO Analyzer RUN 100Hz* +--------------------------------- +A7 [~~~~|____|~~~~] H +A6 [____|~~~~|____] L +A4 [________________] L +B3 [~~~~~~~~~~~~~~~~] H +B2 [________________] L +C3 [~~|___|~~~|____] H +C1 [__|~~~|______] L +C0 [~~~|___|~~] H +``` + +- Each row is one GPIO pin. +- The waveform shows the last 104 samples, scrolling left as new samples arrive. +- The newest sample is always the rightmost pixel column. +- `H` / `L` at the right edge shows the instantaneous pin state. +- Vertical lines in the waveform mark state transitions (rising/falling edges). +- `*` after the sample rate in the header means CSV logging is active. + +--- + +## Controls + +| Button | Action | +|-------------|---------------------------------| +| OK (short) | Toggle capture on / off | +| OK (long) | Clear waveform history | +| UP | Increase sample rate | +| DOWN | Decrease sample rate | +| BACK | Stop capture, close CSV, exit | + +--- + +## Monitored Pins + +| Row | Label | Pin Name | Header Pin | Notes | +|-----|-------|----------|------------|---------------| +| 1 | A7 | PA7 | Pin 2 | 3.3V max | +| 2 | A6 | PA6 | Pin 3 | 3.3V max | +| 3 | A4 | PA4 | Pin 4 | 5V tolerant | +| 4 | B3 | PB3 | Pin 5 | 3.3V max | +| 5 | B2 | PB2 | Pin 6 | 3.3V max | +| 6 | C3 | PC3 | Pin 7 | 3.3V max | +| 7 | C1 | PC1 | Pin 15 | 3.3V max | +| 8 | C0 | PC0 | Pin 16 | 3.3V max | + +All pins are configured as digital inputs with no internal pull resistor. +They are returned to high-impedance analog mode when you exit the analyzer. + +**Voltage warning**: Do not connect signals above 3.3V to any pin except PA4. +No pull-up or pull-down resistors are enabled. Add external resistors to +your circuit if the signals you are probing have undefined idle states. + +FAILURE TO FOLLOW THIS WARNING COULD CAUSE FLIPPER TO LEAK MAGIC SMOKE! + +--- + +## Sample Rates + +| Rate | Period | Notes | +|--------|--------|------------------------------------------------| +| 10 Hz | 100 ms | Safest for SD write overhead at any card speed | +| 50 Hz | 20 ms | Good general-purpose rate | +| 100 Hz | 10 ms | Default (balanced speed and reliability) | +| 200 Hz | 5 ms | Fast; occasional sample jitter near SD writes | +| 500 Hz | 2 ms | Best-effort; may drop samples during CSV flush | + +Rates are FreeRTOS timer-based. The Flipper OS scheduler and SD write latency +can introduce jitter at high rates. For precision timing analysis, use a +dedicated logic analyzer. This tool is intended for qualitative signal +inspection and basic protocol debugging. YMMV, caveat emptor, all that. + +--- + +## CSV Logging + +A new CSV file is created automatically each time you start capture. + +**Location on SD card:** +``` +/ext/gpio_analyzer/gla_YYYYMMDD_HHMMSS.csv +``` + +Example: `/ext/gpio_analyzer/gla_20240315_143022.csv` + +**File format (comma-separated, CRLF line endings):** +``` +sample,A7,A6,A4,B3,B2,C3,C1,C0 +0,1,0,1,0,0,1,0,1 +1,1,0,1,0,0,1,0,0 +2,0,0,1,0,0,1,1,0 +... +``` + +- `sample` - monotonically incrementing sample number (starts at 0 each session). +- Pin columns - `1` = HIGH (3.3V), `0` = LOW (0V). +- Column order matches the on-screen row order (A7 top, C0 bottom). +- CRLF endings ensure correct display in Excel and LibreOffice Calc. + +If the SD card is absent or the directory cannot be created, the app +continues without logging (no `*` in the header) and flashes the error LED. + +--- + +## Building + +Assumes repo is cloned to `gpio_logic_analyzer` and requires [ufbt](https://github.com/flipperdevices/flipperzero-ufbt): + +```sh +pip install ufbt # install ufbt once +cd gpio_logic_analyzer +ufbt # build only (outputs build/.../gpio_logic_analyzer.fap) +ufbt launch # build and side-load over USB +``` + +To build inside the full firmware tree instead: + +```sh +# Place gpio_logic_analyzer/ under applications_user/ +./fbt fap_gpio_logic_analyzer +``` + +To add a custom icon, drop a 10x10 pixel PNG named `gpio_analyzer_icon.png` +into this directory and add `fap_icon="gpio_analyzer_icon.png"` to +`application.fam`. + +--- + +## File Structure + +``` +gpio_logic_analyzer/ + application.fam -- ufbt app manifest + gpio_analyzer.h -- shared types, constants, app struct + gpio_analyzer_app.c -- entry point, alloc/free, scene handler table + scene_menu.c -- main menu (Submenu widget) + scene_analyzer.c -- GPIO capture, waveform renderer, CSV logging + scene_instructions.c -- 4-page on-screen reference (LEFT/RIGHT to page) + scene_about.c -- about screen + README.md -- this file +``` \ No newline at end of file diff --git a/Applications/My_Creations/gpio_logic_analyzer/SOURCE/application.fam b/Applications/My_Creations/gpio_logic_analyzer/SOURCE/application.fam new file mode 100644 index 000000000..6cfbcf7f4 --- /dev/null +++ b/Applications/My_Creations/gpio_logic_analyzer/SOURCE/application.fam @@ -0,0 +1,11 @@ +App( + appid="gpio_logic_analyzer", + name="[GPIO] Logic Analyzer", + apptype=FlipperAppType.EXTERNAL, + entry_point="gpio_analyzer_app", + requires=["gui", "storage", "notification"], + stack_size=3 * 1024, + fap_category="GPIO", + fap_version=(2, 1), + fap_icon="gpio_analyzer_icon.png", +) \ No newline at end of file diff --git a/Applications/My_Creations/gpio_logic_analyzer/SOURCE/gpio_analyzer.h b/Applications/My_Creations/gpio_logic_analyzer/SOURCE/gpio_analyzer.h new file mode 100644 index 000000000..789303888 --- /dev/null +++ b/Applications/My_Creations/gpio_logic_analyzer/SOURCE/gpio_analyzer.h @@ -0,0 +1,197 @@ +#pragma once + +// ########################################################### +// # gpio_analyzer.h -GPIO Logic Analyzer for Flipper Zero # +// # By: UberGuidoZ | https://github.com/UberGuidoZ/ # +// # v2.01 # +// ########################################################### + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ------------------------------------------------------------ +// Screen geometry (actual OLED pixels) +// ------------------------------------------------------------ +#define GA_SCREEN_W 128U +#define GA_SCREEN_H 64U +#define GA_HEADER_H 9U // 8px text row + 1px separator +#define GA_ROW_H 7U // per-channel row height +#define GA_LABEL_X 1U // left edge of pin label column +#define GA_WAVE_X 15U // left edge of waveform column +#define GA_WAVE_W 104U // waveform pixel width (= sample history depth) +#define GA_STATE_X 120U // left edge of H/L state column +#define GA_WAVE_Y_HIGH 1U // row-relative y of HIGH sample dot +#define GA_WAVE_Y_LOW 5U // row-relative y of LOW sample dot + +// Number of GPIO channels displayed +#define GA_PIN_COUNT 8U + +// History buffer depth must equal waveform width for 1:1 pixel mapping. +// A _Static_assert in scene_analyzer.c enforces this at compile time. +#define GA_HISTORY_SIZE GA_WAVE_W + +// ------------------------------------------------------------ +// Sample rate table sizing and default index +// ------------------------------------------------------------ +#define GA_RATE_COUNT 5U +#define GA_DEFAULT_RATE_IDX 2U // 100 Hz + +// ------------------------------------------------------------ +// CSV output +// ------------------------------------------------------------ +#define GA_CSV_DIR "/ext/gpio_analyzer" +#define GA_CSV_PATH_LEN 64U + +// ------------------------------------------------------------ +// Instructions +// ------------------------------------------------------------ +#define GA_INSTR_PAGES 4U // total pages in the instructions view + +// ------------------------------------------------------------ +// Scene IDs (order must match ga_scene_handlers[] in +// gpio_analyzer_app.c) +// ------------------------------------------------------------ +typedef enum { + GaSceneSplash = 0, + GaSceneMenu, + GaSceneAnalyzer, + GaSceneInstructions, + GaSceneAbout, + GaSceneCount, +} GaSceneId; + +// ------------------------------------------------------------ +// View IDs (registered with ViewDispatcher) +// ------------------------------------------------------------ +typedef enum { + GaViewSplash = 0, + GaViewMenu, + GaViewAnalyzer, + GaViewInstructions, + GaViewAbout, + GaViewCount, +} GaViewId; + +// ------------------------------------------------------------ +// Custom events sent via view_dispatcher_send_custom_event() +// ------------------------------------------------------------ +typedef enum { + GaEventMenuStart = 0, + GaEventMenuInstructions = 1, + GaEventMenuAbout = 2, +} GaMenuEvent; + +typedef enum { + GaEventAnalyzerToggle = 0, + GaEventAnalyzerClearHistory = 1, + GaEventAnalyzerRateUp = 2, + GaEventAnalyzerRateDown = 3, +} GaAnalyzerEvent; + +typedef enum { + GaEventInstrPageLeft = 0, + GaEventInstrPageRight = 1, +} GaInstrEvent; + +// ------------------------------------------------------------ +// Per-channel capture state +// ------------------------------------------------------------ +typedef struct { + bool current_state; + uint8_t history[GA_HISTORY_SIZE]; + uint32_t history_head; + uint32_t sample_count; +} GaChannel; + +// ------------------------------------------------------------ +// Forward declaration - allows view model structs below to +// hold a GpioAnalyzer* before the full struct is defined. +// ------------------------------------------------------------ +typedef struct GpioAnalyzer_s GpioAnalyzer; + +// ------------------------------------------------------------ +// View models +// ------------------------------------------------------------ + +// Analyzer view: the draw callback reads channel data through app. +typedef struct { + GpioAnalyzer* app; +} GaAnalyzerModel; + +// Instructions view: tracks which page is displayed. +typedef struct { + uint8_t page; +} GaInstrModel; + +// ------------------------------------------------------------ +// Full application state struct +// ------------------------------------------------------------ +struct GpioAnalyzer_s { + // Scene / view management + SceneManager* scene_manager; + ViewDispatcher* view_dispatcher; + Submenu* submenu; + View* view_splash; + View* view_analyzer; + View* view_instructions; + View* view_about; + + // GPIO capture + FuriMutex* mutex; + FuriTimer* sample_timer; + FuriTimer* splash_timer; // one-shot auto-advance timer + GaChannel channels[GA_PIN_COUNT]; + bool running; + uint8_t rate_index; + uint32_t sample_number; + + // CSV logging + Storage* storage; + File* csv_file; + bool csv_open; + char csv_path[GA_CSV_PATH_LEN]; + + // Notifications + NotificationApp* notifications; +}; + +// ------------------------------------------------------------ +// Scene handler declarations (implemented in each scene_*.c) +// ------------------------------------------------------------ +void scene_splash_on_enter(void* context); +bool scene_splash_on_event(void* context, SceneManagerEvent event); +void scene_splash_on_exit(void* context); + +void scene_menu_on_enter(void* context); +bool scene_menu_on_event(void* context, SceneManagerEvent event); +void scene_menu_on_exit(void* context); + +void scene_analyzer_on_enter(void* context); +bool scene_analyzer_on_event(void* context, SceneManagerEvent event); +void scene_analyzer_on_exit(void* context); + +void scene_instructions_on_enter(void* context); +bool scene_instructions_on_event(void* context, SceneManagerEvent event); +void scene_instructions_on_exit(void* context); + +void scene_about_on_enter(void* context); +bool scene_about_on_event(void* context, SceneManagerEvent event); +void scene_about_on_exit(void* context); + +// ------------------------------------------------------------ +// View setup functions (called once from ga_alloc) +// ------------------------------------------------------------ +void scene_splash_setup_view(GpioAnalyzer* app); +void scene_analyzer_setup_view(GpioAnalyzer* app); +void scene_instructions_setup_view(GpioAnalyzer* app); +void scene_about_setup_view(GpioAnalyzer* app); \ No newline at end of file diff --git a/Applications/My_Creations/gpio_logic_analyzer/SOURCE/gpio_analyzer_app.c b/Applications/My_Creations/gpio_logic_analyzer/SOURCE/gpio_analyzer_app.c new file mode 100644 index 000000000..4cec70bbe --- /dev/null +++ b/Applications/My_Creations/gpio_logic_analyzer/SOURCE/gpio_analyzer_app.c @@ -0,0 +1,226 @@ +// ######################################################### +// # gpio_analyzer_app.c - entry point and app lifecycle # +// # By: UberGuidoZ | https://github.com/UberGuidoZ/ # +// # v2.01 # +// ######################################################### + +#include "gpio_analyzer.h" +#include + +// ============================================================ +// Scene handler tables +// The current Flipper SDK SceneManagerHandlers struct uses three +// separate arrays (on_enter, on_event, on_exit) rather than a +// single array of per-scene structs. Order must match GaSceneId. +// ============================================================ +static void (*const ga_on_enter_handlers[])(void*) = { + scene_splash_on_enter, + scene_menu_on_enter, + scene_analyzer_on_enter, + scene_instructions_on_enter, + scene_about_on_enter, +}; + +static bool (*const ga_on_event_handlers[])(void*, SceneManagerEvent) = { + scene_splash_on_event, + scene_menu_on_event, + scene_analyzer_on_event, + scene_instructions_on_event, + scene_about_on_event, +}; + +static void (*const ga_on_exit_handlers[])(void*) = { + scene_splash_on_exit, + scene_menu_on_exit, + scene_analyzer_on_exit, + scene_instructions_on_exit, + scene_about_on_exit, +}; + +static const SceneManagerHandlers ga_scene_manager_handlers = { + .on_enter_handlers = ga_on_enter_handlers, + .on_event_handlers = ga_on_event_handlers, + .on_exit_handlers = ga_on_exit_handlers, + .scene_num = GaSceneCount, +}; + +// ============================================================ +// ViewDispatcher callbacks +// ============================================================ + +// Routes custom events (button/UI actions) to the active scene. +static bool ga_custom_event_cb(void* context, uint32_t event) { + GpioAnalyzer* app = (GpioAnalyzer*)context; + furi_check(app != NULL); + return scene_manager_handle_custom_event(app->scene_manager, event); +} + +// Routes BACK presses to the active scene; scene manager pops +// the stack on unhandled backs. Returning false from the last +// scene causes ViewDispatcher to stop its run loop. +static bool ga_back_event_cb(void* context) { + GpioAnalyzer* app = (GpioAnalyzer*)context; + furi_check(app != NULL); + return scene_manager_handle_back_event(app->scene_manager); +} + +// ============================================================ +// Allocate and initialise all app resources +// ============================================================ +static GpioAnalyzer* ga_alloc(void) { + GpioAnalyzer* app = malloc(sizeof(GpioAnalyzer)); + furi_check(app != NULL); + memset(app, 0, sizeof(GpioAnalyzer)); + + app->rate_index = GA_DEFAULT_RATE_IDX; + + // ---------------------------------------------------------- + // Scene manager + // ---------------------------------------------------------- + app->scene_manager = scene_manager_alloc(&ga_scene_manager_handlers, app); + furi_check(app->scene_manager != NULL); + + // ---------------------------------------------------------- + // View dispatcher + // ---------------------------------------------------------- + app->view_dispatcher = view_dispatcher_alloc(); + furi_check(app->view_dispatcher != NULL); + + view_dispatcher_set_event_callback_context(app->view_dispatcher, app); + view_dispatcher_set_custom_event_callback(app->view_dispatcher, ga_custom_event_cb); + view_dispatcher_set_navigation_event_callback(app->view_dispatcher, ga_back_event_cb); + + // Attach to the GUI fullscreen layer. + Gui* gui = furi_record_open(RECORD_GUI); + view_dispatcher_attach_to_gui(app->view_dispatcher, gui, ViewDispatcherTypeFullscreen); + furi_record_close(RECORD_GUI); + + // ---------------------------------------------------------- + // Submenu (main menu view) + // ---------------------------------------------------------- + app->submenu = submenu_alloc(); + furi_check(app->submenu != NULL); + view_dispatcher_add_view( + app->view_dispatcher, GaViewMenu, submenu_get_view(app->submenu)); + + // ---------------------------------------------------------- + // Custom views (each scene_*.c sets up draw/input callbacks) + // ---------------------------------------------------------- + scene_splash_setup_view(app); + view_dispatcher_add_view(app->view_dispatcher, GaViewSplash, app->view_splash); + + scene_analyzer_setup_view(app); + view_dispatcher_add_view(app->view_dispatcher, GaViewAnalyzer, app->view_analyzer); + + scene_instructions_setup_view(app); + view_dispatcher_add_view(app->view_dispatcher, GaViewInstructions, app->view_instructions); + + scene_about_setup_view(app); + view_dispatcher_add_view(app->view_dispatcher, GaViewAbout, app->view_about); + + // ---------------------------------------------------------- + // Capture primitives (timer and mutex allocated once) + // ---------------------------------------------------------- + app->mutex = furi_mutex_alloc(FuriMutexTypeNormal); + furi_check(app->mutex != NULL); + + // Timer is started/stopped by the analyzer scene. + // The timer callback is registered in scene_analyzer.c. + // We store only the handle here; setup is in scene_analyzer.c. + // (The timer is allocated there so the callback symbol stays local.) + + // ---------------------------------------------------------- + // Storage and CSV file object + // ---------------------------------------------------------- + app->storage = furi_record_open(RECORD_STORAGE); + furi_check(app->storage != NULL); + + app->csv_file = storage_file_alloc(app->storage); + furi_check(app->csv_file != NULL); + + // Ensure the output directory exists on the SD card. + // FSE_EXIST is a success condition (directory already present). + FS_Error dir_err = storage_common_mkdir(app->storage, GA_CSV_DIR); + if(dir_err != FSE_OK && dir_err != FSE_EXIST) { + // Non-fatal: CSV open will simply fail later if the dir is absent. + } + + // ---------------------------------------------------------- + // Notifications + // ---------------------------------------------------------- + app->notifications = furi_record_open(RECORD_NOTIFICATION); + furi_check(app->notifications != NULL); + + return app; +} + +// ============================================================ +// Free all resources in reverse-allocation order +// ============================================================ +static void ga_free(GpioAnalyzer* app) { + furi_check(app != NULL); + + // Timer was allocated in scene_analyzer.c and must be freed + // before the mutex it may reference. + if(app->sample_timer != NULL) { + furi_timer_stop(app->sample_timer); + furi_timer_free(app->sample_timer); + app->sample_timer = NULL; + } + + if(app->splash_timer != NULL) { + furi_timer_stop(app->splash_timer); + furi_timer_free(app->splash_timer); + app->splash_timer = NULL; + } + + furi_record_close(RECORD_NOTIFICATION); + + if(app->csv_file != NULL) { + if(app->csv_open) { + storage_file_close(app->csv_file); + app->csv_open = false; + } + storage_file_free(app->csv_file); + app->csv_file = NULL; + } + furi_record_close(RECORD_STORAGE); + + furi_mutex_free(app->mutex); + + // Remove views before freeing view dispatcher. + view_dispatcher_remove_view(app->view_dispatcher, GaViewAbout); + view_dispatcher_remove_view(app->view_dispatcher, GaViewInstructions); + view_dispatcher_remove_view(app->view_dispatcher, GaViewAnalyzer); + view_dispatcher_remove_view(app->view_dispatcher, GaViewMenu); + view_dispatcher_remove_view(app->view_dispatcher, GaViewSplash); + + view_free(app->view_about); + view_free(app->view_instructions); + view_free(app->view_analyzer); + view_free(app->view_splash); + submenu_free(app->submenu); + + view_dispatcher_free(app->view_dispatcher); + scene_manager_free(app->scene_manager); + + free(app); +} + +// ============================================================ +// Application entry point +// ============================================================ +int32_t gpio_analyzer_app(void* p) { + UNUSED(p); + + GpioAnalyzer* app = ga_alloc(); + + // Push the main menu scene; ViewDispatcher run() blocks until + // view_dispatcher_stop() is called (by the back handler on the + // root menu scene returning false). + scene_manager_next_scene(app->scene_manager, GaSceneSplash); + view_dispatcher_run(app->view_dispatcher); + + ga_free(app); + return 0; +} \ No newline at end of file diff --git a/Applications/My_Creations/gpio_logic_analyzer/SOURCE/gpio_analyzer_icon.png b/Applications/My_Creations/gpio_logic_analyzer/SOURCE/gpio_analyzer_icon.png new file mode 100644 index 000000000..74242a012 Binary files /dev/null and b/Applications/My_Creations/gpio_logic_analyzer/SOURCE/gpio_analyzer_icon.png differ diff --git a/Applications/My_Creations/gpio_logic_analyzer/SOURCE/gpio_analyzer_icon.png.bak b/Applications/My_Creations/gpio_logic_analyzer/SOURCE/gpio_analyzer_icon.png.bak new file mode 100644 index 000000000..29f379df2 Binary files /dev/null and b/Applications/My_Creations/gpio_logic_analyzer/SOURCE/gpio_analyzer_icon.png.bak differ diff --git a/Applications/My_Creations/gpio_logic_analyzer/SOURCE/scene_about.c b/Applications/My_Creations/gpio_logic_analyzer/SOURCE/scene_about.c new file mode 100644 index 000000000..889d769a3 --- /dev/null +++ b/Applications/My_Creations/gpio_logic_analyzer/SOURCE/scene_about.c @@ -0,0 +1,91 @@ +// ##################################################### +// # scene_about.c - about screen # +// # By: UberGuidoZ | https://github.com/UberGuidoZ/ # +// # v2.01 # +// ##################################################### + +#include "gpio_analyzer.h" + +// ============================================================ +// Draw callback - static content, no model needed +// ============================================================ + +static void ga_about_draw_cb(Canvas* canvas, void* model) { + UNUSED(model); + canvas_clear(canvas); + + // ---------------------------------------------------------- + // Title - FontPrimary. Measure actual pixel widths at runtime + // so centering is exact regardless of font metrics. + // Box is sized from the wider string + 8px padding each side. + // ---------------------------------------------------------- + canvas_set_font(canvas, FontPrimary); + size_t w1 = canvas_string_width(canvas, "GPIO Logic"); + size_t w2 = canvas_string_width(canvas, "Analyzer"); + size_t box_w = (w1 > w2 ? w1 : w2) + 16U; + int32_t box_x = (int32_t)((128U - box_w) / 2U); + if(box_x < 1) box_x = 1; + canvas_draw_frame(canvas, box_x, 8, (uint8_t)box_w, 32); + canvas_draw_str(canvas, (int32_t)((128U - w1) / 2U), 22, "GPIO Logic"); + canvas_draw_str(canvas, (int32_t)((128U - w2) / 2U), 34, "Analyzer"); + + // ---------------------------------------------------------- + // Separator between title box and byline. + // ---------------------------------------------------------- + canvas_draw_line(canvas, 10, 44, 118, 44); + + // ---------------------------------------------------------- + // Byline - FontKeyboard (~6px/char, ~8px tall). + // "v2.01 by UberGuidoZ" = 19 chars * 6 = 114px, x=(128-114)/2 = 7 + // "github.com/UberGuidoZ" = 21 chars * 6 = 126px, x=(128-126)/2 = 1 + // ---------------------------------------------------------- + canvas_set_font(canvas, FontKeyboard); + canvas_draw_str(canvas, 7, 54, "v2.01 by UberGuidoZ"); + canvas_draw_str(canvas, 1, 62, "github.com/UberGuidoZ"); +} + +// ============================================================ +// Input callback - no local inputs; BACK is handled by the +// ViewDispatcher navigation callback (pops the scene). +// ============================================================ + +static bool ga_about_input_cb(InputEvent* event, void* context) { + UNUSED(event); + UNUSED(context); + return false; +} + +// ============================================================ +// View setup (called once from ga_alloc) +// ============================================================ + +void scene_about_setup_view(GpioAnalyzer* app) { + furi_check(app != NULL); + + app->view_about = view_alloc(); + furi_check(app->view_about != NULL); + + view_set_draw_callback(app->view_about, ga_about_draw_cb); + view_set_input_callback(app->view_about, ga_about_input_cb); + // No model needed for static content. +} + +// ============================================================ +// Scene handlers +// ============================================================ + +void scene_about_on_enter(void* context) { + GpioAnalyzer* app = (GpioAnalyzer*)context; + furi_check(app != NULL); + view_dispatcher_switch_to_view(app->view_dispatcher, GaViewAbout); +} + +bool scene_about_on_event(void* context, SceneManagerEvent event) { + UNUSED(context); + UNUSED(event); + return false; +} + +void scene_about_on_exit(void* context) { + UNUSED(context); +} \ No newline at end of file diff --git a/Applications/My_Creations/gpio_logic_analyzer/SOURCE/scene_analyzer.c b/Applications/My_Creations/gpio_logic_analyzer/SOURCE/scene_analyzer.c new file mode 100644 index 000000000..e07a52dd6 --- /dev/null +++ b/Applications/My_Creations/gpio_logic_analyzer/SOURCE/scene_analyzer.c @@ -0,0 +1,508 @@ +// ######################################################### +// # scene_analyzer.c - capture, render, and CSV logging # +// # By: UberGuidoZ | https://github.com/UberGuidoZ/ # +// # v2.01 # +// ######################################################### +// +// GPIO capture runs on a FuriTimer (FreeRTOS timer service task). +// The draw callback runs on the GUI thread. A FuriMutex guards +// all shared channel and CSV state. +// +// CONTROLS (while analyzer is on screen): +// OK (short) - toggle capture on / off +// OK (long) - clear waveform history +// UP - increase sample rate +// DOWN - decrease sample rate +// BACK - stop capture, close CSV, return to menu +// +// ############################################################## + +#include "gpio_analyzer.h" +#include +#include + +// Enforce 1:1 pixel-to-sample mapping required by the renderer. +_Static_assert( + GA_HISTORY_SIZE == GA_WAVE_W, + "GA_HISTORY_SIZE must equal GA_WAVE_W for 1:1 pixel mapping"); + +// ============================================================ +// GPIO pin table (rows displayed top-to-bottom) +// ============================================================ +static const GpioPin* const k_pins[GA_PIN_COUNT] = { + &gpio_ext_pa7, + &gpio_ext_pa6, + &gpio_ext_pa4, + &gpio_ext_pb3, + &gpio_ext_pb2, + &gpio_ext_pc3, + &gpio_ext_pc1, + &gpio_ext_pc0, +}; + +static const char* const k_pin_labels[GA_PIN_COUNT] = { + "A7", "A6", "A4", "B3", "B2", "C3", "C1", "C0", +}; + +// CSV column header names (must match k_pin_labels order) +static const char* const k_csv_headers[GA_PIN_COUNT] = { + "A7", "A6", "A4", "B3", "B2", "C3", "C1", "C0", +}; + +// Pre-computed top-pixel y for each channel row. +static const uint8_t k_row_top[GA_PIN_COUNT] = { + GA_HEADER_H + 0U * GA_ROW_H, // 9 + GA_HEADER_H + 1U * GA_ROW_H, // 16 + GA_HEADER_H + 2U * GA_ROW_H, // 23 + GA_HEADER_H + 3U * GA_ROW_H, // 30 + GA_HEADER_H + 4U * GA_ROW_H, // 37 + GA_HEADER_H + 5U * GA_ROW_H, // 44 + GA_HEADER_H + 6U * GA_ROW_H, // 51 + GA_HEADER_H + 7U * GA_ROW_H, // 58 +}; + +// ============================================================ +// Sample rate table +// ============================================================ +static const uint32_t k_sample_rates[GA_RATE_COUNT] = { + 10U, // 10 Hz -- 100 ms + 50U, // 50 Hz -- 20 ms + 100U, // 100 Hz -- 10 ms + 200U, // 200 Hz -- 5 ms + 500U, // 500 Hz -- 2 ms +}; + +static const char* const k_rate_labels[GA_RATE_COUNT] = { + " 10Hz", + " 50Hz", + "100Hz", + "200Hz", + "500Hz", +}; + +// ============================================================ +// Internal helpers +// ============================================================ + +static uint32_t ga_period_ms(const GpioAnalyzer* app) { + uint32_t period = 1000U / k_sample_rates[app->rate_index]; + return (period < 1U) ? 1U : period; +} + +static void ga_gpio_init(void) { + for(uint8_t i = 0U; i < GA_PIN_COUNT; i++) { + furi_hal_gpio_init(k_pins[i], GpioModeInput, GpioPullNo, GpioSpeedLow); + } +} + +static void ga_gpio_deinit(void) { + // Return pins to high-impedance analog to avoid back-driving + // devices connected to the header after the app exits. + for(uint8_t i = 0U; i < GA_PIN_COUNT; i++) { + furi_hal_gpio_init(k_pins[i], GpioModeAnalog, GpioPullNo, GpioSpeedLow); + } +} + +// Clears the sample history for all channels. Caller must NOT hold +// the mutex. This function acquires and releases it internally. +static void ga_clear_history(GpioAnalyzer* app) { + furi_check(app != NULL); + furi_mutex_acquire(app->mutex, FuriWaitForever); + for(uint8_t i = 0U; i < GA_PIN_COUNT; i++) { + memset(app->channels[i].history, 0, sizeof(app->channels[i].history)); + app->channels[i].history_head = 0U; + app->channels[i].sample_count = 0U; + app->channels[i].current_state = false; + } + furi_mutex_release(app->mutex); +} + +// ============================================================ +// CSV helpers +// ============================================================ + +static void ga_csv_open(GpioAnalyzer* app) { + furi_check(app != NULL); + if(app->csv_open) return; + + // Build timestamped filename using the RTC. + DateTime dt; + furi_hal_rtc_get_datetime(&dt); + snprintf( + app->csv_path, + sizeof(app->csv_path), + GA_CSV_DIR "/gla_%04d%02d%02d_%02d%02d%02d.csv", + (int)dt.year, + (int)dt.month, + (int)dt.day, + (int)dt.hour, + (int)dt.minute, + (int)dt.second); + + if(!storage_file_open( + app->csv_file, app->csv_path, FSAM_WRITE, FSOM_CREATE_ALWAYS)) { + // No SD card or permission error = continue without CSV. + notification_message(app->notifications, &sequence_error); + return; + } + + // Write header row: "sample,A7,A6,..." followed by CRLF for + // maximum compatibility with spreadsheet applications. + char header[48]; + int hlen = snprintf( + header, + sizeof(header), + "sample,%s,%s,%s,%s,%s,%s,%s,%s\r\n", + k_csv_headers[0], k_csv_headers[1], k_csv_headers[2], + k_csv_headers[3], k_csv_headers[4], k_csv_headers[5], + k_csv_headers[6], k_csv_headers[7]); + if(hlen > 0 && hlen < (int)sizeof(header)) { + storage_file_write(app->csv_file, header, (uint16_t)hlen); + } + + app->csv_open = true; + app->sample_number = 0U; + notification_message(app->notifications, &sequence_success); +} + +static void ga_csv_close(GpioAnalyzer* app) { + furi_check(app != NULL); + if(!app->csv_open) return; + storage_file_close(app->csv_file); + app->csv_open = false; +} + +// Writes one CSV data row. Called from the timer callback while +// the mutex is already held by the caller. +static void ga_csv_write_row(GpioAnalyzer* app) { + if(!app->csv_open || app->csv_file == NULL) return; + + char line[48]; + int len = snprintf( + line, + sizeof(line), + "%lu,%d,%d,%d,%d,%d,%d,%d,%d\r\n", + (unsigned long)app->sample_number, + (int)app->channels[0].current_state, + (int)app->channels[1].current_state, + (int)app->channels[2].current_state, + (int)app->channels[3].current_state, + (int)app->channels[4].current_state, + (int)app->channels[5].current_state, + (int)app->channels[6].current_state, + (int)app->channels[7].current_state); + if(len > 0 && len < (int)sizeof(line)) { + storage_file_write(app->csv_file, line, (uint16_t)len); + } + app->sample_number++; +} + +// ============================================================ +// Capture start / stop +// ============================================================ + +static void ga_start_capture(GpioAnalyzer* app) { + furi_check(app != NULL); + if(app->running) return; + ga_csv_open(app); + app->running = true; + furi_timer_start(app->sample_timer, ga_period_ms(app)); +} + +static void ga_stop_capture(GpioAnalyzer* app) { + furi_check(app != NULL); + if(!app->running) return; + furi_timer_stop(app->sample_timer); + app->running = false; + ga_csv_close(app); +} + +static void ga_apply_rate_change(GpioAnalyzer* app) { + if(app->running) { + furi_timer_stop(app->sample_timer); + furi_timer_start(app->sample_timer, ga_period_ms(app)); + } +} + +// ============================================================ +// Timer callback (FreeRTOS timer service task) +// ============================================================ + +static void ga_sample_timer_cb(void* context) { + GpioAnalyzer* app = (GpioAnalyzer*)context; + furi_check(app != NULL); + + // Skip this tick if the draw callback is holding the mutex, + // rather than stalling the timer service task. + if(furi_mutex_acquire(app->mutex, 0U) != FuriStatusOk) return; + + for(uint8_t i = 0U; i < GA_PIN_COUNT; i++) { + bool state = furi_hal_gpio_read(k_pins[i]); + app->channels[i].current_state = state; + + uint32_t pos = app->channels[i].history_head; + app->channels[i].history[pos] = state ? 1U : 0U; + app->channels[i].history_head = (pos + 1U) % GA_HISTORY_SIZE; + if(app->channels[i].sample_count < GA_HISTORY_SIZE) { + app->channels[i].sample_count++; + } + } + + ga_csv_write_row(app); + + furi_mutex_release(app->mutex); + + // Signal the ViewDispatcher to redraw the analyzer view. + // view_commit_model with update=true posts a paint message to + // the GUI thread without acquiring the view model lock. + view_commit_model(app->view_analyzer, true); +} + +// ============================================================ +// Draw callback (GUI thread) +// ============================================================ + +static void ga_analyzer_draw_cb(Canvas* canvas, void* model) { + GaAnalyzerModel* m = (GaAnalyzerModel*)model; + if(m == NULL || m->app == NULL) return; + GpioAnalyzer* app = m->app; + + // Use a short timeout so a slow timer never stalls the GUI. + if(furi_mutex_acquire(app->mutex, 25U) != FuriStatusOk) return; + + canvas_clear(canvas); + canvas_set_font(canvas, FontSecondary); + + // ---------------------------------------------------------- + // Header bar + // ---------------------------------------------------------- + canvas_draw_str(canvas, (int32_t)GA_LABEL_X, 7, "GPIO Analyzer"); + + // Status: RUN/STP + rate + CSV indicator (* when logging). + char status[20]; + snprintf( + status, + sizeof(status), + "%s %s%s", + app->running ? "RUN" : "STP", + k_rate_labels[app->rate_index], + app->csv_open ? "*" : " "); + + // Rough right-align: FontSecondary glyphs are ~6px wide. + uint8_t sw = (uint8_t)(strlen(status) * 6U); + int32_t sx = (int32_t)GA_SCREEN_W - (int32_t)sw - 1; + if(sx < 0) sx = 0; + canvas_draw_str(canvas, sx, 7, status); + + // Separator line below header. + canvas_draw_line( + canvas, 0, (int32_t)(GA_HEADER_H - 1U), + (int32_t)(GA_SCREEN_W - 1U), (int32_t)(GA_HEADER_H - 1U)); + + // ---------------------------------------------------------- + // Channel rows + // ---------------------------------------------------------- + canvas_set_font(canvas, FontKeyboard); + + for(uint8_t i = 0U; i < GA_PIN_COUNT; i++) { + const GaChannel* ch = &app->channels[i]; + uint8_t ry = k_row_top[i]; + uint8_t tb = ry + GA_ROW_H - 1U; + uint8_t yhi = ry + GA_WAVE_Y_HIGH; + uint8_t ylo = ry + GA_WAVE_Y_LOW; + + // Pin label (left column) + canvas_draw_str(canvas, (int32_t)GA_LABEL_X, (int32_t)tb, k_pin_labels[i]); + + // State indicator (right column). + // FontKeyboard glyphs are ~8px tall but rows are only 7px, + // causing text to bleed into the row above. Instead, draw + // a pixel-precise 5x5 shape that fits cleanly within the row: + // filled box = HIGH, outline box = LOW. + // Box occupies y = ry+1 to ry+5, well within ry..ry+6. + if(ch->current_state) { + canvas_draw_box(canvas, (int32_t)GA_STATE_X + 1, (int32_t)ry + 1, 5, 5); + } else { + canvas_draw_frame(canvas, (int32_t)GA_STATE_X + 1, (int32_t)ry + 1, 5, 5); + } + + // ---------------------------------------------------------- + // Waveform renderer + // + // The circular buffer holds up to GA_HISTORY_SIZE samples. + // When full: oldest = history[history_head] + // newest = history[(history_head - 1 + SIZE) % SIZE] + // When not full: oldest = history[0], newest = history[sample_count-1] + // + // We display min(sample_count, GA_WAVE_W) samples L-to-R, + // with the newest sample always at the rightmost pixel column. + // ---------------------------------------------------------- + uint32_t count = ch->sample_count; + uint32_t head = ch->history_head; + uint32_t show = (count < (uint32_t)GA_WAVE_W) ? count : (uint32_t)GA_WAVE_W; + uint32_t skip = (uint32_t)GA_WAVE_W - show; + uint32_t oldest_idx = (count < GA_HISTORY_SIZE) ? 0U : head; + uint8_t prev_val = 0xFFU; // sentinel: no prior sample + + for(uint32_t s = skip; s < (uint32_t)GA_WAVE_W; s++) { + uint32_t age = s - skip; + uint32_t sample_idx = (oldest_idx + age) % GA_HISTORY_SIZE; + uint8_t val = ch->history[sample_idx]; + uint8_t px = (uint8_t)((uint32_t)GA_WAVE_X + s); + uint8_t py = val ? yhi : ylo; + + // Draw vertical transition edge at rising and falling edges. + if(prev_val != 0xFFU && prev_val != val) { + canvas_draw_line( + canvas, (int32_t)px, (int32_t)yhi, + (int32_t)px, (int32_t)ylo); + } + canvas_draw_dot(canvas, (int32_t)px, (int32_t)py); + prev_val = val; + } + } + + furi_mutex_release(app->mutex); +} + +// ============================================================ +// Input callback (GUI thread) +// ============================================================ + +static bool ga_analyzer_input_cb(InputEvent* event, void* context) { + GpioAnalyzer* app = (GpioAnalyzer*)context; + furi_check(app != NULL); + bool consumed = false; + + if(event->type == InputTypeShort) { + switch(event->key) { + case InputKeyOk: + view_dispatcher_send_custom_event( + app->view_dispatcher, GaEventAnalyzerToggle); + consumed = true; + break; + case InputKeyUp: + view_dispatcher_send_custom_event( + app->view_dispatcher, GaEventAnalyzerRateUp); + consumed = true; + break; + case InputKeyDown: + view_dispatcher_send_custom_event( + app->view_dispatcher, GaEventAnalyzerRateDown); + consumed = true; + break; + default: + break; + } + } else if(event->type == InputTypeLong && event->key == InputKeyOk) { + view_dispatcher_send_custom_event( + app->view_dispatcher, GaEventAnalyzerClearHistory); + consumed = true; + } + + return consumed; +} + +// ============================================================ +// View setup (called once from ga_alloc) +// ============================================================ + +void scene_analyzer_setup_view(GpioAnalyzer* app) { + furi_check(app != NULL); + + app->view_analyzer = view_alloc(); + furi_check(app->view_analyzer != NULL); + + view_set_draw_callback(app->view_analyzer, ga_analyzer_draw_cb); + view_set_input_callback(app->view_analyzer, ga_analyzer_input_cb); + view_set_context(app->view_analyzer, app); + + // LockFree model: just a pointer to app. We protect the actual + // channel data with app->mutex rather than the view model lock. + view_allocate_model( + app->view_analyzer, ViewModelTypeLockFree, sizeof(GaAnalyzerModel)); + + GaAnalyzerModel* m = view_get_model(app->view_analyzer); + if(m != NULL) m->app = app; + view_commit_model(app->view_analyzer, false); + + // Allocate the periodic sample timer here so the callback symbol + // stays file-local. The timer is started/stopped by the scene. + app->sample_timer = furi_timer_alloc(ga_sample_timer_cb, FuriTimerTypePeriodic, app); + furi_check(app->sample_timer != NULL); +} + +// ============================================================ +// Scene handlers +// ============================================================ + +void scene_analyzer_on_enter(void* context) { + GpioAnalyzer* app = (GpioAnalyzer*)context; + furi_check(app != NULL); + + ga_gpio_init(); + ga_clear_history(app); + ga_start_capture(app); + + view_dispatcher_switch_to_view(app->view_dispatcher, GaViewAnalyzer); +} + +bool scene_analyzer_on_event(void* context, SceneManagerEvent event) { + GpioAnalyzer* app = (GpioAnalyzer*)context; + furi_check(app != NULL); + bool consumed = false; + + if(event.type == SceneManagerEventTypeCustom) { + switch(event.event) { + case GaEventAnalyzerToggle: + if(app->running) { + ga_stop_capture(app); + } else { + ga_start_capture(app); + } + consumed = true; + break; + + case GaEventAnalyzerClearHistory: + // Stop the timer while clearing to prevent a partial-clear + // race. Resume immediately after if capture was running. + if(app->running) furi_timer_stop(app->sample_timer); + ga_clear_history(app); + if(app->running) furi_timer_start(app->sample_timer, ga_period_ms(app)); + consumed = true; + break; + + case GaEventAnalyzerRateUp: + if(app->rate_index < GA_RATE_COUNT - 1U) { + app->rate_index++; + ga_apply_rate_change(app); + } + consumed = true; + break; + + case GaEventAnalyzerRateDown: + if(app->rate_index > 0U) { + app->rate_index--; + ga_apply_rate_change(app); + } + consumed = true; + break; + + default: + break; + } + } + + return consumed; +} + +void scene_analyzer_on_exit(void* context) { + GpioAnalyzer* app = (GpioAnalyzer*)context; + furi_check(app != NULL); + + ga_stop_capture(app); + ga_gpio_deinit(); + // History is intentionally NOT cleared here. If the user + // navigates back and re-enters, they see a blank screen anyway + // because ga_clear_history() is called in on_enter. +} \ No newline at end of file diff --git a/Applications/My_Creations/gpio_logic_analyzer/SOURCE/scene_instructions.c b/Applications/My_Creations/gpio_logic_analyzer/SOURCE/scene_instructions.c new file mode 100644 index 000000000..42db18d50 --- /dev/null +++ b/Applications/My_Creations/gpio_logic_analyzer/SOURCE/scene_instructions.c @@ -0,0 +1,223 @@ +// ####################################################### +// # scene_instructions.c - 4-page on-screen reference # +// # By: UberGuidoZ | https://github.com/UberGuidoZ/ # +// # v2.01 # +// ####################################################### +// +// Page 1: Voltage & Safety +// Page 2: Display Guide +// Page 3: Controls +// Page 4: CSV Logging +// +// LEFT - previous page +// RIGHT - next page +// BACK - return to menu +// +// ############################################################## + +#include "gpio_analyzer.h" +#include +#include + +// ============================================================ +// Page content +// Each page: title + up to GA_INSTR_BODY_LINES body lines. +// Lines are rendered with FontKeyboard (~4px per char, 6px line pitch). +// Max safe line length: ~30 chars. +// ============================================================ + +#define GA_INSTR_BODY_LINES 5U + +static const char* const k_page_titles[GA_INSTR_PAGES] = { + "Voltage & Safety", + "Display Guide", + "Controls", + "CSV Logging", +}; + +static const char* const k_page_body[GA_INSTR_PAGES][GA_INSTR_BODY_LINES] = { + // Page 0: Voltage & Safety (max 21 chars) + { + "3.3V MAX on all pins", + "PA4: 5V tolerant", + "Others: 3.3V ONLY", + "No pull resistors", + "Never short to VCC!", + }, + // Page 1: Display Guide (max 21 chars) + { + "Each row = one pin", + "Labels: A7,A6...C0", + "Waveform scrolls left", + "Filled box = HIGH", + "Outline box = LOW", + }, + // Page 2: Controls (max 21 chars) + { + "OK start/stop", + "OK hold clr history", + "UP/DOWN rate change", + "BACK exit to menu", + "* header = CSV active", + }, + // Page 3: CSV Logging (max 21 chars) + { + "Auto-logs to SD card", + "gla_YYYYMMDD_HHMMSS", + "/ext/gpio_analyzer/", + "Cols: sample,A7..C0", + "0 = LOW 1 = HIGH", + }, +}; + +// ============================================================ +// Draw callback +// ============================================================ + +static void ga_instructions_draw_cb(Canvas* canvas, void* model) { + GaInstrModel* m = (GaInstrModel*)model; + if(m == NULL) return; + + uint8_t page = m->page; + if(page >= GA_INSTR_PAGES) page = 0U; + + canvas_clear(canvas); + + // ---------------------------------------------------------- + // Title bar (FontSecondary, baseline y=7). + // Page indicator right-aligned on the same line: "1/4" + // ---------------------------------------------------------- + canvas_set_font(canvas, FontSecondary); + canvas_draw_str(canvas, 1, 7, k_page_titles[page]); + + char indicator[8]; + snprintf(indicator, sizeof(indicator), "%d/%d", (int)(page + 1U), (int)GA_INSTR_PAGES); + size_t iw = canvas_string_width(canvas, indicator); + canvas_draw_str(canvas, (int32_t)(127U - iw), 7, indicator); + + // Separator + canvas_draw_line(canvas, 0, 9, 127, 9); + + // ---------------------------------------------------------- + // Body text (FontKeyboard ~8px tall, 10px line spacing = 2px gap). + // 5 lines at y=19,29,39,49,59. Last line on page 3 is centered. + // ---------------------------------------------------------- + canvas_set_font(canvas, FontKeyboard); + int32_t y = 19; + for(uint8_t i = 0U; i < GA_INSTR_BODY_LINES; i++) { + if(k_page_body[page][i] != NULL && k_page_body[page][i][0] != '\0') { + int32_t lx = 1; + if(page == 3U && i == GA_INSTR_BODY_LINES - 1U) { + size_t lw = canvas_string_width(canvas, k_page_body[page][i]); + lx = (int32_t)((128U - lw) / 2U); + if(lx < 1) lx = 1; + } + canvas_draw_str(canvas, lx, y, k_page_body[page][i]); + } + y += 10; + } +} + +// ============================================================ +// Input callback +// ============================================================ + +static bool ga_instructions_input_cb(InputEvent* event, void* context) { + GpioAnalyzer* app = (GpioAnalyzer*)context; + furi_check(app != NULL); + bool consumed = false; + + if(event->type == InputTypeShort) { + switch(event->key) { + case InputKeyLeft: + view_dispatcher_send_custom_event( + app->view_dispatcher, GaEventInstrPageLeft); + consumed = true; + break; + case InputKeyRight: + view_dispatcher_send_custom_event( + app->view_dispatcher, GaEventInstrPageRight); + consumed = true; + break; + default: + break; + } + } + + return consumed; +} + +// ============================================================ +// View setup (called once from ga_alloc) +// ============================================================ + +void scene_instructions_setup_view(GpioAnalyzer* app) { + furi_check(app != NULL); + + app->view_instructions = view_alloc(); + furi_check(app->view_instructions != NULL); + + view_set_draw_callback(app->view_instructions, ga_instructions_draw_cb); + view_set_input_callback(app->view_instructions, ga_instructions_input_cb); + view_set_context(app->view_instructions, app); + + // LockFree model: just holds the current page number. + // Only ever written from the scene event handler (GUI thread). + view_allocate_model( + app->view_instructions, ViewModelTypeLockFree, sizeof(GaInstrModel)); + + GaInstrModel* m = view_get_model(app->view_instructions); + if(m != NULL) m->page = 0U; + view_commit_model(app->view_instructions, false); +} + +// ============================================================ +// Scene handlers +// ============================================================ + +void scene_instructions_on_enter(void* context) { + GpioAnalyzer* app = (GpioAnalyzer*)context; + furi_check(app != NULL); + + // Always start on page 0 when the user enters instructions. + GaInstrModel* m = view_get_model(app->view_instructions); + if(m != NULL) m->page = 0U; + view_commit_model(app->view_instructions, false); + + view_dispatcher_switch_to_view(app->view_dispatcher, GaViewInstructions); +} + +bool scene_instructions_on_event(void* context, SceneManagerEvent event) { + GpioAnalyzer* app = (GpioAnalyzer*)context; + furi_check(app != NULL); + bool consumed = false; + + if(event.type == SceneManagerEventTypeCustom) { + GaInstrModel* m = view_get_model(app->view_instructions); + if(m == NULL) return false; + + switch(event.event) { + case GaEventInstrPageLeft: + if(m->page > 0U) m->page--; + view_commit_model(app->view_instructions, true); + consumed = true; + break; + + case GaEventInstrPageRight: + if(m->page < GA_INSTR_PAGES - 1U) m->page++; + view_commit_model(app->view_instructions, true); + consumed = true; + break; + + default: + break; + } + } + + return consumed; +} + +void scene_instructions_on_exit(void* context) { + UNUSED(context); + // Nothing to tear down. +} \ No newline at end of file diff --git a/Applications/My_Creations/gpio_logic_analyzer/SOURCE/scene_menu.c b/Applications/My_Creations/gpio_logic_analyzer/SOURCE/scene_menu.c new file mode 100644 index 000000000..aeb592d8c --- /dev/null +++ b/Applications/My_Creations/gpio_logic_analyzer/SOURCE/scene_menu.c @@ -0,0 +1,90 @@ +// ##################################################### +// # scene_menu.c - main menu scene # +// # By: UberGuidoZ | https://github.com/UberGuidoZ/ # +// # v2.01 # +// ##################################################### + +#include "gpio_analyzer.h" + +// ============================================================ +// Submenu callback - sends a custom event to the scene manager +// ============================================================ +static void ga_menu_submenu_cb(void* context, uint32_t index) { + GpioAnalyzer* app = (GpioAnalyzer*)context; + furi_check(app != NULL); + view_dispatcher_send_custom_event(app->view_dispatcher, index); +} + +// ============================================================ +// Scene handlers +// ============================================================ + +void scene_menu_on_enter(void* context) { + GpioAnalyzer* app = (GpioAnalyzer*)context; + furi_check(app != NULL); + + submenu_reset(app->submenu); + submenu_set_header(app->submenu, "GPIO Logic Analyzer"); + + submenu_add_item( + app->submenu, + "Start Analyzer", + GaEventMenuStart, + ga_menu_submenu_cb, + app); + submenu_add_item( + app->submenu, + "Instructions", + GaEventMenuInstructions, + ga_menu_submenu_cb, + app); + submenu_add_item( + app->submenu, + "About", + GaEventMenuAbout, + ga_menu_submenu_cb, + app); + + view_dispatcher_switch_to_view(app->view_dispatcher, GaViewMenu); +} + +bool scene_menu_on_event(void* context, SceneManagerEvent event) { + GpioAnalyzer* app = (GpioAnalyzer*)context; + furi_check(app != NULL); + bool consumed = false; + + // BACK on the root menu exits the app entirely. + // Without this, scene manager would pop the menu and reveal + // the splash scene underneath, trapping the user. + if(event.type == SceneManagerEventTypeBack) { + view_dispatcher_stop(app->view_dispatcher); + return true; + } + + if(event.type == SceneManagerEventTypeCustom) { + switch(event.event) { + case GaEventMenuStart: + scene_manager_next_scene(app->scene_manager, GaSceneAnalyzer); + consumed = true; + break; + case GaEventMenuInstructions: + scene_manager_next_scene(app->scene_manager, GaSceneInstructions); + consumed = true; + break; + case GaEventMenuAbout: + scene_manager_next_scene(app->scene_manager, GaSceneAbout); + consumed = true; + break; + default: + break; + } + } + + return consumed; +} + +void scene_menu_on_exit(void* context) { + GpioAnalyzer* app = (GpioAnalyzer*)context; + furi_check(app != NULL); + submenu_reset(app->submenu); +} \ No newline at end of file diff --git a/Applications/My_Creations/gpio_logic_analyzer/SOURCE/scene_splash.c b/Applications/My_Creations/gpio_logic_analyzer/SOURCE/scene_splash.c new file mode 100644 index 000000000..fb640ffba --- /dev/null +++ b/Applications/My_Creations/gpio_logic_analyzer/SOURCE/scene_splash.c @@ -0,0 +1,161 @@ +// ##################################################### +// # scene_splash.c - title / splash screen # +// # By: UberGuidoZ | https://github.com/UberGuidoZ/ # +// # v2.01 # +// ##################################################### +// +// Displays a bordered title card for 1.5 seconds, then +// automatically advances to the main menu. Any key press +// also skips the splash immediately. +// +// Layout (128x64 OLED): +// +// +---------------------+ <- 2px border with corner marks +// | | <- Box around title text, centered +// | GPIO Logic | <- FontPrimary, line 1 +// | Analyzer | <- FontPrimary, line 2 +// |---------------------| <- separator +// | v2.01 by UberGuidoZ | <- FontKeyboard byline +// |github.com/UberGuidoZ| <- FontKeyboard URL +// +---------------------+ +// +// ############################################################## + +#include "gpio_analyzer.h" + +// Auto-advance delay in milliseconds +#define GA_SPLASH_DELAY_MS 3000U + +// Custom event fired when the timer expires +#define GA_SPLASH_TIMER_DONE 0U + +// ============================================================ +// Splash timer callback (FreeRTOS timer service task) +// Fires once after GA_SPLASH_DELAY_MS. +// ============================================================ + +static void ga_splash_timer_cb(void* context) { + GpioAnalyzer* app = (GpioAnalyzer*)context; + furi_check(app != NULL); + view_dispatcher_send_custom_event(app->view_dispatcher, GA_SPLASH_TIMER_DONE); +} + +// ============================================================ +// Draw callback - pure static content, no model needed +// ============================================================ + +static void ga_splash_draw_cb(Canvas* canvas, void* model) { + UNUSED(model); + canvas_clear(canvas); + + // ---------------------------------------------------------- + // Title - FontPrimary. Measure actual pixel widths at runtime + // so centering is exact regardless of font metrics. + // Box is sized from the wider string + 8px padding each side. + // ---------------------------------------------------------- + canvas_set_font(canvas, FontPrimary); + size_t w1 = canvas_string_width(canvas, "GPIO Logic"); + size_t w2 = canvas_string_width(canvas, "Analyzer"); + size_t box_w = (w1 > w2 ? w1 : w2) + 16U; + int32_t box_x = (int32_t)((128U - box_w) / 2U); + if(box_x < 1) box_x = 1; + canvas_draw_frame(canvas, box_x, 8, (uint8_t)box_w, 32); + canvas_draw_str(canvas, (int32_t)((128U - w1) / 2U), 22, "GPIO Logic"); + canvas_draw_str(canvas, (int32_t)((128U - w2) / 2U), 34, "Analyzer"); + + // ---------------------------------------------------------- + // Separator between title box and byline. + // ---------------------------------------------------------- + canvas_draw_line(canvas, 10, 44, 118, 44); + + // ---------------------------------------------------------- + // Byline - FontKeyboard (~6px/char, ~8px tall). + // "v2.01 by UberGuidoZ" = 19 chars * 6 = 114px, x=(128-114)/2 = 7 + // "github.com/UberGuidoZ" = 21 chars * 6 = 126px, x=(128-126)/2 = 1 + // ---------------------------------------------------------- + canvas_set_font(canvas, FontKeyboard); + canvas_draw_str(canvas, 7, 54, "v2.01 by UberGuidoZ"); + canvas_draw_str(canvas, 1, 62, "github.com/UberGuidoZ"); +} + +// ============================================================ +// Input callback - any key skips the splash immediately +// ============================================================ + +static bool ga_splash_input_cb(InputEvent* event, void* context) { + GpioAnalyzer* app = (GpioAnalyzer*)context; + furi_check(app != NULL); + + // Only act on key-down edges to avoid double-fires. + if(event->type == InputTypeShort || event->type == InputTypeLong) { + view_dispatcher_send_custom_event(app->view_dispatcher, GA_SPLASH_TIMER_DONE); + return true; + } + return false; +} + +// ============================================================ +// View setup (called once from ga_alloc) +// ============================================================ + +void scene_splash_setup_view(GpioAnalyzer* app) { + furi_check(app != NULL); + + app->view_splash = view_alloc(); + furi_check(app->view_splash != NULL); + + view_set_draw_callback(app->view_splash, ga_splash_draw_cb); + view_set_input_callback(app->view_splash, ga_splash_input_cb); + view_set_context(app->view_splash, app); + // No model - all content is static. +} + +// ============================================================ +// Scene handlers +// ============================================================ + +void scene_splash_on_enter(void* context) { + GpioAnalyzer* app = (GpioAnalyzer*)context; + furi_check(app != NULL); + + // Allocate a one-shot timer. It is freed in on_exit so the + // callback cannot fire after the scene has been torn down. + app->splash_timer = furi_timer_alloc( + ga_splash_timer_cb, FuriTimerTypeOnce, app); + furi_check(app->splash_timer != NULL); + furi_timer_start(app->splash_timer, GA_SPLASH_DELAY_MS); + + view_dispatcher_switch_to_view(app->view_dispatcher, GaViewSplash); +} + +bool scene_splash_on_event(void* context, SceneManagerEvent event) { + GpioAnalyzer* app = (GpioAnalyzer*)context; + furi_check(app != NULL); + + if(event.type == SceneManagerEventTypeCustom && + event.event == GA_SPLASH_TIMER_DONE) { + // Stop and free the timer before transitioning so it cannot + // double-fire if the user tapped a key and the timer also + // expired in the same tick. + if(app->splash_timer != NULL) { + furi_timer_stop(app->splash_timer); + furi_timer_free(app->splash_timer); + app->splash_timer = NULL; + } + scene_manager_next_scene(app->scene_manager, GaSceneMenu); + return true; + } + return false; +} + +void scene_splash_on_exit(void* context) { + GpioAnalyzer* app = (GpioAnalyzer*)context; + furi_check(app != NULL); + + // Guard in case on_event already freed it (key-skip path). + if(app->splash_timer != NULL) { + furi_timer_stop(app->splash_timer); + furi_timer_free(app->splash_timer); + app->splash_timer = NULL; + } +} \ No newline at end of file