From 96b5884a87a5f1eca3c516bb936ea9dad325c2c7 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 29 Jun 2026 14:48:18 +0100 Subject: [PATCH] Add double/long press, redesign UI to v0.3 with circular d-pad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Double press and long press detection per button (5 buttons × 3 event types = 15 distinct BTHome events) - New circular d-pad UI: large ring with chevron arrows, numbered buttons (1=centre, 2=up, 3=down, 4=left, 5=right) outside/inside the ring - Bluetooth icon asset (bluetooth_10x10.png) in top-left via canvas_draw_icon - Back arrow icon (Pin_back_arrow_10x8.png) bottom-right as exit hint - Version v0.3 bottom-left; direction + event label centred when active - Thread-safe timer callbacks via view_dispatcher_send_custom_event Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01EYNXvRg4xjHHE9oPH9kYYU --- application.fam | 4 +- assets/Pin_back_arrow_10x10.png | Bin 0 -> 90 bytes assets/Pin_back_arrow_10x8.png | Bin 0 -> 90 bytes assets/bluetooth_10x10.png | Bin 0 -> 96 bytes assets/hid_ble_10px.png | Bin 0 -> 96 bytes src/bt_home_controller.c | 549 +++++++++++++++++++++----------- 6 files changed, 369 insertions(+), 184 deletions(-) create mode 100644 assets/Pin_back_arrow_10x10.png create mode 100644 assets/Pin_back_arrow_10x8.png create mode 100644 assets/bluetooth_10x10.png create mode 100644 assets/hid_ble_10px.png diff --git a/application.fam b/application.fam index 2b619a5..6762ad9 100644 --- a/application.fam +++ b/application.fam @@ -5,8 +5,8 @@ App( entry_point="bt_home_controller_app", stack_size=2 * 1024, fap_category="Bluetooth", - fap_version="0.1", - fap_icon="bthome.png", + fap_version="0.3", + fap_icon="assets/bluetooth_10x10.png", fap_description="BTHome D-pad controller for the Flipper Zero", fap_author="Alessandro Ghedini", fap_weburl="https://github.com/ghedo/flipper-bthome", diff --git a/assets/Pin_back_arrow_10x10.png b/assets/Pin_back_arrow_10x10.png new file mode 100644 index 0000000000000000000000000000000000000000..a11c5c2825efa09e1218877f1c78108f27aa7c4f GIT binary patch literal 90 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2xkYHHq`AGmsDSEm%hE&W+{!>5UB|kIA--HB< m0|$QmQxaxp7Uq^PG+6g^!WLn`JZ@A*IBB?B|_VaB$f md^&vmGesn3NbnihRWL9ls5Ur5*F)|L$Vj tulr2n@!{d|IW8GdR-3Ztdxz&lMuxeII5)+8P-F*b^>p=fS?83{1OR(_90~vc literal 0 HcmV?d00001 diff --git a/assets/hid_ble_10px.png b/assets/hid_ble_10px.png new file mode 100644 index 0000000000000000000000000000000000000000..27355f8dbab9f62f03c3114bd345117b72703df2 GIT binary patch literal 96 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2xkYHHq`AGmsse8IOhE&W+{!>5Ur5*F)|L$Vj tulr2n@!{d|IW8GdR-3Ztdxz&lMuxeII5)+8P-F*b^>p=fS?83{1OR(_90~vc literal 0 HcmV?d00001 diff --git a/src/bt_home_controller.c b/src/bt_home_controller.c index 9f45c52..f96c081 100644 --- a/src/bt_home_controller.c +++ b/src/bt_home_controller.c @@ -1,13 +1,12 @@ #include #include #include +#include #include #include #include -#include - // BTHome UUID: 0xFCD2 (little-endian). #define BTHOME_UUID_LSB 0xD2 #define BTHOME_UUID_MSB 0xFC @@ -22,18 +21,19 @@ #define BTHOME_OBJ_BATTERY 0x01 #define BTHOME_OBJ_BUTTON 0x3A -// Button event values. -#define BTHOME_BUTTON_EVENT_NONE 0x00 -#define BTHOME_BUTTON_EVENT_PRESS 0x01 -#define BTHOME_BUTTON_EVENT_LONG_PRESS 0x04 +// BTHome v2 button event values. +#define BTHOME_BUTTON_NONE 0x00 +#define BTHOME_BUTTON_PRESS 0x01 +#define BTHOME_BUTTON_DOUBLE 0x02 +#define BTHOME_BUTTON_LONG 0x04 -// App values. -#define APP_LOG_TAG "BTHOME" +#define APP_LOG_TAG "BTHOME" +#define APP_VIEW_MAIN 0 +#define APP_BEACON_MS 1000 // how long to keep beacon alive after event +#define DOUBLE_PRESS_MS 350 // window for detecting a second tap -#define APP_VIEW_MAIN 0 -#define APP_BEACON_TIMER 1000 - -// D-pad button indices — order determines BTHome button numbering (button, button_2 … button_5). +// D-pad button indices — order determines BTHome button numbering +// (button, button_2 ... button_5 in HA). #define BTN_OK 0 #define BTN_UP 1 #define BTN_DOWN 2 @@ -41,270 +41,451 @@ #define BTN_RIGHT 4 #define BTN_COUNT 5 -// Payload offsets. -static size_t payload_battery_value_off = 0; +// Custom events routed through ViewDispatcher (thread-safe). +// Values 0..BTN_COUNT-1 = double-press window expired for that button. +// Value BTN_COUNT = beacon clear. +#define APP_EV_DOUBLE_TIMEOUT(btn) ((uint32_t)(btn)) +#define APP_EV_BEACON_OFF ((uint32_t)BTN_COUNT) + +// Payload byte offsets — filled by bthome_init_beacon_payload(). +static size_t payload_battery_value_off = 0; static size_t payload_packet_id_value_off = 0; static size_t payload_button_off[BTN_COUNT] = {0}; +typedef struct BtHomeApp BtHomeApp; + +// Per-button context for double-press timer callbacks. typedef struct { - ViewDispatcher *view_dispatcher; + BtHomeApp* app; + int btn; +} BtnCtx; - View *main_view; - - uint8_t packet_id; - - uint8_t beacon_payload[EXTRA_BEACON_MAX_DATA_SIZE]; - size_t beacon_payload_len; - - FuriTimer *beacon_timer; -} BtHomeApp; +struct BtHomeApp { + ViewDispatcher* view_dispatcher; + View* main_view; + uint8_t packet_id; + uint8_t beacon_payload[EXTRA_BEACON_MAX_DATA_SIZE]; + size_t beacon_payload_len; + FuriTimer* beacon_timer; + FuriTimer* double_timers[BTN_COUNT]; + BtnCtx btn_ctx[BTN_COUNT]; + bool btn_pending[BTN_COUNT]; // true while double-press window is open +}; typedef struct { InputKey active_key; - bool is_pressed; + bool is_pressed; + uint8_t last_event; // last BTHOME_BUTTON_* value dispatched + InputKey last_btn_key; // which key the last event was for } BtHomeModel; +// Circular d-pad geometry. Screen 128×64; no title bar; label strip y=54..63. +#define DPAD_CX 64 // centre x — horizontally centred +#define DPAD_CY 32 // centre y — near vertical centre, leaves strip room at bottom +#define DPAD_R 24 // outer circle radius (enlarged to fill freed title-bar space) +#define DPAD_R_OK 6 // centre (OK) button radius +#define DPAD_DISC_D 15 // distance from centre to active-segment disc centre +#define DPAD_DISC_R 8 // active-segment disc radius +#define DPAD_ARR_D 19 // distance from centre to arrow tip +#define DPAD_ARR_B 14 // distance from centre to arrow base (closer than tip) +#define DPAD_ARR_W 5 // half-width of arrow at its base + +/* ── helpers ─────────────────────────────────────────────────── */ + static int bthome_key_to_btn(InputKey key) { - switch (key) { - case InputKeyOk: return BTN_OK; - case InputKeyUp: return BTN_UP; - case InputKeyDown: return BTN_DOWN; - case InputKeyLeft: return BTN_LEFT; - case InputKeyRight: return BTN_RIGHT; - default: return -1; + switch(key) { + case InputKeyOk: return BTN_OK; + case InputKeyUp: return BTN_UP; + case InputKeyDown: return BTN_DOWN; + case InputKeyLeft: return BTN_LEFT; + case InputKeyRight: return BTN_RIGHT; + default: return -1; } } -// Draws a 11x11 D-pad at (x, y). The active cell is filled; others are outlined. -// Layout (each cell 3x3, 1px gap): -// [U] -// [L][O][R] -// [D] -static void bthome_draw_dpad(Canvas *canvas, int x, int y, bool is_pressed, InputKey active_key) { - const struct { InputKey key; int dx; int dy; } cells[BTN_COUNT] = { - { InputKeyOk, 4, 4 }, - { InputKeyUp, 4, 0 }, - { InputKeyDown, 4, 8 }, - { InputKeyLeft, 0, 4 }, - { InputKeyRight, 8, 4 }, +static InputKey bthome_btn_to_key(int btn) { + switch(btn) { + case BTN_OK: return InputKeyOk; + case BTN_UP: return InputKeyUp; + case BTN_DOWN: return InputKeyDown; + case BTN_LEFT: return InputKeyLeft; + case BTN_RIGHT: return InputKeyRight; + default: return InputKeyOk; + } +} + +static const char* bthome_key_to_direction(InputKey key) { + switch(key) { + case InputKeyOk: return "centre"; + case InputKeyUp: return "up"; + case InputKeyDown: return "down"; + case InputKeyLeft: return "left"; + case InputKeyRight: return "right"; + default: return ""; + } +} + +// Circular d-pad centred at (DPAD_CX, DPAD_CY). +// Draw order ensures a clean segmented look: +// 1. Outer circle border +// 2. Cross dividers (horizontal + vertical) +// 3. Active-segment filled disc (covers cross lines in that quadrant) +// 4. Centre OK circle (outline or filled) +// 5. Chevron arrows (white on active segment, black elsewhere) +static void bthome_draw_dpad(Canvas* canvas, InputKey active_key, uint8_t ev) { + const int cx = DPAD_CX, cy = DPAD_CY; + bool has_ev = (ev != BTHOME_BUTTON_NONE); + bool ok_active = has_ev && (active_key == InputKeyOk); + + canvas_set_color(canvas, ColorBlack); + + // 1. Outer circle. + canvas_draw_circle(canvas, cx, cy, DPAD_R); + + // 2. Cross dividers — omitted for cleaner look. + + // 3. Filled disc for the active directional button. + if(has_ev && !ok_active) { + int dx = 0, dy = 0; + if(active_key == InputKeyUp) dy = -DPAD_DISC_D; + else if(active_key == InputKeyDown) dy = +DPAD_DISC_D; + else if(active_key == InputKeyLeft) dx = -DPAD_DISC_D; + else if(active_key == InputKeyRight) dx = +DPAD_DISC_D; + canvas_draw_disc(canvas, cx + dx, cy + dy, DPAD_DISC_R); + } + + // 4. Centre OK circle. + if(ok_active) + canvas_draw_disc(canvas, cx, cy, DPAD_R_OK); + else + canvas_draw_circle(canvas, cx, cy, DPAD_R_OK); + + // 5. Chevron arrows — white when that direction is active. + const struct { + InputKey key; + int tx, ty; // tip + int lx, ly, rx, ry; // left-arm end, right-arm end + } arrows[4] = { + {InputKeyUp, cx, cy - DPAD_ARR_D, + cx - DPAD_ARR_W, cy - DPAD_ARR_B, + cx + DPAD_ARR_W, cy - DPAD_ARR_B}, + {InputKeyDown, cx, cy + DPAD_ARR_D, + cx - DPAD_ARR_W, cy + DPAD_ARR_B, + cx + DPAD_ARR_W, cy + DPAD_ARR_B}, + {InputKeyLeft, cx - DPAD_ARR_D, cy, + cx - DPAD_ARR_B, cy - DPAD_ARR_W, + cx - DPAD_ARR_B, cy + DPAD_ARR_W}, + {InputKeyRight, cx + DPAD_ARR_D, cy, + cx + DPAD_ARR_B, cy - DPAD_ARR_W, + cx + DPAD_ARR_B, cy + DPAD_ARR_W}, }; - - for (int i = 0; i < BTN_COUNT; i++) { - int cx = x + cells[i].dx; - int cy = y + cells[i].dy; - if (is_pressed && active_key == cells[i].key) { - canvas_draw_box(canvas, cx, cy, 3, 3); - } else { - canvas_draw_frame(canvas, cx, cy, 3, 3); - } + for(int i = 0; i < 4; i++) { + bool active = has_ev && (active_key == arrows[i].key); + canvas_set_color(canvas, active ? ColorWhite : ColorBlack); + canvas_draw_line(canvas, arrows[i].tx, arrows[i].ty, arrows[i].lx, arrows[i].ly); + canvas_draw_line(canvas, arrows[i].tx, arrows[i].ty, arrows[i].rx, arrows[i].ry); } + + // 6. Button numbers. + canvas_set_font(canvas, FontSecondary); + // "1" inside OK circle — inverts white when active. + canvas_set_color(canvas, ok_active ? ColorWhite : ColorBlack); + canvas_draw_str_aligned(canvas, cx, cy, AlignCenter, AlignCenter, "1"); + // 2-5 outside the ring — always black (on white background). + canvas_set_color(canvas, ColorBlack); + canvas_draw_str_aligned(canvas, cx, cy - DPAD_R - 4, AlignCenter, AlignCenter, "2"); + canvas_draw_str_aligned(canvas, cx, cy + DPAD_R + 5, AlignCenter, AlignCenter, "3"); + canvas_draw_str_aligned(canvas, cx - DPAD_R - 5, cy, AlignCenter, AlignCenter, "4"); + canvas_draw_str_aligned(canvas, cx + DPAD_R + 5, cy, AlignCenter, AlignCenter, "5"); } -static void bthome_draw_callback(Canvas *canvas, void *ctx) { - BtHomeModel *model = ctx; +/* ── beacon helpers ──────────────────────────────────────────── */ + +// Must only be called from the ViewDispatcher (GUI) thread. +static void bthome_send_event(BtHomeApp* app, int btn, uint8_t ev_val) { + app->beacon_payload[payload_packet_id_value_off] = app->packet_id++; + app->beacon_payload[payload_battery_value_off] = furi_hal_power_get_pct(); + + for(int i = 0; i < BTN_COUNT; i++) + app->beacon_payload[payload_button_off[i]] = BTHOME_BUTTON_NONE; + app->beacon_payload[payload_button_off[btn]] = ev_val; + + furi_hal_bt_extra_beacon_set_data(app->beacon_payload, app->beacon_payload_len); + if(!furi_hal_bt_extra_beacon_is_active()) + furi_hal_bt_extra_beacon_start(); + + furi_timer_start(app->beacon_timer, APP_BEACON_MS); + + with_view_model( + app->main_view, + BtHomeModel* model, + { + model->last_event = ev_val; + model->last_btn_key = bthome_btn_to_key(btn); + }, + true); +} + +/* ── draw ────────────────────────────────────────────────────── */ + +static void bthome_draw_callback(Canvas* canvas, void* ctx) { + BtHomeModel* model = ctx; furi_assert(model); canvas_clear(canvas); + canvas_set_color(canvas, ColorBlack); - canvas_set_bitmap_mode(canvas, true); + // BT icon — top left, no title text, no separator. + canvas_draw_icon(canvas, 2, 1, &I_bluetooth_10x10); - // Logo moved to y=0 to leave 12px at the bottom for controls. - canvas_draw_icon(canvas, 3, 0, &I_bthome_123x52); + // D-pad: show last-sent event (1 s) → physical hold → idle. + InputKey highlight_key; + uint8_t highlight_ev; + if(model->last_event != BTHOME_BUTTON_NONE) { + highlight_key = model->last_btn_key; + highlight_ev = model->last_event; + } else if(model->is_pressed) { + highlight_key = model->active_key; + highlight_ev = BTHOME_BUTTON_PRESS; + } else { + highlight_key = InputKeyOk; + highlight_ev = BTHOME_BUTTON_NONE; + } + bthome_draw_dpad(canvas, highlight_key, highlight_ev); canvas_set_font(canvas, FontSecondary); - canvas_draw_str_aligned(canvas, 3, 54, AlignLeft, AlignTop, "D-pad to send"); + // Left: version. + canvas_draw_str_aligned(canvas, 2, 56, AlignLeft, AlignTop, "v0.3"); - bthome_draw_dpad(canvas, 107, 53, model->is_pressed, model->active_key); + // Right: back-arrow icon (exit hint). + canvas_draw_icon(canvas, 116, 56, &I_Pin_back_arrow_10x8); + + // Centre: direction + event type when active. + if(model->last_event != BTHOME_BUTTON_NONE) { + const char* dir = bthome_key_to_direction(model->last_btn_key); + const char* ev_type = + model->last_event == BTHOME_BUTTON_DOUBLE ? "double press" : + model->last_event == BTHOME_BUTTON_LONG ? "long press" : "press"; + char ev_label[32]; + snprintf(ev_label, sizeof(ev_label), "%s %s", dir, ev_type); + canvas_draw_str_aligned(canvas, 64, 56, AlignCenter, AlignTop, ev_label); + } } -static bool bthome_input_callback(InputEvent *event, void *ctx) { - BtHomeApp *app = ctx; - switch (event->key) { - case InputKeyBack: { - if (event->type == InputTypeShort) { - view_dispatcher_stop(app->view_dispatcher); - return true; - } - break; - } - case InputKeyOk: - case InputKeyUp: - case InputKeyDown: - case InputKeyLeft: - case InputKeyRight: { - if ((event->type == InputTypeShort) || - (event->type == InputTypeLong)) +/* ── timer callbacks (run on timer thread) ───────────────────── */ + +static void bthome_double_timer_cb(void* ctx) { + BtnCtx* bc = ctx; + // Thread-safe: just enqueue; don't touch shared state here. + view_dispatcher_send_custom_event( + bc->app->view_dispatcher, APP_EV_DOUBLE_TIMEOUT(bc->btn)); +} + +static void bthome_beacon_timer_callback(void* ctx) { + BtHomeApp* app = ctx; + view_dispatcher_send_custom_event(app->view_dispatcher, APP_EV_BEACON_OFF); +} + +/* ── custom event callback (runs on ViewDispatcher / GUI thread) */ + +static bool bthome_custom_callback(void* ctx, uint32_t event) { + BtHomeApp* app = ctx; + + if(event == APP_EV_BEACON_OFF) { + if(furi_hal_bt_extra_beacon_is_active()) + furi_hal_bt_extra_beacon_stop(); + for(int i = 0; i < BTN_COUNT; i++) + app->beacon_payload[payload_button_off[i]] = BTHOME_BUTTON_NONE; + with_view_model( + app->main_view, + BtHomeModel* model, { - int btn = bthome_key_to_btn(event->key); + model->last_event = BTHOME_BUTTON_NONE; + model->last_btn_key = InputKeyOk; + }, + true); + return true; + } - app->beacon_payload[payload_packet_id_value_off] = - app->packet_id++; - - app->beacon_payload[payload_battery_value_off] = - furi_hal_power_get_pct(); - - for (int i = 0; i < BTN_COUNT; i++) { - app->beacon_payload[payload_button_off[i]] = - BTHOME_BUTTON_EVENT_NONE; - } - - app->beacon_payload[payload_button_off[btn]] = - event->type == InputTypeShort ? - BTHOME_BUTTON_EVENT_PRESS : - BTHOME_BUTTON_EVENT_LONG_PRESS; - - furi_hal_bt_extra_beacon_set_data(app->beacon_payload, - app->beacon_payload_len); - - if (!furi_hal_bt_extra_beacon_is_active()) { - furi_hal_bt_extra_beacon_start(); - - furi_timer_start(app->beacon_timer, APP_BEACON_TIMER); - } - } - - if ((event->type == InputTypePress) || - (event->type == InputTypeRelease)) - { - with_view_model( - app->main_view, - BtHomeModel * model, - { - model->active_key = event->key; - model->is_pressed = (event->type == InputTypePress); - }, - true - ); - } - - return true; + // Double-press window expired — fire the deferred single press. + if(event < (uint32_t)BTN_COUNT) { + int btn = (int)event; + if(app->btn_pending[btn]) { + app->btn_pending[btn] = false; + bthome_send_event(app, btn, BTHOME_BUTTON_PRESS); } - - default: - break; + return true; } return false; } -static void bthome_beacon_timer_callback(void *ctx) { - BtHomeApp *app = ctx; +/* ── input callback (runs on ViewDispatcher / GUI thread) ────── */ - if (furi_hal_bt_extra_beacon_is_active()) { - furi_hal_bt_extra_beacon_stop(); +static bool bthome_input_callback(InputEvent* event, void* ctx) { + BtHomeApp* app = ctx; + + switch(event->key) { + case InputKeyBack: + if(event->type == InputTypeShort) { + view_dispatcher_stop(app->view_dispatcher); + return true; + } + break; + + case InputKeyOk: + case InputKeyUp: + case InputKeyDown: + case InputKeyLeft: + case InputKeyRight: { + int btn = bthome_key_to_btn(event->key); + + if(event->type == InputTypeShort) { + if(app->btn_pending[btn]) { + // Second tap within window → double press. + furi_timer_stop(app->double_timers[btn]); + app->btn_pending[btn] = false; + bthome_send_event(app, btn, BTHOME_BUTTON_DOUBLE); + } else { + // First tap — open double-press window. + app->btn_pending[btn] = true; + furi_timer_start(app->double_timers[btn], DOUBLE_PRESS_MS); + } + } else if(event->type == InputTypeLong) { + // Long press — cancel any pending single and fire long. + if(app->btn_pending[btn]) { + furi_timer_stop(app->double_timers[btn]); + app->btn_pending[btn] = false; + } + bthome_send_event(app, btn, BTHOME_BUTTON_LONG); + } + + // Visual feedback: fill the d-pad cell while the key is held. + if(event->type == InputTypePress || event->type == InputTypeRelease) { + with_view_model( + app->main_view, + BtHomeModel* model, + { + model->active_key = event->key; + model->is_pressed = (event->type == InputTypePress); + }, + true); + } + + return true; } - for (int i = 0; i < BTN_COUNT; i++) { - app->beacon_payload[payload_button_off[i]] = BTHOME_BUTTON_EVENT_NONE; + default: + break; } + + return false; } -static void bthome_init_beacon_payload(uint8_t *payload, size_t *payload_len) { +/* ── payload init ────────────────────────────────────────────── */ + +static void bthome_init_beacon_payload(uint8_t* payload, size_t* payload_len) { size_t i = 0; - // Flags data. - payload[i++] = 2; // Flags length. + // Flags. + payload[i++] = 2; payload[i++] = BTHOME_AD_TYPE_FLAGS; - payload[i++] = 0x06; // LE General Discoverable Mode + BR/EDR Not Supported. + payload[i++] = 0x06; // LE General Discoverable + BR/EDR Not Supported - // Service data. - // Length = 1 (type) + 2 (UUID) + 1 (device_info) + 2 (packet_id) + 2 (battery) + BTN_COUNT * 2 + // Service data: + // 1 (type) + 2 (UUID) + 1 (device_info) + 2 (packet_id) + 2 (battery) + BTN_COUNT*2 payload[i++] = 8 + BTN_COUNT * 2; payload[i++] = BTHOME_AD_TYPE_SERVICE_DATA; payload[i++] = BTHOME_UUID_LSB; payload[i++] = BTHOME_UUID_MSB; - payload[i++] = 0x44; // no encryption, trigger=true, version=2. + payload[i++] = 0x44; // no encryption, trigger-based, version 2 - // Packet ID object data. payload[i++] = BTHOME_OBJ_PACKET_ID; payload_packet_id_value_off = i; payload[i++] = 0x00; - // Battery object data. payload[i++] = BTHOME_OBJ_BATTERY; payload_battery_value_off = i; payload[i++] = 0x00; - // Button event objects: OK, Up, Down, Left, Right. - // Home Assistant names them button, button_2, button_3, button_4, button_5. - for (int btn = 0; btn < BTN_COUNT; btn++) { + // Button objects: OK=button, Up=button_2, Down=button_3, Left=button_4, Right=button_5. + for(int btn = 0; btn < BTN_COUNT; btn++) { payload[i++] = BTHOME_OBJ_BUTTON; payload_button_off[btn] = i; - payload[i++] = BTHOME_BUTTON_EVENT_NONE; + payload[i++] = BTHOME_BUTTON_NONE; } - // Complete local name. - const char *name = furi_hal_version_get_device_name_ptr(); - size_t name_len = strlen(name); - - // Truncate name if not enough space left. - // - // 2 bytes for the header + the actual name length. - if (i + 2 + name_len > EXTRA_BEACON_MAX_DATA_SIZE) { + // Complete local name (truncated if it doesn't fit). + const char* name = furi_hal_version_get_device_name_ptr(); + size_t name_len = strlen(name); + if(i + 2 + name_len > EXTRA_BEACON_MAX_DATA_SIZE) { name_len = EXTRA_BEACON_MAX_DATA_SIZE - (i + 2); - - FURI_LOG_E(APP_LOG_TAG, "name len %zu", name_len); + FURI_LOG_E(APP_LOG_TAG, "name truncated to %zu", name_len); } - - payload[i++] = 1 + name_len; // Name object length. + payload[i++] = 1 + name_len; payload[i++] = BTHOME_AD_TYPE_COMPLETE_LOCAL_NAME; - memcpy(&payload[i], name, name_len); i += name_len; *payload_len = i; } -static BtHomeApp *bthome_app_alloc(void) { - BtHomeApp *app = malloc(sizeof(BtHomeApp)); - if (!app) { - FURI_LOG_E(APP_LOG_TAG, "Failed to allocate app"); +/* ── lifecycle ───────────────────────────────────────────────── */ +static BtHomeApp* bthome_app_alloc(void) { + BtHomeApp* app = malloc(sizeof(BtHomeApp)); + if(!app) { + FURI_LOG_E(APP_LOG_TAG, "alloc failed"); return NULL; } app->packet_id = 0; + memset(app->btn_pending, 0, sizeof(app->btn_pending)); bthome_init_beacon_payload(app->beacon_payload, &app->beacon_payload_len); - app->beacon_timer = furi_timer_alloc(bthome_beacon_timer_callback, - FuriTimerTypeOnce, - app); + app->beacon_timer = + furi_timer_alloc(bthome_beacon_timer_callback, FuriTimerTypeOnce, app); + + for(int i = 0; i < BTN_COUNT; i++) { + app->btn_ctx[i] = (BtnCtx){.app = app, .btn = i}; + app->double_timers[i] = + furi_timer_alloc(bthome_double_timer_cb, FuriTimerTypeOnce, &app->btn_ctx[i]); + } furi_hal_bt_extra_beacon_stop(); GapExtraBeaconConfig config = { .min_adv_interval_ms = 100, .max_adv_interval_ms = 200, - .adv_channel_map = GapAdvChannelMapAll, - .adv_power_level = GapAdvPowerLevel_6dBm, - .address_type = GapAddressTypePublic, + .adv_channel_map = GapAdvChannelMapAll, + .adv_power_level = GapAdvPowerLevel_6dBm, + .address_type = GapAddressTypePublic, }; - memcpy(config.address, furi_hal_version_get_ble_mac(), sizeof(config.address)); - if (!furi_hal_bt_extra_beacon_set_config(&config)) { - FURI_LOG_E("BTHOME", "Failed to set beacon config"); + if(!furi_hal_bt_extra_beacon_set_config(&config)) { + FURI_LOG_E(APP_LOG_TAG, "beacon config failed"); free(app); return NULL; } app->view_dispatcher = view_dispatcher_alloc(); - if (!app->view_dispatcher) { - FURI_LOG_E(APP_LOG_TAG, "Failed to allocate view dispatcher"); - + if(!app->view_dispatcher) { + FURI_LOG_E(APP_LOG_TAG, "view_dispatcher alloc failed"); free(app); return NULL; } + view_dispatcher_set_custom_event_callback(app->view_dispatcher, bthome_custom_callback); + view_dispatcher_set_event_callback_context(app->view_dispatcher, app); + app->main_view = view_alloc(); - view_allocate_model(app->main_view, ViewModelTypeLocking, sizeof(BtHomeModel)); - view_set_draw_callback(app->main_view, bthome_draw_callback); view_set_input_callback(app->main_view, bthome_input_callback); view_set_context(app->main_view, app); @@ -314,12 +495,19 @@ static BtHomeApp *bthome_app_alloc(void) { return app; } -static void bthome_app_free(BtHomeApp *app) { +static void bthome_app_free(BtHomeApp* app) { furi_hal_bt_extra_beacon_stop(); + // Stop all timers before freeing the ViewDispatcher so no in-flight + // callback can post to a freed queue. furi_timer_stop(app->beacon_timer); furi_timer_free(app->beacon_timer); + for(int i = 0; i < BTN_COUNT; i++) { + furi_timer_stop(app->double_timers[i]); + furi_timer_free(app->double_timers[i]); + } + view_dispatcher_remove_view(app->view_dispatcher, APP_VIEW_MAIN); view_free_model(app->main_view); view_free(app->main_view); @@ -328,23 +516,20 @@ static void bthome_app_free(BtHomeApp *app) { free(app); } -int32_t bt_home_controller_app(void *p) { +/* ── entry point ─────────────────────────────────────────────── */ + +int32_t bt_home_controller_app(void* p) { UNUSED(p); - BtHomeApp *app = bthome_app_alloc(); - if (!app) { - return -1; - } - - Gui *gui = furi_record_open(RECORD_GUI); + BtHomeApp* app = bthome_app_alloc(); + if(!app) return -1; + Gui* gui = furi_record_open(RECORD_GUI); view_dispatcher_attach_to_gui(app->view_dispatcher, gui, ViewDispatcherTypeFullscreen); view_dispatcher_switch_to_view(app->view_dispatcher, APP_VIEW_MAIN); view_dispatcher_run(app->view_dispatcher); furi_record_close(RECORD_GUI); - bthome_app_free(app); - return 0; }