/* * bt_hid.c * * BLE HID host for SC-F001. * * Architecture * ------------ * bt_hid_scan_task (priority 4, 6 KB) * - On boot: checks NVS for a saved BDA. If found, attempts * esp_hidh_dev_open() directly and waits BT_HID_BOND_TIMEOUT_MS. * If that times out (device not nearby) falls through to scan. * - While disconnected: runs a BLE scan for BT_HID_SCAN_DURATION_S * seconds, picks the HID device with best RSSI, opens it. * - While connected: sleeps 500 ms between checks. * - Every BT_HID_REPEAT_MS the held-button state is sampled and * pulse_override() is called if a mapped key is down. * * hidh_callback (runs in esp_hidh event task) * - OPEN: saves BDA to NVS, marks connected. * - CLOSE: clears connected flag, clears held key. * - INPUT: decodes usage code, sets s_held_key. * * ble_gap_event_handler (runs in BT stack task) * - Collects HID-UUID scan results. * - Signals scan_semaphore when scan params set / scan complete. * - Handles pairing/auth confirmations. */ #include #include #include #include #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/semphr.h" #include "esp_system.h" #include "esp_log.h" #include "esp_event.h" #include "nvs_flash.h" #include "nvs.h" #include "esp_bt.h" #include "esp_bt_device.h" #include "esp_bt_main.h" #include "esp_bt_defs.h" #include "esp_gap_ble_api.h" #include "esp_gattc_api.h" #include "esp_gatt_defs.h" #include "esp_hidh.h" #include "esp_hid_common.h" #include "esp_timer.h" #include "bt_hid.h" #include "control_fsm.h" // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- #define TAG "BT_HID" #define BT_HID_SCAN_DURATION_S 3 #define BT_HID_MIN_RSSI (-70) /* dBm — ignore devices weaker than this */ #define BT_HID_RECONNECT_MS 2000 #define BT_HID_BOND_TIMEOUT_MS 5000 /* wait for saved device before scanning */ #define BT_HID_CONNECT_WAIT_MS 5000 /* wait after open() before next loop */ #define BT_HID_NVS_NAMESPACE "bt_hid" #define BT_HID_NVS_BDA_KEY "bda" // --------------------------------------------------------------------------- // HID usage codes -> machine actions // --------------------------------------------------------------------------- /* * Usage codes sent by generic BLE media remotes (HID Consumer Control page). * Only the four we care about are mapped; everything else is ignored. */ #define USAGE_VOL_UP 0x00E9u /* -> jack up */ #define USAGE_VOL_DOWN 0x00EAu /* -> jack down */ #define USAGE_PREV 0x00B6u /* -> reverse */ #define USAGE_NEXT 0x00B5u /* -> forward */ #define USAGE_NONE 0x0000u // --------------------------------------------------------------------------- // Shared remote state // --------------------------------------------------------------------------- typedef enum { REMOTE_DISCONNECTED = 0, REMOTE_CONNECTED, } remote_conn_state_t; typedef struct { remote_conn_state_t conn_state; uint16_t held_key; /* current usage code, 0 = nothing held */ esp_bd_addr_t bda; /* address of the connected device */ } remote_state_t; static remote_state_t s_remote = { .conn_state = REMOTE_DISCONNECTED }; static portMUX_TYPE s_mux = portMUX_INITIALIZER_UNLOCKED; /* Semaphore signalled by GAP callback to unblock the scan task. */ static SemaphoreHandle_t s_scan_sem = NULL; /* Set once we have a saved BDA from NVS (direct reconnect path). */ static bool s_has_saved_bda = false; static esp_bd_addr_t s_saved_bda = {0}; static esp_ble_addr_type_t s_saved_addr_type = BLE_ADDR_TYPE_RANDOM; /* Address type used for the most recent esp_hidh_dev_open() call. * Set immediately before open so the OPEN callback can persist it. */ static esp_ble_addr_type_t s_connect_addr_type = BLE_ADDR_TYPE_RANDOM; // --------------------------------------------------------------------------- // Scan-result list (heap-allocated, freed after each scan round) // --------------------------------------------------------------------------- typedef struct scan_result_s { esp_bd_addr_t bda; esp_ble_addr_type_t addr_type; int rssi; char name[64]; struct scan_result_s *next; } scan_result_t; static scan_result_t *s_scan_results = NULL; static size_t s_num_scan_results = 0; static TaskHandle_t s_scan_task_handle = NULL; static scan_result_t *find_scan_result(const esp_bd_addr_t bda) { scan_result_t *r = s_scan_results; while (r) { if (memcmp(bda, r->bda, sizeof(esp_bd_addr_t)) == 0) return r; r = r->next; } return NULL; } static void add_scan_result(const esp_bd_addr_t bda, esp_ble_addr_type_t addr_type, const char *name, int rssi) { if (find_scan_result(bda)) return; /* deduplicate */ scan_result_t *r = (scan_result_t *)calloc(1, sizeof(scan_result_t)); if (!r) return; memcpy(r->bda, bda, sizeof(esp_bd_addr_t)); r->addr_type = addr_type; r->rssi = rssi; if (name) strncpy(r->name, name, sizeof(r->name) - 1); r->next = s_scan_results; s_scan_results = r; s_num_scan_results++; } static void free_scan_results(void) { scan_result_t *r; while (s_scan_results) { r = s_scan_results; s_scan_results = s_scan_results->next; free(r); } s_num_scan_results = 0; } // --------------------------------------------------------------------------- // NVS helpers — store / load the last-connected BDA // --------------------------------------------------------------------------- static void nvs_save_bda(const esp_bd_addr_t bda, esp_ble_addr_type_t addr_type) { nvs_handle_t h; if (nvs_open(BT_HID_NVS_NAMESPACE, NVS_READWRITE, &h) != ESP_OK) return; nvs_set_blob(h, BT_HID_NVS_BDA_KEY, bda, sizeof(esp_bd_addr_t)); nvs_set_u8(h, "addr_type", (uint8_t)addr_type); nvs_commit(h); nvs_close(h); ESP_LOGI(TAG, "Saved BDA %02x:%02x:%02x:%02x:%02x:%02x (addr_type=%d) to NVS", bda[0], bda[1], bda[2], bda[3], bda[4], bda[5], (int)addr_type); } static bool nvs_load_bda(esp_bd_addr_t out_bda, esp_ble_addr_type_t *out_addr_type) { nvs_handle_t h; if (nvs_open(BT_HID_NVS_NAMESPACE, NVS_READONLY, &h) != ESP_OK) return false; size_t len = sizeof(esp_bd_addr_t); bool ok = (nvs_get_blob(h, BT_HID_NVS_BDA_KEY, out_bda, &len) == ESP_OK); if (ok) { uint8_t at = (uint8_t)BLE_ADDR_TYPE_RANDOM; nvs_get_u8(h, "addr_type", &at); /* missing key → stays RANDOM, safe default */ *out_addr_type = (esp_ble_addr_type_t)at; } nvs_close(h); return ok; } // --------------------------------------------------------------------------- // Map a HID usage code to an FSM override command // Returns false if the usage is not mapped. // --------------------------------------------------------------------------- static bool usage_to_override(uint16_t usage, fsm_override_t *out) { switch (usage) { case USAGE_VOL_UP: *out = FSM_OVERRIDE_JACK_UP; return true; case USAGE_VOL_DOWN: *out = FSM_OVERRIDE_JACK_DOWN; return true; case USAGE_PREV: *out = FSM_OVERRIDE_DRIVE_REV; return true; case USAGE_NEXT: *out = FSM_OVERRIDE_DRIVE_FWD; return true; default: return false; } } // --------------------------------------------------------------------------- // HID Host event callback // Runs in the esp_hidh internal event task — keep it short, no blocking. // --------------------------------------------------------------------------- static void hidh_callback(void *handler_args, esp_event_base_t base, int32_t id, void *event_data) { esp_hidh_event_t event = (esp_hidh_event_t)id; esp_hidh_event_data_t *p = (esp_hidh_event_data_t *)event_data; switch (event) { case ESP_HIDH_OPEN_EVENT: { if (p->open.status == ESP_OK) { const uint8_t *bda = esp_hidh_dev_bda_get(p->open.dev); ESP_LOGI(TAG, "CONNECTED: %02x:%02x:%02x:%02x:%02x:%02x '%s'", bda[0], bda[1], bda[2], bda[3], bda[4], bda[5], esp_hidh_dev_name_get(p->open.dev)); esp_hidh_dev_dump(p->open.dev, stdout); taskENTER_CRITICAL(&s_mux); s_remote.conn_state = REMOTE_CONNECTED; s_remote.held_key = USAGE_NONE; memcpy(s_remote.bda, bda, sizeof(esp_bd_addr_t)); taskEXIT_CRITICAL(&s_mux); nvs_save_bda(bda, s_connect_addr_type); } else { ESP_LOGE(TAG, "OPEN failed, status=%d", p->open.status); /* Free the failed device handle — not doing so leaks a BT * resource and can block future connection attempts. */ esp_hidh_dev_free(p->open.dev); } break; } case ESP_HIDH_CLOSE_EVENT: { taskENTER_CRITICAL(&s_mux); s_remote.conn_state = REMOTE_DISCONNECTED; s_remote.held_key = USAGE_NONE; taskEXIT_CRITICAL(&s_mux); ESP_LOGW(TAG, "DISCONNECTED — will rescan"); break; } case ESP_HIDH_BATTERY_EVENT: ESP_LOGI(TAG, "Battery: %d%%", p->battery.level); break; case ESP_HIDH_INPUT_EVENT: { /* * Decode usage code from the first two bytes of the input report. * BLE consumer-control remotes typically send a 16-bit usage on * report ID 3 (or similar), zero on key-up. */ uint16_t usage = USAGE_NONE; if (p->input.length >= 2) { usage = (uint16_t)(p->input.data[0] | ((uint16_t)p->input.data[1] << 8)); } else if (p->input.length == 1) { usage = p->input.data[0]; } taskENTER_CRITICAL(&s_mux); s_remote.held_key = usage; taskEXIT_CRITICAL(&s_mux); /* Log mapped presses only (not key-up noise). */ if (usage != USAGE_NONE) { fsm_override_t dummy; if (usage_to_override(usage, &dummy)) { ESP_LOGI(TAG, "KEY DOWN 0x%04X", usage); } else { ESP_LOGD(TAG, "KEY 0x%04X (unmapped)", usage); } } else { ESP_LOGD(TAG, "KEY UP"); } break; } default: break; } } // --------------------------------------------------------------------------- // BLE GAP event handler // --------------------------------------------------------------------------- static void ble_gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { switch (event) { /* Scan parameters accepted — safe to start scanning now. */ case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: xSemaphoreGive(s_scan_sem); break; case ESP_GAP_BLE_SCAN_RESULT_EVT: { switch (param->scan_rst.search_evt) { case ESP_GAP_SEARCH_INQ_RES_EVT: { uint8_t *adv = param->scan_rst.ble_adv; /* Only keep devices that advertise the HID service UUID (0x1812). */ uint8_t uuid_len = 0; uint8_t *uuid_d = esp_ble_resolve_adv_data( adv, ESP_BLE_AD_TYPE_16SRV_CMPL, &uuid_len); uint16_t uuid = 0; if (uuid_d && uuid_len >= 2) { uuid = (uint16_t)(uuid_d[0] | ((uint16_t)uuid_d[1] << 8)); } if (uuid != ESP_GATT_UUID_HID_SVC) break; /* Parse device name (prefer complete, fall back to short). */ uint8_t name_len = 0; uint8_t *name_d = esp_ble_resolve_adv_data( adv, ESP_BLE_AD_TYPE_NAME_CMPL, &name_len); if (!name_d || !name_len) { name_d = esp_ble_resolve_adv_data( adv, ESP_BLE_AD_TYPE_NAME_SHORT, &name_len); } char name[64] = {0}; if (name_d && name_len > 0) { size_t copy = name_len < (sizeof(name) - 1) ? name_len : (sizeof(name) - 1); memcpy(name, name_d, copy); } if (param->scan_rst.rssi < BT_HID_MIN_RSSI) { ESP_LOGD(TAG, "SCAN %02x:%02x:%02x:%02x:%02x:%02x RSSI:%d '%s' (too weak, skipping)", param->scan_rst.bda[0], param->scan_rst.bda[1], param->scan_rst.bda[2], param->scan_rst.bda[3], param->scan_rst.bda[4], param->scan_rst.bda[5], param->scan_rst.rssi, name); break; } ESP_LOGI(TAG, "SCAN %02x:%02x:%02x:%02x:%02x:%02x RSSI:%d '%s' <<< HID", param->scan_rst.bda[0], param->scan_rst.bda[1], param->scan_rst.bda[2], param->scan_rst.bda[3], param->scan_rst.bda[4], param->scan_rst.bda[5], param->scan_rst.rssi, name); add_scan_result(param->scan_rst.bda, param->scan_rst.ble_addr_type, name, param->scan_rst.rssi); break; } /* Scan complete — unblock the scan task. */ case ESP_GAP_SEARCH_INQ_CMPL_EVT: xSemaphoreGive(s_scan_sem); break; default: break; } break; } /* Pairing / security events — auto-accept so bonding completes. */ case ESP_GAP_BLE_AUTH_CMPL_EVT: if (!param->ble_security.auth_cmpl.success) { ESP_LOGE(TAG, "AUTH ERROR 0x%x", param->ble_security.auth_cmpl.fail_reason); } else { ESP_LOGI(TAG, "AUTH SUCCESS"); } break; case ESP_GAP_BLE_SEC_REQ_EVT: esp_ble_gap_security_rsp(param->ble_security.ble_req.bd_addr, true); break; case ESP_GAP_BLE_NC_REQ_EVT: esp_ble_confirm_reply(param->ble_security.key_notif.bd_addr, true); break; default: break; } } // --------------------------------------------------------------------------- // Scan + reconnect task // --------------------------------------------------------------------------- static void bt_hid_scan_task(void *pvParameters) { esp_ble_scan_params_t scan_params = { .scan_type = BLE_SCAN_TYPE_ACTIVE, .own_addr_type = BLE_ADDR_TYPE_PUBLIC, .scan_filter_policy = BLE_SCAN_FILTER_ALLOW_ALL, .scan_interval = 0x50, .scan_window = 0x30, .scan_duplicate = BLE_SCAN_DUPLICATE_ENABLE, }; TickType_t repeat_tick = xTaskGetTickCount(); /* ------------------------------------------------------------------ * On the very first iteration, try to reconnect to the saved BDA. * ------------------------------------------------------------------ */ if (s_has_saved_bda) { ESP_LOGI(TAG, "Trying saved device %02x:%02x:%02x:%02x:%02x:%02x ...", s_saved_bda[0], s_saved_bda[1], s_saved_bda[2], s_saved_bda[3], s_saved_bda[4], s_saved_bda[5]); /* * We don't know the address type of the saved device at this point. * BLE_ADDR_TYPE_RANDOM is the most common for consumer remotes; if it * fails the outer scan loop will find it correctly. */ s_connect_addr_type = s_saved_addr_type; esp_hidh_dev_open(s_saved_bda, ESP_HID_TRANSPORT_BLE, s_saved_addr_type); vTaskDelay(pdMS_TO_TICKS(BT_HID_BOND_TIMEOUT_MS)); /* If the open succeeded the state will be CONNECTED — skip to main loop. */ } /* ------------------------------------------------------------------ * Main loop: scan → connect → wait → repeat if disconnected. * ------------------------------------------------------------------ */ while (1) { /* ---- While connected: check held key and call pulse_override() ---- */ taskENTER_CRITICAL(&s_mux); remote_conn_state_t conn = s_remote.conn_state; uint16_t held_key = s_remote.held_key; taskEXIT_CRITICAL(&s_mux); if (conn == REMOTE_CONNECTED) { /* Fire pulse_override every BT_HID_REPEAT_MS while a key is held. */ if (xTaskGetTickCount() - repeat_tick >= pdMS_TO_TICKS(BT_HID_REPEAT_MS)) { repeat_tick = xTaskGetTickCount(); fsm_override_t override_cmd; if (held_key != USAGE_NONE && usage_to_override(held_key, &override_cmd)) { pulse_override(override_cmd); } } vTaskDelay(pdMS_TO_TICKS(10)); /* Short sleep to yield; repeat timer governs rate. */ continue; } /* ---- Not connected — run a scan ---- */ ESP_LOGI(TAG, "Scanning for HID devices (%ds)...", BT_HID_SCAN_DURATION_S); free_scan_results(); esp_ble_gap_set_scan_params(&scan_params); xSemaphoreTake(s_scan_sem, portMAX_DELAY); /* wait: SCAN_PARAM_SET_COMPLETE */ esp_ble_gap_start_scanning(BT_HID_SCAN_DURATION_S); xSemaphoreTake(s_scan_sem, portMAX_DELAY); /* wait: SCAN_INQ_CMPL */ ESP_LOGI(TAG, "Found %u HID device(s)", (unsigned)s_num_scan_results); if (s_num_scan_results > 0) { /* Pick the device with the strongest signal. */ scan_result_t *r = s_scan_results; scan_result_t *best = r; while (r) { if (r->rssi > best->rssi) best = r; r = r->next; } ESP_LOGI(TAG, "Connecting to %02x:%02x:%02x:%02x:%02x:%02x '%s' (RSSI:%d)", best->bda[0], best->bda[1], best->bda[2], best->bda[3], best->bda[4], best->bda[5], best->name, best->rssi); s_connect_addr_type = best->addr_type; esp_hidh_dev_open(best->bda, ESP_HID_TRANSPORT_BLE, best->addr_type); free_scan_results(); /* Give the connection time to establish before looping. */ vTaskDelay(pdMS_TO_TICKS(BT_HID_CONNECT_WAIT_MS)); } else { ESP_LOGI(TAG, "No HID devices found, retrying in %dms...", BT_HID_RECONNECT_MS); vTaskDelay(pdMS_TO_TICKS(BT_HID_RECONNECT_MS)); } repeat_tick = xTaskGetTickCount(); /* reset repeat timer after reconnect */ } } // --------------------------------------------------------------------------- // Public init // --------------------------------------------------------------------------- esp_err_t bt_hid_init(void) { esp_err_t ret; /* Release classic BT memory — we only use BLE. */ ESP_ERROR_CHECK(esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT)); esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT(); ret = esp_bt_controller_init(&bt_cfg); if (ret != ESP_OK) { ESP_LOGE(TAG, "bt_controller_init failed: %s", esp_err_to_name(ret)); return ret; } ret = esp_bt_controller_enable(ESP_BT_MODE_BLE); if (ret != ESP_OK) { ESP_LOGE(TAG, "bt_controller_enable failed: %s", esp_err_to_name(ret)); return ret; } esp_bluedroid_config_t bd_cfg = BT_BLUEDROID_INIT_CONFIG_DEFAULT(); ret = esp_bluedroid_init_with_cfg(&bd_cfg); if (ret != ESP_OK) { ESP_LOGE(TAG, "bluedroid_init failed: %s", esp_err_to_name(ret)); return ret; } ret = esp_bluedroid_enable(); if (ret != ESP_OK) { ESP_LOGE(TAG, "bluedroid_enable failed: %s", esp_err_to_name(ret)); return ret; } /* esp_hidh posts events to the default event loop — create it if the * webserver hasn't done so yet. ESP_ERR_INVALID_STATE means it already * exists, which is fine. */ ret = esp_event_loop_create_default(); if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) { ESP_LOGE(TAG, "event_loop_create failed: %s", esp_err_to_name(ret)); return ret; } ESP_ERROR_CHECK(esp_ble_gap_register_callback(ble_gap_event_handler)); ESP_ERROR_CHECK(esp_ble_gattc_register_callback(esp_hidh_gattc_event_handler)); esp_hidh_config_t hidh_cfg = { .callback = hidh_callback, .event_stack_size = 4096, .callback_arg = NULL, }; ret = esp_hidh_init(&hidh_cfg); if (ret != ESP_OK) { ESP_LOGE(TAG, "hidh_init failed: %s", esp_err_to_name(ret)); return ret; } /* Try to load a previously bonded device address from NVS. */ s_has_saved_bda = nvs_load_bda(s_saved_bda, &s_saved_addr_type); if (s_has_saved_bda) { ESP_LOGI(TAG, "Found saved BDA in NVS — will try direct reconnect first"); } s_scan_sem = xSemaphoreCreateBinary(); if (!s_scan_sem) { ESP_LOGE(TAG, "Failed to create scan semaphore"); return ESP_ERR_NO_MEM; } /* * Priority 4: above webserver (typically 5–6 on core 0) is fine; below * the FSM (10) and RF task (5) so control always wins CPU. */ xTaskCreate(bt_hid_scan_task, "bt_hid_scan", 6 * 1024, NULL, 4, &s_scan_task_handle); ESP_LOGI(TAG, "BLE HID host initialised"); return ESP_OK; } void bt_hid_stop(void) { if (s_scan_task_handle != NULL) { vTaskSuspend(s_scan_task_handle); ESP_LOGI(TAG, "BT HID scan task suspended"); } } void bt_hid_resume(void) { if (s_scan_task_handle != NULL) { vTaskResume(s_scan_task_handle); ESP_LOGI(TAG, "BT HID scan task resumed"); } }