Working build of App - can use dpad not just centre button
2
CHANGELOG.md
Normal file
@@ -0,0 +1,2 @@
|
||||
0.1
|
||||
Initial release
|
||||
30
README.md
Normal file
@@ -0,0 +1,30 @@
|
||||

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

|
||||
|
||||
The device's page will then show the current battery status and its events:
|
||||
|
||||

|
||||
14
application.fam
Normal file
@@ -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",
|
||||
)
|
||||
0
assets/.gitkeep
Normal file
BIN
assets/Ok_btn_9x9.png
Normal file
|
After Width: | Height: | Size: 92 B |
BIN
assets/Ok_btn_pressed_13x12.png
Normal file
|
After Width: | Height: | Size: 95 B |
BIN
assets/bthome_123x52.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
bthome.png
Normal file
|
After Width: | Height: | Size: 165 B |
BIN
screenshots/bthome_app.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
screenshots/home_assistant_discovered.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
screenshots/home_assistant_paired.png
Normal file
|
After Width: | Height: | Size: 269 KiB |
350
src/bt_home_controller.c
Normal file
@@ -0,0 +1,350 @@
|
||||
#include <furi.h>
|
||||
#include <furi_hal_bt.h>
|
||||
#include <furi_hal_power.h>
|
||||
|
||||
#include <gui/gui.h>
|
||||
#include <gui/view_dispatcher.h>
|
||||
#include <gui/view.h>
|
||||
|
||||
#include <bt_home_controller_icons.h>
|
||||
|
||||
// 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;
|
||||
}
|
||||