Compare commits

..

8 Commits

Author SHA1 Message Date
UberGuidoZ
3782be7837
Update README.md 2026-03-22 22:04:12 -07:00
UberGuidoZ
a691e53f66 Release v2.01 (API 87.1) 2026-03-22 21:51:38 -07:00
UberGuidoZ
271786a17e Merge branch 'main' of https://github.com/UberGuidoZ/Flipper 2026-03-22 21:50:21 -07:00
UberGuidoZ
2b3b4a9fa7 Update Location 2026-03-22 21:49:49 -07:00
UberGuidoZ
647729aa21
Delete Applications/UberGuidoZ/gpio_logic_analyzer/Release/gpio_logic_analyzer.fap 2026-03-22 21:48:40 -07:00
UberGuidoZ
264cd9d6c5 Update location and add screenshots 2026-03-22 21:47:08 -07:00
UberGuidoZ
421373f36e
Release v2.01 2026-03-22 21:11:28 -07:00
UberGuidoZ
8fa4a625f4
Initial upload (v2.01) 2026-03-22 21:08:39 -07:00
22 changed files with 1679 additions and 2 deletions

View File

@ -0,0 +1,180 @@
# GPIO Logic Analyzer v2.01 for Flipper Zero
By: UberGuidoZ | https://github.com/UberGuidoZ/Flipper
Code: [Applications/UberGuidoZ/gpio_logic_analyzer/Source](https://github.com/UberGuidoZ/Flipper/tree/main/Applications/UberGuidoZ/gpio_logic_analyzer/Source)
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. Grab FAP from [Release folder](https://github.com/UberGuidoZ/Flipper/tree/main/Applications/UberGuidoZ/gpio_logic_analyzer/Release), build and side-load (see Building section below), or download 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.
---
## Screenshots
Screenshots of the various screens [can be found here](https://github.com/UberGuidoZ/Flipper/tree/main/Applications/UberGuidoZ/gpio_logic_analyzer/Screenshots).
---
## 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 [~~~~|_____|~~~~|____] L
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.<br>
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.<br>
No pull-up or pull-down resistors are enabled. Add external resistors to<br>
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 change the custom icon, drop a 10x10 pixel PNG named `gpio_analyzer_icon.png` overwriting the existing.
---
## 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
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -1,10 +1,11 @@
App(
appid="gpio_logic_analyzer",
name="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, 0),
fap_version=(2, 1),
fap_icon="gpio_analyzer_icon.png",
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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