Initial upload (v2.01)

This commit is contained in:
UberGuidoZ
2026-03-22 21:08:39 -07:00
committed by GitHub
parent 7cf1d6ee0d
commit 8fa4a625f4
11 changed files with 1683 additions and 0 deletions
@@ -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
```
@@ -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",
)
@@ -0,0 +1,197 @@
#pragma once
// ###########################################################
// # gpio_analyzer.h -GPIO Logic Analyzer for Flipper Zero #
// # By: UberGuidoZ | https://github.com/UberGuidoZ/ #
// # v2.01 #
// ###########################################################
#include <furi.h>
#include <furi_hal.h>
#include <furi_hal_gpio.h>
#include <datetime/datetime.h>
#include <gui/gui.h>
#include <gui/view.h>
#include <gui/view_dispatcher.h>
#include <gui/scene_manager.h>
#include <gui/modules/submenu.h>
#include <input/input.h>
#include <notification/notification_messages.h>
#include <storage/storage.h>
// ------------------------------------------------------------
// 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);
@@ -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 <string.h>
// ============================================================
// 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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 371 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 B

@@ -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);
}
@@ -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 <string.h>
#include <stdio.h>
// 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.
}
@@ -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 <stdio.h>
#include <string.h>
// ============================================================
// 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.
}
@@ -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);
}
@@ -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;
}
}