commit ef0ecf97e115765b2fbc46aa53150eaa03ad8ccf Author: Jon Date: Sat May 2 22:21:41 2026 +0100 Working build of App - can use dpad not just centre button diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..df9afdc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +0.1 +Initial release diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7c6296 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +![bthome app](screenshots/bthome_app.png) + +This application turns the Flipper Zero into a [BTHome](https://bthome.io) +beacon, and can be used to integrate the Flipper with home automation systems +that support BTHome, such as Home Assistant. + +[BTHome](https://bthome.io) is an open standard for broadcasting sensor data +over Bluetooth Low Energy (BLE). It allows devices to transmit sensor readings +(temperature, humidity, battery level, button events, etc.) without requiring +pairing or an active connection. + +The BTHome beacon is triggered when pressing the Flipper Zero "OK" button +and currently includes the Flipper's battery percentage as a sensor. + +Unfortunately, due to the limited size of BLE packets the Flipper Zero can send +there isn't much room left to add more sensors without removing the existing +one. + +### Home Assistant setup + +The first step is setting up the [BTHome](https://www.home-assistant.io/integrations/bthome/) integration in Home Assistant. + +Once setup, the Flipper should be automatically discovered after pressing the +center button once: + +![home assistant discovered](screenshots/home_assistant_discovered.png) + +The device's page will then show the current battery status and its events: + +![home assistant paired](screenshots/home_assistant_paired.png) diff --git a/application.fam b/application.fam new file mode 100644 index 0000000..2b619a5 --- /dev/null +++ b/application.fam @@ -0,0 +1,14 @@ +App( + appid="bt_home_controller", + name="BT Home Controller", + apptype=FlipperAppType.EXTERNAL, + entry_point="bt_home_controller_app", + stack_size=2 * 1024, + fap_category="Bluetooth", + fap_version="0.1", + fap_icon="bthome.png", + fap_description="BTHome D-pad controller for the Flipper Zero", + fap_author="Alessandro Ghedini", + fap_weburl="https://github.com/ghedo/flipper-bthome", + fap_icon_assets="assets", +) diff --git a/assets/.gitkeep b/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/Ok_btn_9x9.png b/assets/Ok_btn_9x9.png new file mode 100644 index 0000000..ceff4e8 Binary files /dev/null and b/assets/Ok_btn_9x9.png differ diff --git a/assets/Ok_btn_pressed_13x12.png b/assets/Ok_btn_pressed_13x12.png new file mode 100644 index 0000000..4044083 Binary files /dev/null and b/assets/Ok_btn_pressed_13x12.png differ diff --git a/assets/bthome_123x52.png b/assets/bthome_123x52.png new file mode 100644 index 0000000..965d307 Binary files /dev/null and b/assets/bthome_123x52.png differ diff --git a/bthome.png b/bthome.png new file mode 100644 index 0000000..148d605 Binary files /dev/null and b/bthome.png differ diff --git a/screenshots/bthome_app.png b/screenshots/bthome_app.png new file mode 100644 index 0000000..759c06a Binary files /dev/null and b/screenshots/bthome_app.png differ diff --git a/screenshots/home_assistant_discovered.png b/screenshots/home_assistant_discovered.png new file mode 100644 index 0000000..06347b8 Binary files /dev/null and b/screenshots/home_assistant_discovered.png differ diff --git a/screenshots/home_assistant_paired.png b/screenshots/home_assistant_paired.png new file mode 100644 index 0000000..b414b69 Binary files /dev/null and b/screenshots/home_assistant_paired.png differ diff --git a/src/bt_home_controller.c b/src/bt_home_controller.c new file mode 100644 index 0000000..9f45c52 --- /dev/null +++ b/src/bt_home_controller.c @@ -0,0 +1,350 @@ +#include +#include +#include + +#include +#include +#include + +#include + +// BTHome UUID: 0xFCD2 (little-endian). +#define BTHOME_UUID_LSB 0xD2 +#define BTHOME_UUID_MSB 0xFC + +// BTHome AD types. +#define BTHOME_AD_TYPE_FLAGS 0x01 +#define BTHOME_AD_TYPE_COMPLETE_LOCAL_NAME 0x09 +#define BTHOME_AD_TYPE_SERVICE_DATA 0x16 + +// BTHome object IDs. +#define BTHOME_OBJ_PACKET_ID 0x00 +#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 + +// App values. +#define APP_LOG_TAG "BTHOME" + +#define APP_VIEW_MAIN 0 +#define APP_BEACON_TIMER 1000 + +// D-pad button indices — order determines BTHome button numbering (button, button_2 … button_5). +#define BTN_OK 0 +#define BTN_UP 1 +#define BTN_DOWN 2 +#define BTN_LEFT 3 +#define BTN_RIGHT 4 +#define BTN_COUNT 5 + +// Payload offsets. +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 { + 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; +} BtHomeApp; + +typedef struct { + InputKey active_key; + bool is_pressed; +} BtHomeModel; + +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; + } +} + +// 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 }, + }; + + 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); + } + } +} + +static void bthome_draw_callback(Canvas *canvas, void *ctx) { + BtHomeModel *model = ctx; + furi_assert(model); + + canvas_clear(canvas); + + canvas_set_bitmap_mode(canvas, true); + + // Logo moved to y=0 to leave 12px at the bottom for controls. + canvas_draw_icon(canvas, 3, 0, &I_bthome_123x52); + + canvas_set_font(canvas, FontSecondary); + + canvas_draw_str_aligned(canvas, 3, 54, AlignLeft, AlignTop, "D-pad to send"); + + bthome_draw_dpad(canvas, 107, 53, model->is_pressed, model->active_key); +} + +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)) + { + int btn = bthome_key_to_btn(event->key); + + 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; + } + + default: + break; + } + + return false; +} + +static void bthome_beacon_timer_callback(void *ctx) { + BtHomeApp *app = ctx; + + 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_EVENT_NONE; + } +} + +static void bthome_init_beacon_payload(uint8_t *payload, size_t *payload_len) { + size_t i = 0; + + // Flags data. + payload[i++] = 2; // Flags length. + payload[i++] = BTHOME_AD_TYPE_FLAGS; + payload[i++] = 0x06; // LE General Discoverable Mode + BR/EDR Not Supported. + + // Service data. + // Length = 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. + + // 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++) { + payload[i++] = BTHOME_OBJ_BUTTON; + payload_button_off[btn] = i; + payload[i++] = BTHOME_BUTTON_EVENT_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) { + name_len = EXTRA_BEACON_MAX_DATA_SIZE - (i + 2); + + FURI_LOG_E(APP_LOG_TAG, "name len %zu", name_len); + } + + payload[i++] = 1 + name_len; // Name object length. + 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"); + + return NULL; + } + + app->packet_id = 0; + + bthome_init_beacon_payload(app->beacon_payload, &app->beacon_payload_len); + + app->beacon_timer = furi_timer_alloc(bthome_beacon_timer_callback, + FuriTimerTypeOnce, + app); + + 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, + }; + + 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"); + 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"); + + free(app); + return NULL; + } + + 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); + + view_dispatcher_add_view(app->view_dispatcher, APP_VIEW_MAIN, app->main_view); + + return app; +} + +static void bthome_app_free(BtHomeApp *app) { + furi_hal_bt_extra_beacon_stop(); + + furi_timer_stop(app->beacon_timer); + furi_timer_free(app->beacon_timer); + + view_dispatcher_remove_view(app->view_dispatcher, APP_VIEW_MAIN); + view_free_model(app->main_view); + view_free(app->main_view); + view_dispatcher_free(app->view_dispatcher); + + free(app); +} + +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); + + 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; +}