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