timed-remote

Flipper Zero app for sending delayed IR commands
git clone git://src.adamsgaard.dk/timed-remote # fast
git clone https://src.adamsgaard.dk/timed-remote.git # slow
Log | Files | Refs | README | LICENSE Back to index

commit 1c9a4403c1711ab87c34efd9ddcfb65155f7c621
parent 302f38bbcea02801e91d0e1f2e97cdbbce73c68f
Author: Anders Damsgaard <anders@adamsgaard.dk>
Date:   Fri, 30 Jan 2026 22:29:32 +0100

Implement timed IR remote with countdown mode

Add complete scene-based UI for scheduling IR signal transmission:

Features:
- Browse and select signals from existing .ir files
- Record new IR signals with auto-generated names
- Countdown timer mode with configurable duration (HH:MM:SS)
- Optional repeat with configurable count (0 = unlimited)
- Live countdown display with IR transmission on completion
- Confirmation popup after signal sent

Technical changes:
- Add infrared library dependency and 4KB stack
- Implement scene manager with 11 scenes for full workflow
- Add ir_helper for signal recording, loading, saving, transmitting
- Add time_helper for HMS/seconds conversion
- Fix InfraredErrorCode return type handling (0 = success)
- Fix flipper_format_read_header NULL pointer crash
- Remove deprecated view_dispatcher_enable_queue call
- Replace storage_dir_rewind with close/reopen pattern

Tested: Countdown mode playback of stored signals works.
TODO: Test scheduled mode and signal recording.

Diffstat:
MMakefile | 3++-
Mapplication.fam | 3++-
Ahelpers/ir_helper.c | 278+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahelpers/ir_helper.h | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahelpers/time_helper.c | 48++++++++++++++++++++++++++++++++++++++++++++++++
Ahelpers/time_helper.h | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Ascenes/scene_confirm.c | 46++++++++++++++++++++++++++++++++++++++++++++++
Ascenes/scene_ir_browse.c | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascenes/scene_ir_record.c | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascenes/scene_ir_select.c | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascenes/scene_ir_source.c | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascenes/scene_main_menu.c | 45+++++++++++++++++++++++++++++++++++++++++++++
Ascenes/scene_repeat_options.c | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascenes/scene_signal_name.c | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascenes/scene_time_input.c | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascenes/scene_timer_mode.c | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascenes/scene_timer_running.c | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascenes/timed_remote_scene.c | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascenes/timed_remote_scene.h | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtimed_remote.c | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Atimed_remote.h | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
21 files changed, 1622 insertions(+), 10 deletions(-)

diff --git a/Makefile b/Makefile @@ -25,4 +25,4 @@ start: ${BIN} clean: rm -rf dist -.PHONY: start clean +.PHONY: start clean +\ No newline at end of file diff --git a/application.fam b/application.fam @@ -5,8 +5,9 @@ App( name="App timed_remote", # Displayed in UI apptype=FlipperAppType.EXTERNAL, entry_point="timed_remote_app", - stack_size=2 * 1024, + stack_size=4 * 1024, fap_category="Infrared", + fap_libs=["infrared"], # Optional values fap_description="Send IR commands after timed duration", fap_version="0.1", # (major, minor) diff --git a/helpers/ir_helper.c b/helpers/ir_helper.c @@ -0,0 +1,278 @@ +#include "ir_helper.h" + +#include <flipper_format/flipper_format.h> +#include <furi.h> +#include <infrared/worker/infrared_worker.h> +#include <lib/infrared/signal/infrared_error_code.h> +#include <storage/storage.h> + +#define IR_FILE_HEADER "IR signals file" +#define IR_FILE_VERSION 1 + +/* ========== Signal List ========== */ + +IrSignalList *ir_signal_list_alloc(void) { + IrSignalList *list = malloc(sizeof(IrSignalList)); + list->items = NULL; + list->count = 0; + list->capacity = 0; + return list; +} + +void ir_signal_list_free(IrSignalList *list) { + if (!list) + return; + + for (size_t i = 0; i < list->count; i++) { + if (list->items[i].signal) { + infrared_signal_free(list->items[i].signal); + } + if (list->items[i].name) { + furi_string_free(list->items[i].name); + } + } + if (list->items) { + free(list->items); + } + free(list); +} + +static void ir_signal_list_add(IrSignalList *list, InfraredSignal *signal, + const char *name) { + if (list->count >= list->capacity) { + size_t new_capacity = list->capacity == 0 ? 8 : list->capacity * 2; + list->items = realloc(list->items, new_capacity * sizeof(IrSignalItem)); + list->capacity = new_capacity; + } + + list->items[list->count].signal = signal; + list->items[list->count].name = furi_string_alloc_set(name); + list->count++; +} + +/* ========== Recording ========== */ + +typedef struct { + InfraredSignal *signal; + bool received; + FuriSemaphore *semaphore; +} RecordContext; + +static void ir_record_callback(void *context, + InfraredWorkerSignal *received_signal) { + RecordContext *ctx = context; + + const InfraredMessage *message = + infrared_worker_get_decoded_signal(received_signal); + if (message) { + ctx->signal = infrared_signal_alloc(); + infrared_signal_set_message(ctx->signal, message); + ctx->received = true; + } else { + /* Raw signal */ + const uint32_t *timings; + size_t timings_cnt; + infrared_worker_get_raw_signal(received_signal, &timings, &timings_cnt); + if (timings && timings_cnt > 0) { + ctx->signal = infrared_signal_alloc(); + infrared_signal_set_raw_signal(ctx->signal, timings, timings_cnt, + INFRARED_COMMON_CARRIER_FREQUENCY, + INFRARED_COMMON_DUTY_CYCLE); + ctx->received = true; + } + } + + furi_semaphore_release(ctx->semaphore); +} + +bool ir_helper_record(InfraredSignal **signal, uint32_t timeout_ms) { + InfraredWorker *worker = infrared_worker_alloc(); + + RecordContext ctx = { + .signal = NULL, + .received = false, + .semaphore = furi_semaphore_alloc(0, 1), + }; + + infrared_worker_rx_set_received_signal_callback(worker, ir_record_callback, + &ctx); + infrared_worker_rx_start(worker); + + FuriStatus status = furi_semaphore_acquire(ctx.semaphore, timeout_ms); + + infrared_worker_rx_stop(worker); + infrared_worker_free(worker); + furi_semaphore_free(ctx.semaphore); + + if (status == FuriStatusOk && ctx.received) { + *signal = ctx.signal; + return true; + } + + if (ctx.signal) { + infrared_signal_free(ctx.signal); + } + *signal = NULL; + return false; +} + +/* ========== File I/O ========== */ + +bool ir_helper_save(InfraredSignal *signal, const char *name, + const char *path) { + Storage *storage = furi_record_open(RECORD_STORAGE); + FlipperFormat *ff = flipper_format_file_alloc(storage); + bool success = false; + + do { + /* Check if file exists */ + if (storage_file_exists(storage, path)) { + /* Append mode */ + if (!flipper_format_file_open_append(ff, path)) + break; + } else { + /* Create new file */ + if (!flipper_format_file_open_always(ff, path)) + break; + if (!flipper_format_write_header_cstr(ff, IR_FILE_HEADER, + IR_FILE_VERSION)) + break; + } + + if (!infrared_signal_save(signal, ff, name)) + break; + + success = true; + } while (false); + + flipper_format_free(ff); + furi_record_close(RECORD_STORAGE); + return success; +} + +bool ir_helper_load_file(const char *path, IrSignalList *list) { + Storage *storage = furi_record_open(RECORD_STORAGE); + FlipperFormat *ff = flipper_format_file_alloc(storage); + bool success = false; + + do { + if (!flipper_format_file_open_existing(ff, path)) + break; + + /* Verify header */ + FuriString *filetype = furi_string_alloc(); + uint32_t version; + bool header_ok = flipper_format_read_header(ff, filetype, &version); + furi_string_free(filetype); + if (!header_ok) + break; + + /* Read all signals */ + FuriString *signal_name = furi_string_alloc(); + while (true) { + InfraredSignal *signal = infrared_signal_alloc(); + InfraredErrorCode err = infrared_signal_read(signal, ff, signal_name); + if (err == InfraredErrorCodeNone) { + ir_signal_list_add(list, signal, furi_string_get_cstr(signal_name)); + } else { + infrared_signal_free(signal); + break; + } + } + furi_string_free(signal_name); + + success = true; + } while (false); + + flipper_format_free(ff); + furi_record_close(RECORD_STORAGE); + return success; +} + +/* ========== Transmit ========== */ + +void ir_helper_transmit(InfraredSignal *signal) { + infrared_signal_transmit(signal); +} + +/* ========== File Browser ========== */ + +bool ir_helper_list_files(const char *dir_path, FuriString ***files, + size_t *count) { + Storage *storage = furi_record_open(RECORD_STORAGE); + File *dir = storage_file_alloc(storage); + + *files = NULL; + *count = 0; + + if (!storage_dir_open(dir, dir_path)) { + storage_file_free(dir); + furi_record_close(RECORD_STORAGE); + return false; + } + + /* First pass: count .ir files */ + FileInfo file_info; + char name_buf[256]; + size_t file_count = 0; + + while (storage_dir_read(dir, &file_info, name_buf, sizeof(name_buf))) { + if (!(file_info.flags & FSF_DIRECTORY)) { + size_t len = strlen(name_buf); + if (len > 3 && strcmp(name_buf + len - 3, ".ir") == 0) { + file_count++; + } + } + } + + if (file_count == 0) { + storage_dir_close(dir); + storage_file_free(dir); + furi_record_close(RECORD_STORAGE); + return true; /* Success, but no files */ + } + + /* Allocate array */ + *files = malloc(file_count * sizeof(FuriString *)); + *count = file_count; + + /* Second pass: collect filenames - close and reopen directory */ + storage_dir_close(dir); + if (!storage_dir_open(dir, dir_path)) { + free(*files); + *files = NULL; + *count = 0; + storage_file_free(dir); + furi_record_close(RECORD_STORAGE); + return false; + } + + size_t idx = 0; + + while (storage_dir_read(dir, &file_info, name_buf, sizeof(name_buf)) && + idx < file_count) { + if (!(file_info.flags & FSF_DIRECTORY)) { + size_t len = strlen(name_buf); + if (len > 3 && strcmp(name_buf + len - 3, ".ir") == 0) { + (*files)[idx] = furi_string_alloc_set(name_buf); + idx++; + } + } + } + + storage_dir_close(dir); + storage_file_free(dir); + furi_record_close(RECORD_STORAGE); + return true; +} + +void ir_helper_free_file_list(FuriString **files, size_t count) { + if (!files) + return; + for (size_t i = 0; i < count; i++) { + if (files[i]) { + furi_string_free(files[i]); + } + } + free(files); +} diff --git a/helpers/ir_helper.h b/helpers/ir_helper.h @@ -0,0 +1,84 @@ +#pragma once + +#include <furi.h> +#include <infrared.h> +#include <lib/infrared/signal/infrared_signal.h> + +/** + * IR signal container with name. + */ +typedef struct { + InfraredSignal *signal; + FuriString *name; +} IrSignalItem; + +/** + * List of signals parsed from an .ir file. + */ +typedef struct { + IrSignalItem *items; + size_t count; + size_t capacity; +} IrSignalList; + +/** + * Allocate an IR signal list. + */ +IrSignalList *ir_signal_list_alloc(void); + +/** + * Free an IR signal list and all contained signals. + */ +void ir_signal_list_free(IrSignalList *list); + +/** + * Record an IR signal from the receiver. + * Blocks until signal is received or timeout. + * + * @param signal Output: allocated InfraredSignal (caller must free) + * @param timeout_ms Timeout in milliseconds + * @return true if signal recorded, false on timeout/error + */ +bool ir_helper_record(InfraredSignal **signal, uint32_t timeout_ms); + +/** + * Save a signal to an .ir file (appends if file exists). + * + * @param signal The signal to save + * @param name Signal name (e.g., "Power") + * @param path File path (e.g., "/ext/infrared/timed_remote.ir") + * @return true on success + */ +bool ir_helper_save(InfraredSignal *signal, const char *name, const char *path); + +/** + * Load all signals from an .ir file. + * + * @param path File path to load + * @param list Output list of signals + * @return true on success + */ +bool ir_helper_load_file(const char *path, IrSignalList *list); + +/** + * Transmit an IR signal. + * + * @param signal The signal to transmit + */ +void ir_helper_transmit(InfraredSignal *signal); + +/** + * List .ir files in a directory. + * + * @param dir_path Directory path (e.g., "/ext/infrared") + * @param files Output: array of FuriString* (caller frees) + * @param count Output: number of files + * @return true on success + */ +bool ir_helper_list_files(const char *dir_path, FuriString ***files, + size_t *count); + +/** + * Free a list of file paths. + */ +void ir_helper_free_file_list(FuriString **files, size_t count); diff --git a/helpers/time_helper.c b/helpers/time_helper.c @@ -0,0 +1,48 @@ +#include "time_helper.h" + +#ifndef TIMED_REMOTE_TEST_BUILD +#include <furi_hal.h> +#include <stdio.h> +#endif + +uint32_t time_helper_hms_to_seconds(uint8_t h, uint8_t m, uint8_t s) { + return (uint32_t)h * 3600 + (uint32_t)m * 60 + (uint32_t)s; +} + +void time_helper_seconds_to_hms(uint32_t total_seconds, uint8_t *h, uint8_t *m, + uint8_t *s) { + /* Handle times >= 24 hours by wrapping */ + total_seconds = total_seconds % (24 * 3600); + + *h = (uint8_t)(total_seconds / 3600); + total_seconds %= 3600; + *m = (uint8_t)(total_seconds / 60); + *s = (uint8_t)(total_seconds % 60); +} + +#ifndef TIMED_REMOTE_TEST_BUILD +uint32_t time_helper_seconds_until(uint8_t target_h, uint8_t target_m, + uint8_t target_s) { + DateTime now; + furi_hal_rtc_get_datetime(&now); + + uint32_t now_seconds = + time_helper_hms_to_seconds(now.hour, now.minute, now.second); + uint32_t target_seconds = + time_helper_hms_to_seconds(target_h, target_m, target_s); + + if (target_seconds <= now_seconds) { + return 0; /* Target time has already passed today */ + } + + return target_seconds - now_seconds; +} + +void time_helper_generate_signal_name(char *buffer, size_t buffer_size) { + DateTime now; + furi_hal_rtc_get_datetime(&now); + + snprintf(buffer, buffer_size, "IR_%04d%02d%02d_%02d%02d%02d", now.year, + now.month, now.day, now.hour, now.minute, now.second); +} +#endif diff --git a/helpers/time_helper.h b/helpers/time_helper.h @@ -0,0 +1,49 @@ +#pragma once + +#include <stdbool.h> +#include <stddef.h> +#include <stdint.h> + +/** + * Convert hours, minutes, seconds to total seconds. + * + * @param h Hours (0-23) + * @param m Minutes (0-59) + * @param s Seconds (0-59) + * @return Total seconds + */ +uint32_t time_helper_hms_to_seconds(uint8_t h, uint8_t m, uint8_t s); + +/** + * Convert total seconds to hours, minutes, seconds. + * + * @param total_seconds Total seconds to convert + * @param h Output: hours (0-23, wraps at 24) + * @param m Output: minutes (0-59) + * @param s Output: seconds (0-59) + */ +void time_helper_seconds_to_hms(uint32_t total_seconds, uint8_t *h, uint8_t *m, + uint8_t *s); + +#ifndef TIMED_REMOTE_TEST_BUILD +/** + * Calculate seconds remaining until a target time (today). + * Uses Flipper's RTC. If target time has passed, returns 0. + * + * @param target_h Target hour (0-23) + * @param target_m Target minute (0-59) + * @param target_s Target second (0-59) + * @return Seconds until target, or 0 if already passed + */ +uint32_t time_helper_seconds_until(uint8_t target_h, uint8_t target_m, + uint8_t target_s); + +/** + * Generate a timestamp-based signal name placeholder. + * Format: IR_YYYYMMDD_HHMMSS + * + * @param buffer Output buffer (must be at least 20 bytes) + * @param buffer_size Size of buffer + */ +void time_helper_generate_signal_name(char *buffer, size_t buffer_size); +#endif diff --git a/scenes/scene_confirm.c b/scenes/scene_confirm.c @@ -0,0 +1,46 @@ +#include "../timed_remote.h" +#include "timed_remote_scene.h" + +static void confirm_popup_callback(void *context) { + TimedRemoteApp *app = context; + view_dispatcher_send_custom_event(app->view_dispatcher, + TimedRemoteEventConfirmDone); +} + +void timed_remote_scene_confirm_on_enter(void *context) { + TimedRemoteApp *app = context; + + popup_reset(app->popup); + popup_set_header(app->popup, "Signal Sent!", 64, 20, AlignCenter, + AlignCenter); + popup_set_text(app->popup, app->signal_name, 64, 35, AlignCenter, + AlignCenter); + popup_set_timeout(app->popup, 2000); /* 2 seconds */ + popup_set_context(app->popup, app); + popup_set_callback(app->popup, confirm_popup_callback); + popup_enable_timeout(app->popup); + + view_dispatcher_switch_to_view(app->view_dispatcher, TimedRemoteViewPopup); +} + +bool timed_remote_scene_confirm_on_event(void *context, + SceneManagerEvent event) { + TimedRemoteApp *app = context; + bool consumed = false; + + if (event.type == SceneManagerEventTypeCustom) { + if (event.event == TimedRemoteEventConfirmDone) { + /* Return to main menu */ + scene_manager_search_and_switch_to_previous_scene( + app->scene_manager, TimedRemoteSceneMainMenu); + consumed = true; + } + } + + return consumed; +} + +void timed_remote_scene_confirm_on_exit(void *context) { + TimedRemoteApp *app = context; + popup_reset(app->popup); +} diff --git a/scenes/scene_ir_browse.c b/scenes/scene_ir_browse.c @@ -0,0 +1,69 @@ +#include "../helpers/ir_helper.h" +#include "../timed_remote.h" +#include "timed_remote_scene.h" + +#define IR_DIR_PATH "/ext/infrared" + +static FuriString **file_list = NULL; +static size_t file_count = 0; + +static void ir_browse_callback(void *context, uint32_t index) { + TimedRemoteApp *app = context; + if (index < file_count) { + /* Store selected file path */ + snprintf(app->selected_file_path, sizeof(app->selected_file_path), "%s/%s", + IR_DIR_PATH, furi_string_get_cstr(file_list[index])); + view_dispatcher_send_custom_event(app->view_dispatcher, + TimedRemoteEventFileSelected); + } +} + +void timed_remote_scene_ir_browse_on_enter(void *context) { + TimedRemoteApp *app = context; + + submenu_reset(app->submenu); + submenu_set_header(app->submenu, "Select IR File"); + + /* Get list of .ir files */ + if (ir_helper_list_files(IR_DIR_PATH, &file_list, &file_count)) { + if (file_count == 0) { + submenu_add_item(app->submenu, "(No IR files found)", 0, NULL, NULL); + } else { + for (size_t i = 0; i < file_count; i++) { + submenu_add_item(app->submenu, furi_string_get_cstr(file_list[i]), i, + ir_browse_callback, app); + } + } + } else { + submenu_add_item(app->submenu, "(Error reading directory)", 0, NULL, NULL); + } + + view_dispatcher_switch_to_view(app->view_dispatcher, TimedRemoteViewSubmenu); +} + +bool timed_remote_scene_ir_browse_on_event(void *context, + SceneManagerEvent event) { + TimedRemoteApp *app = context; + bool consumed = false; + + if (event.type == SceneManagerEventTypeCustom) { + if (event.event == TimedRemoteEventFileSelected) { + scene_manager_next_scene(app->scene_manager, TimedRemoteSceneIrSelect); + consumed = true; + } + } + + return consumed; +} + +void timed_remote_scene_ir_browse_on_exit(void *context) { + TimedRemoteApp *app = context; + submenu_reset(app->submenu); + + /* Free file list */ + if (file_list) { + ir_helper_free_file_list(file_list, file_count); + file_list = NULL; + file_count = 0; + } +} diff --git a/scenes/scene_ir_record.c b/scenes/scene_ir_record.c @@ -0,0 +1,57 @@ +#include "../helpers/ir_helper.h" +#include "../timed_remote.h" +#include "timed_remote_scene.h" + +#define RECORD_TIMEOUT_MS 30000 + +void timed_remote_scene_ir_record_on_enter(void *context) { + TimedRemoteApp *app = context; + + widget_reset(app->widget); + widget_add_string_element(app->widget, 64, 10, AlignCenter, AlignTop, + FontPrimary, "Recording IR..."); + widget_add_string_element(app->widget, 64, 26, AlignCenter, AlignTop, + FontSecondary, "Point remote at Flipper"); + widget_add_string_element(app->widget, 64, 40, AlignCenter, AlignTop, + FontSecondary, "and press a button"); + + view_dispatcher_switch_to_view(app->view_dispatcher, TimedRemoteViewWidget); + + /* Start recording in a thread or use async approach */ + /* For now, blocking call - consider making async for better UX */ + InfraredSignal *signal = NULL; + bool success = ir_helper_record(&signal, RECORD_TIMEOUT_MS); + + if (success && signal) { + /* Free any previous signal */ + if (app->ir_signal) { + infrared_signal_free(app->ir_signal); + } + app->ir_signal = signal; + view_dispatcher_send_custom_event(app->view_dispatcher, + TimedRemoteEventRecordComplete); + } else { + /* Timeout or error - go back */ + scene_manager_previous_scene(app->scene_manager); + } +} + +bool timed_remote_scene_ir_record_on_event(void *context, + SceneManagerEvent event) { + TimedRemoteApp *app = context; + bool consumed = false; + + if (event.type == SceneManagerEventTypeCustom) { + if (event.event == TimedRemoteEventRecordComplete) { + scene_manager_next_scene(app->scene_manager, TimedRemoteSceneSignalName); + consumed = true; + } + } + + return consumed; +} + +void timed_remote_scene_ir_record_on_exit(void *context) { + TimedRemoteApp *app = context; + widget_reset(app->widget); +} diff --git a/scenes/scene_ir_select.c b/scenes/scene_ir_select.c @@ -0,0 +1,79 @@ +#include "../helpers/ir_helper.h" +#include "../timed_remote.h" +#include "timed_remote_scene.h" + +static IrSignalList *signal_list = NULL; + +static void ir_select_callback(void *context, uint32_t index) { + TimedRemoteApp *app = context; + if (signal_list && index < signal_list->count) { + /* Free any previous signal */ + if (app->ir_signal) { + infrared_signal_free(app->ir_signal); + } + /* Copy the selected signal */ + app->ir_signal = infrared_signal_alloc(); + infrared_signal_set_signal(app->ir_signal, + signal_list->items[index].signal); + + /* Copy signal name */ + strncpy(app->signal_name, + furi_string_get_cstr(signal_list->items[index].name), + sizeof(app->signal_name) - 1); + app->signal_name[sizeof(app->signal_name) - 1] = '\0'; + + view_dispatcher_send_custom_event(app->view_dispatcher, + TimedRemoteEventSignalSelected); + } +} + +void timed_remote_scene_ir_select_on_enter(void *context) { + TimedRemoteApp *app = context; + + submenu_reset(app->submenu); + submenu_set_header(app->submenu, "Select Signal"); + + /* Load signals from selected file */ + signal_list = ir_signal_list_alloc(); + if (ir_helper_load_file(app->selected_file_path, signal_list)) { + if (signal_list->count == 0) { + submenu_add_item(app->submenu, "(No signals in file)", 0, NULL, NULL); + } else { + for (size_t i = 0; i < signal_list->count; i++) { + submenu_add_item(app->submenu, + furi_string_get_cstr(signal_list->items[i].name), i, + ir_select_callback, app); + } + } + } else { + submenu_add_item(app->submenu, "(Error reading file)", 0, NULL, NULL); + } + + view_dispatcher_switch_to_view(app->view_dispatcher, TimedRemoteViewSubmenu); +} + +bool timed_remote_scene_ir_select_on_event(void *context, + SceneManagerEvent event) { + TimedRemoteApp *app = context; + bool consumed = false; + + if (event.type == SceneManagerEventTypeCustom) { + if (event.event == TimedRemoteEventSignalSelected) { + scene_manager_next_scene(app->scene_manager, TimedRemoteSceneTimerMode); + consumed = true; + } + } + + return consumed; +} + +void timed_remote_scene_ir_select_on_exit(void *context) { + TimedRemoteApp *app = context; + submenu_reset(app->submenu); + + /* Free signal list */ + if (signal_list) { + ir_signal_list_free(signal_list); + signal_list = NULL; + } +} diff --git a/scenes/scene_ir_source.c b/scenes/scene_ir_source.c @@ -0,0 +1,54 @@ +#include "../timed_remote.h" +#include "timed_remote_scene.h" + +enum { + IrSourceIndexRecord, + IrSourceIndexBrowse, +}; + +static void ir_source_callback(void *context, uint32_t index) { + TimedRemoteApp *app = context; + if (index == IrSourceIndexRecord) { + view_dispatcher_send_custom_event(app->view_dispatcher, + TimedRemoteEventRecordSignal); + } else if (index == IrSourceIndexBrowse) { + view_dispatcher_send_custom_event(app->view_dispatcher, + TimedRemoteEventBrowseFiles); + } +} + +void timed_remote_scene_ir_source_on_enter(void *context) { + TimedRemoteApp *app = context; + + submenu_reset(app->submenu); + submenu_set_header(app->submenu, "Select IR Source"); + submenu_add_item(app->submenu, "Record New Signal", IrSourceIndexRecord, + ir_source_callback, app); + submenu_add_item(app->submenu, "Browse IR Files", IrSourceIndexBrowse, + ir_source_callback, app); + + view_dispatcher_switch_to_view(app->view_dispatcher, TimedRemoteViewSubmenu); +} + +bool timed_remote_scene_ir_source_on_event(void *context, + SceneManagerEvent event) { + TimedRemoteApp *app = context; + bool consumed = false; + + if (event.type == SceneManagerEventTypeCustom) { + if (event.event == TimedRemoteEventRecordSignal) { + scene_manager_next_scene(app->scene_manager, TimedRemoteSceneIrRecord); + consumed = true; + } else if (event.event == TimedRemoteEventBrowseFiles) { + scene_manager_next_scene(app->scene_manager, TimedRemoteSceneIrBrowse); + consumed = true; + } + } + + return consumed; +} + +void timed_remote_scene_ir_source_on_exit(void *context) { + TimedRemoteApp *app = context; + submenu_reset(app->submenu); +} diff --git a/scenes/scene_main_menu.c b/scenes/scene_main_menu.c @@ -0,0 +1,45 @@ +#include "../timed_remote.h" +#include "timed_remote_scene.h" + +enum { + MainMenuIndexStartTimer, +}; + +static void main_menu_callback(void *context, uint32_t index) { + TimedRemoteApp *app = context; + if (index == MainMenuIndexStartTimer) { + view_dispatcher_send_custom_event(app->view_dispatcher, + TimedRemoteEventStartTimer); + } +} + +void timed_remote_scene_main_menu_on_enter(void *context) { + TimedRemoteApp *app = context; + + submenu_reset(app->submenu); + submenu_set_header(app->submenu, "Timed Remote"); + submenu_add_item(app->submenu, "Start New Timer", MainMenuIndexStartTimer, + main_menu_callback, app); + + view_dispatcher_switch_to_view(app->view_dispatcher, TimedRemoteViewSubmenu); +} + +bool timed_remote_scene_main_menu_on_event(void *context, + SceneManagerEvent event) { + TimedRemoteApp *app = context; + bool consumed = false; + + if (event.type == SceneManagerEventTypeCustom) { + if (event.event == TimedRemoteEventStartTimer) { + scene_manager_next_scene(app->scene_manager, TimedRemoteSceneIrSource); + consumed = true; + } + } + + return consumed; +} + +void timed_remote_scene_main_menu_on_exit(void *context) { + TimedRemoteApp *app = context; + submenu_reset(app->submenu); +} diff --git a/scenes/scene_repeat_options.c b/scenes/scene_repeat_options.c @@ -0,0 +1,100 @@ +#include "../timed_remote.h" +#include "timed_remote_scene.h" + +enum { + RepeatOptionsIndexToggle, + RepeatOptionsIndexCount, + RepeatOptionsIndexConfirm, +}; + +static void repeat_toggle_change(VariableItem *item) { + TimedRemoteApp *app = variable_item_get_context(item); + uint8_t index = variable_item_get_current_value_index(item); + app->repeat_enabled = (index == 1); + variable_item_set_current_value_text(item, + app->repeat_enabled ? "On" : "Off"); +} + +static void repeat_count_change(VariableItem *item) { + TimedRemoteApp *app = variable_item_get_context(item); + uint8_t index = variable_item_get_current_value_index(item); + app->repeat_count = index; /* 0 = unlimited, 1-99 = fixed */ + + char buf[16]; + if (app->repeat_count == 0) { + snprintf(buf, sizeof(buf), "Unlimited"); + } else { + snprintf(buf, sizeof(buf), "%d", app->repeat_count); + } + variable_item_set_current_value_text(item, buf); +} + +static void repeat_options_enter_callback(void *context, uint32_t index) { + TimedRemoteApp *app = context; + if (index == RepeatOptionsIndexConfirm) { + view_dispatcher_send_custom_event(app->view_dispatcher, + TimedRemoteEventRepeatConfigured); + } +} + +void timed_remote_scene_repeat_options_on_enter(void *context) { + TimedRemoteApp *app = context; + VariableItem *item; + + variable_item_list_reset(app->variable_item_list); + + /* Repeat toggle: Off/On */ + item = variable_item_list_add(app->variable_item_list, "Repeat", 2, + repeat_toggle_change, app); + variable_item_set_current_value_index(item, app->repeat_enabled ? 1 : 0); + variable_item_set_current_value_text(item, + app->repeat_enabled ? "On" : "Off"); + + /* Repeat count: 0 = unlimited, 1-99 = fixed */ + item = variable_item_list_add(app->variable_item_list, "Count", 100, + repeat_count_change, app); + variable_item_set_current_value_index(item, app->repeat_count); + if (app->repeat_count == 0) { + variable_item_set_current_value_text(item, "Unlimited"); + } else { + char buf[8]; + snprintf(buf, sizeof(buf), "%d", app->repeat_count); + variable_item_set_current_value_text(item, buf); + } + + /* Confirm button */ + variable_item_list_add(app->variable_item_list, ">> Start Timer <<", 0, NULL, + NULL); + + variable_item_list_set_enter_callback(app->variable_item_list, + repeat_options_enter_callback, app); + + view_dispatcher_switch_to_view(app->view_dispatcher, + TimedRemoteViewVariableItemList); +} + +bool timed_remote_scene_repeat_options_on_event(void *context, + SceneManagerEvent event) { + TimedRemoteApp *app = context; + bool consumed = false; + + if (event.type == SceneManagerEventTypeCustom) { + if (event.event == TimedRemoteEventRepeatConfigured) { + /* Initialize repeats remaining */ + app->repeats_remaining = + app->repeat_enabled + ? (app->repeat_count == 0 ? 255 : app->repeat_count) + : 1; + scene_manager_next_scene(app->scene_manager, + TimedRemoteSceneTimerRunning); + consumed = true; + } + } + + return consumed; +} + +void timed_remote_scene_repeat_options_on_exit(void *context) { + TimedRemoteApp *app = context; + variable_item_list_reset(app->variable_item_list); +} diff --git a/scenes/scene_signal_name.c b/scenes/scene_signal_name.c @@ -0,0 +1,61 @@ +#include "../helpers/ir_helper.h" +#include "../helpers/time_helper.h" +#include "../timed_remote.h" +#include "timed_remote_scene.h" + +#define TIMED_REMOTE_IR_PATH "/ext/infrared/timed_remote.ir" + +static void signal_name_text_input_callback(void *context) { + TimedRemoteApp *app = context; + view_dispatcher_send_custom_event(app->view_dispatcher, + TimedRemoteEventRecordComplete); +} + +void timed_remote_scene_signal_name_on_enter(void *context) { + TimedRemoteApp *app = context; + + /* Generate default name based on timestamp */ + time_helper_generate_signal_name(app->text_input_buffer, + sizeof(app->text_input_buffer)); + + text_input_reset(app->text_input); + text_input_set_header_text(app->text_input, "Name this signal:"); + text_input_set_result_callback( + app->text_input, signal_name_text_input_callback, app, + app->text_input_buffer, sizeof(app->text_input_buffer), + true /* clear default text on focus */); + + view_dispatcher_switch_to_view(app->view_dispatcher, + TimedRemoteViewTextInput); +} + +bool timed_remote_scene_signal_name_on_event(void *context, + SceneManagerEvent event) { + TimedRemoteApp *app = context; + bool consumed = false; + + if (event.type == SceneManagerEventTypeCustom) { + if (event.event == TimedRemoteEventRecordComplete) { + /* Save the signal name */ + strncpy(app->signal_name, app->text_input_buffer, + sizeof(app->signal_name) - 1); + app->signal_name[sizeof(app->signal_name) - 1] = '\0'; + + /* Save signal to file */ + if (app->ir_signal) { + ir_helper_save(app->ir_signal, app->signal_name, TIMED_REMOTE_IR_PATH); + } + + /* Proceed to timer mode selection */ + scene_manager_next_scene(app->scene_manager, TimedRemoteSceneTimerMode); + consumed = true; + } + } + + return consumed; +} + +void timed_remote_scene_signal_name_on_exit(void *context) { + TimedRemoteApp *app = context; + text_input_reset(app->text_input); +} diff --git a/scenes/scene_time_input.c b/scenes/scene_time_input.c @@ -0,0 +1,116 @@ +#include "../timed_remote.h" +#include "timed_remote_scene.h" + +/* State indices for VariableItemList */ +enum { + TimeInputIndexHours, + TimeInputIndexMinutes, + TimeInputIndexSeconds, + TimeInputIndexConfirm, +}; + +static void time_input_hours_change(VariableItem *item) { + TimedRemoteApp *app = variable_item_get_context(item); + uint8_t index = variable_item_get_current_value_index(item); + app->hours = index; + char buf[4]; + snprintf(buf, sizeof(buf), "%02d", app->hours); + variable_item_set_current_value_text(item, buf); +} + +static void time_input_minutes_change(VariableItem *item) { + TimedRemoteApp *app = variable_item_get_context(item); + uint8_t index = variable_item_get_current_value_index(item); + app->minutes = index; + char buf[4]; + snprintf(buf, sizeof(buf), "%02d", app->minutes); + variable_item_set_current_value_text(item, buf); +} + +static void time_input_seconds_change(VariableItem *item) { + TimedRemoteApp *app = variable_item_get_context(item); + uint8_t index = variable_item_get_current_value_index(item); + app->seconds = index; + char buf[4]; + snprintf(buf, sizeof(buf), "%02d", app->seconds); + variable_item_set_current_value_text(item, buf); +} + +static void time_input_enter_callback(void *context, uint32_t index) { + TimedRemoteApp *app = context; + if (index == TimeInputIndexConfirm) { + view_dispatcher_send_custom_event(app->view_dispatcher, + TimedRemoteEventTimeSet); + } +} + +void timed_remote_scene_time_input_on_enter(void *context) { + TimedRemoteApp *app = context; + VariableItem *item; + char buf[4]; + + variable_item_list_reset(app->variable_item_list); + + /* Note: VariableItemList doesn't support headers, the mode context is + * implicit */ + + /* Hours: 0-23 */ + item = variable_item_list_add(app->variable_item_list, "Hours", 24, + time_input_hours_change, app); + variable_item_set_current_value_index(item, app->hours); + snprintf(buf, sizeof(buf), "%02d", app->hours); + variable_item_set_current_value_text(item, buf); + + /* Minutes: 0-59 */ + item = variable_item_list_add(app->variable_item_list, "Minutes", 60, + time_input_minutes_change, app); + variable_item_set_current_value_index(item, app->minutes); + snprintf(buf, sizeof(buf), "%02d", app->minutes); + variable_item_set_current_value_text(item, buf); + + /* Seconds: 0-59 */ + item = variable_item_list_add(app->variable_item_list, "Seconds", 60, + time_input_seconds_change, app); + variable_item_set_current_value_index(item, app->seconds); + snprintf(buf, sizeof(buf), "%02d", app->seconds); + variable_item_set_current_value_text(item, buf); + + /* Confirm button */ + variable_item_list_add(app->variable_item_list, ">> Start Timer <<", 0, NULL, + NULL); + + variable_item_list_set_enter_callback(app->variable_item_list, + time_input_enter_callback, app); + + view_dispatcher_switch_to_view(app->view_dispatcher, + TimedRemoteViewVariableItemList); +} + +bool timed_remote_scene_time_input_on_event(void *context, + SceneManagerEvent event) { + TimedRemoteApp *app = context; + bool consumed = false; + + if (event.type == SceneManagerEventTypeCustom) { + if (event.event == TimedRemoteEventTimeSet) { + if (app->timer_mode == TimerModeCountdown) { + /* Countdown mode - go to repeat options */ + scene_manager_next_scene(app->scene_manager, + TimedRemoteSceneRepeatOptions); + } else { + /* Scheduled mode - go directly to timer */ + app->repeat_enabled = false; + scene_manager_next_scene(app->scene_manager, + TimedRemoteSceneTimerRunning); + } + consumed = true; + } + } + + return consumed; +} + +void timed_remote_scene_time_input_on_exit(void *context) { + TimedRemoteApp *app = context; + variable_item_list_reset(app->variable_item_list); +} diff --git a/scenes/scene_timer_mode.c b/scenes/scene_timer_mode.c @@ -0,0 +1,56 @@ +#include "../timed_remote.h" +#include "timed_remote_scene.h" + +enum { + TimerModeIndexCountdown, + TimerModeIndexScheduled, +}; + +static void timer_mode_callback(void *context, uint32_t index) { + TimedRemoteApp *app = context; + if (index == TimerModeIndexCountdown) { + view_dispatcher_send_custom_event(app->view_dispatcher, + TimedRemoteEventModeCountdown); + } else if (index == TimerModeIndexScheduled) { + view_dispatcher_send_custom_event(app->view_dispatcher, + TimedRemoteEventModeScheduled); + } +} + +void timed_remote_scene_timer_mode_on_enter(void *context) { + TimedRemoteApp *app = context; + + submenu_reset(app->submenu); + submenu_set_header(app->submenu, "Timer Mode"); + submenu_add_item(app->submenu, "Countdown (in X time)", + TimerModeIndexCountdown, timer_mode_callback, app); + submenu_add_item(app->submenu, "Scheduled (at X time)", + TimerModeIndexScheduled, timer_mode_callback, app); + + view_dispatcher_switch_to_view(app->view_dispatcher, TimedRemoteViewSubmenu); +} + +bool timed_remote_scene_timer_mode_on_event(void *context, + SceneManagerEvent event) { + TimedRemoteApp *app = context; + bool consumed = false; + + if (event.type == SceneManagerEventTypeCustom) { + if (event.event == TimedRemoteEventModeCountdown) { + app->timer_mode = TimerModeCountdown; + scene_manager_next_scene(app->scene_manager, TimedRemoteSceneTimeInput); + consumed = true; + } else if (event.event == TimedRemoteEventModeScheduled) { + app->timer_mode = TimerModeScheduled; + scene_manager_next_scene(app->scene_manager, TimedRemoteSceneTimeInput); + consumed = true; + } + } + + return consumed; +} + +void timed_remote_scene_timer_mode_on_exit(void *context) { + TimedRemoteApp *app = context; + submenu_reset(app->submenu); +} diff --git a/scenes/scene_timer_running.c b/scenes/scene_timer_running.c @@ -0,0 +1,127 @@ +#include "../helpers/ir_helper.h" +#include "../helpers/time_helper.h" +#include "../timed_remote.h" +#include "timed_remote_scene.h" + +static void timer_callback(void *context) { + TimedRemoteApp *app = context; + view_dispatcher_send_custom_event(app->view_dispatcher, + TimedRemoteEventTimerTick); +} + +static void update_display(TimedRemoteApp *app) { + uint8_t h, m, s; + time_helper_seconds_to_hms(app->seconds_remaining, &h, &m, &s); + + char time_str[16]; + snprintf(time_str, sizeof(time_str), "%02d:%02d:%02d", h, m, s); + + widget_reset(app->widget); + widget_add_string_element(app->widget, 64, 5, AlignCenter, AlignTop, + FontSecondary, app->signal_name); + widget_add_string_element(app->widget, 64, 25, AlignCenter, AlignTop, + FontBigNumbers, time_str); + + if (app->repeat_enabled && app->repeat_count > 0) { + char repeat_str[24]; + snprintf(repeat_str, sizeof(repeat_str), "Repeat: %d/%d", + app->repeat_count - app->repeats_remaining + 1, app->repeat_count); + widget_add_string_element(app->widget, 64, 52, AlignCenter, AlignTop, + FontSecondary, repeat_str); + } else if (app->repeat_enabled) { + widget_add_string_element(app->widget, 64, 52, AlignCenter, AlignTop, + FontSecondary, "Repeat: Unlimited"); + } +} + +void timed_remote_scene_timer_running_on_enter(void *context) { + TimedRemoteApp *app = context; + + /* Calculate initial remaining time */ + if (app->timer_mode == TimerModeCountdown) { + app->seconds_remaining = + time_helper_hms_to_seconds(app->hours, app->minutes, app->seconds); + } else { + /* Scheduled mode - calculate time until target */ + app->seconds_remaining = + time_helper_seconds_until(app->hours, app->minutes, app->seconds); + if (app->seconds_remaining == 0) { + /* Target time already passed - fire immediately */ + view_dispatcher_send_custom_event(app->view_dispatcher, + TimedRemoteEventTimerFired); + return; + } + } + + /* Initialize repeat tracking for non-repeat or scheduled */ + if (!app->repeat_enabled) { + app->repeats_remaining = 1; + } + + update_display(app); + view_dispatcher_switch_to_view(app->view_dispatcher, TimedRemoteViewWidget); + + /* Start 1-second timer */ + app->timer = furi_timer_alloc(timer_callback, FuriTimerTypePeriodic, app); + furi_timer_start(app->timer, furi_kernel_get_tick_frequency()); /* 1 second */ +} + +bool timed_remote_scene_timer_running_on_event(void *context, + SceneManagerEvent event) { + TimedRemoteApp *app = context; + bool consumed = false; + + if (event.type == SceneManagerEventTypeCustom) { + if (event.event == TimedRemoteEventTimerTick) { + if (app->seconds_remaining > 0) { + app->seconds_remaining--; + update_display(app); + } + + if (app->seconds_remaining == 0) { + /* Timer fired! */ + view_dispatcher_send_custom_event(app->view_dispatcher, + TimedRemoteEventTimerFired); + } + consumed = true; + } else if (event.event == TimedRemoteEventTimerFired) { + /* Transmit IR signal */ + if (app->ir_signal) { + ir_helper_transmit(app->ir_signal); + } + + app->repeats_remaining--; + + if (app->repeat_enabled && app->repeats_remaining > 0) { + /* Reset countdown for next repeat */ + app->seconds_remaining = + time_helper_hms_to_seconds(app->hours, app->minutes, app->seconds); + update_display(app); + } else { + /* Done - show confirmation */ + scene_manager_next_scene(app->scene_manager, TimedRemoteSceneConfirm); + } + consumed = true; + } + } else if (event.type == SceneManagerEventTypeBack) { + /* User pressed Back - cancel timer */ + scene_manager_search_and_switch_to_previous_scene(app->scene_manager, + TimedRemoteSceneMainMenu); + consumed = true; + } + + return consumed; +} + +void timed_remote_scene_timer_running_on_exit(void *context) { + TimedRemoteApp *app = context; + + /* Stop and free timer */ + if (app->timer) { + furi_timer_stop(app->timer); + furi_timer_free(app->timer); + app->timer = NULL; + } + + widget_reset(app->widget); +} diff --git a/scenes/timed_remote_scene.c b/scenes/timed_remote_scene.c @@ -0,0 +1,53 @@ +#include "timed_remote_scene.h" + +/* Scene handler tables */ + +void (*const timed_remote_scene_on_enter_handlers[])(void *) = { + timed_remote_scene_main_menu_on_enter, + timed_remote_scene_ir_source_on_enter, + timed_remote_scene_ir_record_on_enter, + timed_remote_scene_signal_name_on_enter, + timed_remote_scene_ir_browse_on_enter, + timed_remote_scene_ir_select_on_enter, + timed_remote_scene_timer_mode_on_enter, + timed_remote_scene_time_input_on_enter, + timed_remote_scene_repeat_options_on_enter, + timed_remote_scene_timer_running_on_enter, + timed_remote_scene_confirm_on_enter, +}; + +bool (*const timed_remote_scene_on_event_handlers[])(void *, + SceneManagerEvent) = { + timed_remote_scene_main_menu_on_event, + timed_remote_scene_ir_source_on_event, + timed_remote_scene_ir_record_on_event, + timed_remote_scene_signal_name_on_event, + timed_remote_scene_ir_browse_on_event, + timed_remote_scene_ir_select_on_event, + timed_remote_scene_timer_mode_on_event, + timed_remote_scene_time_input_on_event, + timed_remote_scene_repeat_options_on_event, + timed_remote_scene_timer_running_on_event, + timed_remote_scene_confirm_on_event, +}; + +void (*const timed_remote_scene_on_exit_handlers[])(void *) = { + timed_remote_scene_main_menu_on_exit, + timed_remote_scene_ir_source_on_exit, + timed_remote_scene_ir_record_on_exit, + timed_remote_scene_signal_name_on_exit, + timed_remote_scene_ir_browse_on_exit, + timed_remote_scene_ir_select_on_exit, + timed_remote_scene_timer_mode_on_exit, + timed_remote_scene_time_input_on_exit, + timed_remote_scene_repeat_options_on_exit, + timed_remote_scene_timer_running_on_exit, + timed_remote_scene_confirm_on_exit, +}; + +const SceneManagerHandlers timed_remote_scene_handlers = { + .on_enter_handlers = timed_remote_scene_on_enter_handlers, + .on_event_handlers = timed_remote_scene_on_event_handlers, + .on_exit_handlers = timed_remote_scene_on_exit_handlers, + .scene_num = TimedRemoteSceneCount, +}; diff --git a/scenes/timed_remote_scene.h b/scenes/timed_remote_scene.h @@ -0,0 +1,107 @@ +#pragma once + +#include <gui/scene_manager.h> + +/* Scene IDs */ +typedef enum { + TimedRemoteSceneMainMenu, + TimedRemoteSceneIrSource, + TimedRemoteSceneIrRecord, + TimedRemoteSceneSignalName, + TimedRemoteSceneIrBrowse, + TimedRemoteSceneIrSelect, + TimedRemoteSceneTimerMode, + TimedRemoteSceneTimeInput, + TimedRemoteSceneRepeatOptions, + TimedRemoteSceneTimerRunning, + TimedRemoteSceneConfirm, + TimedRemoteSceneCount, +} TimedRemoteScene; + +/* Scene event IDs */ +typedef enum { + TimedRemoteSceneEventConsumed = true, + TimedRemoteSceneEventNotConsumed = false, +} TimedRemoteSceneEvent; + +/* Custom events */ +typedef enum { + /* Main menu */ + TimedRemoteEventStartTimer, + /* IR source */ + TimedRemoteEventRecordSignal, + TimedRemoteEventBrowseFiles, + /* Recording complete */ + TimedRemoteEventRecordComplete, + /* File/signal selected */ + TimedRemoteEventFileSelected, + TimedRemoteEventSignalSelected, + /* Timer mode */ + TimedRemoteEventModeCountdown, + TimedRemoteEventModeScheduled, + /* Time input complete */ + TimedRemoteEventTimeSet, + /* Repeat options */ + TimedRemoteEventRepeatConfigured, + /* Timer events */ + TimedRemoteEventTimerTick, + TimedRemoteEventTimerFired, + /* Confirmation */ + TimedRemoteEventConfirmDone, +} TimedRemoteCustomEvent; + +/* Scene handlers - declared extern, defined in individual scene files */ +extern void timed_remote_scene_main_menu_on_enter(void *context); +extern bool timed_remote_scene_main_menu_on_event(void *context, + SceneManagerEvent event); +extern void timed_remote_scene_main_menu_on_exit(void *context); + +extern void timed_remote_scene_ir_source_on_enter(void *context); +extern bool timed_remote_scene_ir_source_on_event(void *context, + SceneManagerEvent event); +extern void timed_remote_scene_ir_source_on_exit(void *context); + +extern void timed_remote_scene_ir_record_on_enter(void *context); +extern bool timed_remote_scene_ir_record_on_event(void *context, + SceneManagerEvent event); +extern void timed_remote_scene_ir_record_on_exit(void *context); + +extern void timed_remote_scene_signal_name_on_enter(void *context); +extern bool timed_remote_scene_signal_name_on_event(void *context, + SceneManagerEvent event); +extern void timed_remote_scene_signal_name_on_exit(void *context); + +extern void timed_remote_scene_ir_browse_on_enter(void *context); +extern bool timed_remote_scene_ir_browse_on_event(void *context, + SceneManagerEvent event); +extern void timed_remote_scene_ir_browse_on_exit(void *context); + +extern void timed_remote_scene_ir_select_on_enter(void *context); +extern bool timed_remote_scene_ir_select_on_event(void *context, + SceneManagerEvent event); +extern void timed_remote_scene_ir_select_on_exit(void *context); + +extern void timed_remote_scene_timer_mode_on_enter(void *context); +extern bool timed_remote_scene_timer_mode_on_event(void *context, + SceneManagerEvent event); +extern void timed_remote_scene_timer_mode_on_exit(void *context); + +extern void timed_remote_scene_time_input_on_enter(void *context); +extern bool timed_remote_scene_time_input_on_event(void *context, + SceneManagerEvent event); +extern void timed_remote_scene_time_input_on_exit(void *context); + +extern void timed_remote_scene_repeat_options_on_enter(void *context); +extern bool timed_remote_scene_repeat_options_on_event(void *context, + SceneManagerEvent event); +extern void timed_remote_scene_repeat_options_on_exit(void *context); + +extern void timed_remote_scene_timer_running_on_enter(void *context); +extern bool timed_remote_scene_timer_running_on_event(void *context, + SceneManagerEvent event); +extern void timed_remote_scene_timer_running_on_exit(void *context); + +extern void timed_remote_scene_confirm_on_enter(void *context); +extern bool timed_remote_scene_confirm_on_event(void *context, + SceneManagerEvent event); +extern void timed_remote_scene_confirm_on_exit(void *context); diff --git a/timed_remote.c b/timed_remote.c @@ -1,12 +1,120 @@ -#include <furi.h> +#include "timed_remote.h" +#include "scenes/timed_remote_scene.h" -/* generated by fbt from .png files in images folder */ -/* #include <demo_app_icons.h> */ +extern const SceneManagerHandlers timed_remote_scene_handlers; -int32_t timed_remote_app(void* p) { - UNUSED(p); - FURI_LOG_I("TEST", "Hello world"); - FURI_LOG_I("TEST", "I'm demo_app!"); +/* View dispatcher navigation callback */ +static bool timed_remote_navigation_callback(void *context) { + TimedRemoteApp *app = context; + return scene_manager_handle_back_event(app->scene_manager); +} + +/* View dispatcher custom event callback */ +static bool timed_remote_custom_event_callback(void *context, + uint32_t custom_event) { + TimedRemoteApp *app = context; + return scene_manager_handle_custom_event(app->scene_manager, custom_event); +} + +TimedRemoteApp *timed_remote_app_alloc(void) { + TimedRemoteApp *app = malloc(sizeof(TimedRemoteApp)); + memset(app, 0, sizeof(TimedRemoteApp)); + + /* Open GUI record */ + app->gui = furi_record_open(RECORD_GUI); + + /* Allocate view dispatcher */ + app->view_dispatcher = view_dispatcher_alloc(); + view_dispatcher_set_event_callback_context(app->view_dispatcher, app); + view_dispatcher_set_navigation_event_callback( + app->view_dispatcher, timed_remote_navigation_callback); + view_dispatcher_set_custom_event_callback(app->view_dispatcher, + timed_remote_custom_event_callback); + view_dispatcher_attach_to_gui(app->view_dispatcher, app->gui, + ViewDispatcherTypeFullscreen); + + /* Allocate scene manager */ + app->scene_manager = scene_manager_alloc(&timed_remote_scene_handlers, app); + + /* Allocate views */ + app->submenu = submenu_alloc(); + view_dispatcher_add_view(app->view_dispatcher, TimedRemoteViewSubmenu, + submenu_get_view(app->submenu)); + + app->variable_item_list = variable_item_list_alloc(); + view_dispatcher_add_view( + app->view_dispatcher, TimedRemoteViewVariableItemList, + variable_item_list_get_view(app->variable_item_list)); + + app->text_input = text_input_alloc(); + view_dispatcher_add_view(app->view_dispatcher, TimedRemoteViewTextInput, + text_input_get_view(app->text_input)); + + app->widget = widget_alloc(); + view_dispatcher_add_view(app->view_dispatcher, TimedRemoteViewWidget, + widget_get_view(app->widget)); + + app->popup = popup_alloc(); + view_dispatcher_add_view(app->view_dispatcher, TimedRemoteViewPopup, + popup_get_view(app->popup)); + + return app; +} + +void timed_remote_app_free(TimedRemoteApp *app) { + /* Free timer if still running */ + if (app->timer) { + furi_timer_stop(app->timer); + furi_timer_free(app->timer); + } + + /* Free IR signal if allocated */ + if (app->ir_signal) { + infrared_signal_free(app->ir_signal); + } + + /* Remove and free views */ + view_dispatcher_remove_view(app->view_dispatcher, TimedRemoteViewSubmenu); + submenu_free(app->submenu); + + view_dispatcher_remove_view(app->view_dispatcher, + TimedRemoteViewVariableItemList); + variable_item_list_free(app->variable_item_list); + + view_dispatcher_remove_view(app->view_dispatcher, TimedRemoteViewTextInput); + text_input_free(app->text_input); + + view_dispatcher_remove_view(app->view_dispatcher, TimedRemoteViewWidget); + widget_free(app->widget); + + view_dispatcher_remove_view(app->view_dispatcher, TimedRemoteViewPopup); + popup_free(app->popup); + + /* Free scene manager */ + scene_manager_free(app->scene_manager); + + /* Free view dispatcher */ + view_dispatcher_free(app->view_dispatcher); + + /* Close GUI record */ + furi_record_close(RECORD_GUI); + + free(app); +} + +int32_t timed_remote_app(void *p) { + UNUSED(p); + + TimedRemoteApp *app = timed_remote_app_alloc(); + + /* Start with main menu scene */ + scene_manager_next_scene(app->scene_manager, TimedRemoteSceneMainMenu); + + /* Run event loop */ + view_dispatcher_run(app->view_dispatcher); + + /* Cleanup */ + timed_remote_app_free(app); - return 0; + return 0; } diff --git a/timed_remote.h b/timed_remote.h @@ -0,0 +1,73 @@ +#pragma once + +#include <furi.h> +#include <gui/gui.h> +#include <gui/modules/popup.h> +#include <gui/modules/submenu.h> +#include <gui/modules/text_input.h> +#include <gui/modules/variable_item_list.h> +#include <gui/modules/widget.h> +#include <gui/scene_manager.h> +#include <gui/view_dispatcher.h> +#include <lib/infrared/signal/infrared_signal.h> + +/* Timer mode enumeration */ +typedef enum { + TimerModeCountdown, + TimerModeScheduled, +} TimerMode; + +/* View IDs for ViewDispatcher */ +typedef enum { + TimedRemoteViewSubmenu, + TimedRemoteViewVariableItemList, + TimedRemoteViewTextInput, + TimedRemoteViewWidget, + TimedRemoteViewPopup, +} TimedRemoteView; + +/* Maximum lengths */ +#define SIGNAL_NAME_MAX_LEN 32 +#define FILE_PATH_MAX_LEN 256 + +/* Main application state */ +typedef struct { + /* Core Flipper components */ + Gui *gui; + ViewDispatcher *view_dispatcher; + SceneManager *scene_manager; + + /* Views */ + Submenu *submenu; + VariableItemList *variable_item_list; + TextInput *text_input; + Widget *widget; + Popup *popup; + + /* IR state */ + InfraredSignal *ir_signal; + char signal_name[SIGNAL_NAME_MAX_LEN]; + char selected_file_path[FILE_PATH_MAX_LEN]; + + /* Timer configuration */ + TimerMode timer_mode; + uint8_t hours; + uint8_t minutes; + uint8_t seconds; + + /* Repeat options (Countdown mode only) */ + bool repeat_enabled; + uint8_t repeat_count; /* 0 = unlimited */ + uint8_t repeats_remaining; + + /* Timer runtime state */ + FuriTimer *timer; + uint32_t seconds_remaining; + + /* Text input buffer */ + char text_input_buffer[SIGNAL_NAME_MAX_LEN]; +} TimedRemoteApp; + +/* App lifecycle */ +TimedRemoteApp *timed_remote_app_alloc(void); +void timed_remote_app_free(TimedRemoteApp *app);