/* * bt_classic.c -- Classic Bluetooth GAP/SSP + minimal SPP peripheral. * * Provides pairing support across all four SSP association models * (Just Works, Numeric Comparison, Passkey Display, Passkey Entry) * plus legacy PIN for pre-2.1 devices. SPP is registered so remote * devices have a service to connect to. */ #include "bt_classic.h" #include "protocol.h" #include "uart_handler.h" #include "event_reporter.h" #include "esp_bt.h" #include "esp_bt_main.h" #include "esp_gap_bt_api.h" #include "esp_bt_device.h" #include "esp_spp_api.h" #include "esp_log.h" #include #include #include static const char *TAG = "classic"; #define SPP_SERVER_NAME "mcbt_spp" #define SPP_TAG "spp" /* ------------------------------------------------------------------ */ /* Module state */ /* ------------------------------------------------------------------ */ typedef enum { PAIR_TYPE_NONE = 0, PAIR_TYPE_NUMERIC_COMPARISON, PAIR_TYPE_PASSKEY_DISPLAY, PAIR_TYPE_PASSKEY_ENTRY, PAIR_TYPE_PIN_REQUEST, } pair_type_t; static struct { bool enabled; bool discoverable; esp_bt_io_cap_t io_cap; char pin_code[17]; bool ssp_enabled; /* Pending pairing state */ char pending_pair_address[18]; char pending_pair_cmd_id[32]; bool pair_pending; pair_type_t pair_type; /* SPP handle for the listening server */ uint32_t spp_handle; } classic_state = { .io_cap = ESP_BT_IO_CAP_IO, /* display+yesno (numeric comparison) */ .ssp_enabled = true, }; /* ------------------------------------------------------------------ */ /* Address helpers */ /* ------------------------------------------------------------------ */ static bool parse_bd_addr(const char *str, esp_bd_addr_t addr) { if (!str || strlen(str) < 17) { return false; } unsigned int b[6]; int rc = sscanf(str, "%02x:%02x:%02x:%02x:%02x:%02x", &b[0], &b[1], &b[2], &b[3], &b[4], &b[5]); if (rc != 6) { /* try uppercase */ rc = sscanf(str, "%02X:%02X:%02X:%02X:%02X:%02X", &b[0], &b[1], &b[2], &b[3], &b[4], &b[5]); } if (rc != 6) { return false; } for (int i = 0; i < 6; i++) { addr[i] = (uint8_t)b[i]; } return true; } static void bd_addr_to_str(const esp_bd_addr_t addr, char *str) { sprintf(str, "%02X:%02X:%02X:%02X:%02X:%02X", addr[0], addr[1], addr[2], addr[3], addr[4], addr[5]); } /* ------------------------------------------------------------------ */ /* GAP callback */ /* ------------------------------------------------------------------ */ static void gap_cb(esp_bt_gap_cb_event_t event, esp_bt_gap_cb_param_t *param) { char addr_str[18]; switch (event) { /* --- Pairing complete --- */ case ESP_BT_GAP_AUTH_CMPL_EVT: { bd_addr_to_str(param->auth_cmpl.bda, addr_str); bool ok = (param->auth_cmpl.stat == ESP_BT_STATUS_SUCCESS); ESP_LOGI(TAG, "auth_cmpl: %s %s (lk_type=%d)", addr_str, ok ? "success" : "FAIL", param->auth_cmpl.lk_type); event_report_pair_complete(addr_str, ok); /* If a pair_respond command is waiting, resolve it now. */ if (classic_state.pair_pending && strcmp(classic_state.pending_pair_address, addr_str) == 0) { cJSON *d = cJSON_CreateObject(); cJSON_AddStringToObject(d, "address", addr_str); cJSON_AddBoolToObject(d, "success", ok); uart_send_response(classic_state.pending_pair_cmd_id, STATUS_OK, d); classic_state.pair_pending = false; } break; } /* --- Numeric Comparison (SSP model 2) --- */ case ESP_BT_GAP_CFM_REQ_EVT: { bd_addr_to_str(param->cfm_req.bda, addr_str); uint32_t passkey = param->cfm_req.num_val; ESP_LOGI(TAG, "cfm_req: %s passkey=%06" PRIu32, addr_str, passkey); event_report_pair_request(addr_str, "numeric_comparison", (int)passkey); /* Stash address so pair_respond can reply */ strncpy(classic_state.pending_pair_address, addr_str, sizeof(classic_state.pending_pair_address) - 1); classic_state.pair_type = PAIR_TYPE_NUMERIC_COMPARISON; break; } /* --- Passkey Display (SSP model 3 -- we show, remote enters) --- */ case ESP_BT_GAP_KEY_NOTIF_EVT: { bd_addr_to_str(param->key_notif.bda, addr_str); uint32_t passkey = param->key_notif.passkey; ESP_LOGI(TAG, "key_notif: %s passkey=%06" PRIu32, addr_str, passkey); event_report_pair_request(addr_str, "passkey_display", (int)passkey); /* Nothing to wait for -- the remote side enters the key. */ break; } /* --- Passkey Entry (SSP model 4 -- we must enter) --- */ case ESP_BT_GAP_KEY_REQ_EVT: { bd_addr_to_str(param->key_req.bda, addr_str); ESP_LOGI(TAG, "key_req: %s (waiting for passkey)", addr_str); event_report_pair_request(addr_str, "passkey_entry", 0); strncpy(classic_state.pending_pair_address, addr_str, sizeof(classic_state.pending_pair_address) - 1); classic_state.pair_type = PAIR_TYPE_PASSKEY_ENTRY; break; } /* --- Legacy PIN request (pre-2.1 devices) --- */ case ESP_BT_GAP_PIN_REQ_EVT: { bd_addr_to_str(param->pin_req.bda, addr_str); bool min_16 = param->pin_req.min_16_digit; ESP_LOGI(TAG, "pin_req: %s min_16=%d", addr_str, min_16); /* Auto-reply if a PIN is pre-configured */ if (classic_state.pin_code[0] != '\0') { uint8_t pin_len = (uint8_t)strlen(classic_state.pin_code); esp_bt_pin_code_t pin; memset(pin, 0, sizeof(pin)); memcpy(pin, classic_state.pin_code, pin_len); esp_bt_gap_pin_reply(param->pin_req.bda, true, pin_len, pin); ESP_LOGI(TAG, "pin_req: auto-replied with stored PIN"); /* Still report the event so the host sees what happened */ event_report_pair_request(addr_str, "pin_request", 0); break; } /* No stored PIN -- ask the host */ event_report_pair_request(addr_str, "pin_request", 0); strncpy(classic_state.pending_pair_address, addr_str, sizeof(classic_state.pending_pair_address) - 1); classic_state.pair_type = PAIR_TYPE_PIN_REQUEST; break; } /* --- Discovery (we're a peripheral, not scanning -- just log) --- */ case ESP_BT_GAP_DISC_STATE_CHANGED_EVT: ESP_LOGI(TAG, "discovery state changed: %d", param->disc_st_chg.state); break; case ESP_BT_GAP_DISC_RES_EVT: /* We don't initiate discovery, ignore. */ break; default: ESP_LOGD(TAG, "gap event %d (unhandled)", event); break; } } /* ------------------------------------------------------------------ */ /* SPP callback (minimal -- just report connects/disconnects) */ /* ------------------------------------------------------------------ */ static void spp_cb(esp_spp_cb_event_t event, esp_spp_cb_param_t *param) { char addr_str[18]; switch (event) { case ESP_SPP_INIT_EVT: if (param->init.status == ESP_SPP_SUCCESS) { esp_spp_start_srv(ESP_SPP_SEC_AUTHENTICATE, ESP_SPP_ROLE_SLAVE, 0, SPP_SERVER_NAME); ESP_LOGI(SPP_TAG, "SPP initialised, starting server"); } else { ESP_LOGE(SPP_TAG, "SPP init failed: %d", param->init.status); } break; case ESP_SPP_SRV_OPEN_EVT: bd_addr_to_str(param->srv_open.rem_bda, addr_str); classic_state.spp_handle = param->srv_open.handle; ESP_LOGI(SPP_TAG, "SPP client connected: %s (handle=%" PRIu32 ")", addr_str, param->srv_open.handle); event_report_connect(addr_str, "classic"); break; case ESP_SPP_CLOSE_EVT: ESP_LOGI(SPP_TAG, "SPP disconnected (handle=%" PRIu32 ")", param->close.handle); /* We don't have the remote address in CLOSE_EVT on all IDF versions, * so report with handle info. */ { cJSON *d = cJSON_CreateObject(); cJSON_AddNumberToObject(d, "handle", (double)param->close.handle); cJSON_AddStringToObject(d, "transport", "classic"); event_report(EVT_DISCONNECT, d); } if (classic_state.spp_handle == param->close.handle) { classic_state.spp_handle = 0; } break; case ESP_SPP_START_EVT: if (param->start.status == ESP_SPP_SUCCESS) { ESP_LOGI(SPP_TAG, "SPP server started (scn=%d)", param->start.scn); } else { ESP_LOGE(SPP_TAG, "SPP server start failed: %d", param->start.status); } break; case ESP_SPP_DATA_IND_EVT: /* Data received over SPP -- log but don't process for now. */ ESP_LOGI(SPP_TAG, "SPP data: %d bytes on handle %" PRIu32, param->data_ind.len, param->data_ind.handle); break; default: ESP_LOGD(SPP_TAG, "spp event %d", event); break; } } /* ------------------------------------------------------------------ */ /* IO capability string mapping */ /* ------------------------------------------------------------------ */ static bool str_to_io_cap(const char *s, esp_bt_io_cap_t *out) { if (!s || !out) return false; if (strcmp(s, IO_CAP_DISPLAY_ONLY) == 0) { *out = ESP_BT_IO_CAP_OUT; } else if (strcmp(s, IO_CAP_DISPLAY_YESNO) == 0) { *out = ESP_BT_IO_CAP_IO; } else if (strcmp(s, IO_CAP_KEYBOARD_ONLY) == 0) { *out = ESP_BT_IO_CAP_IN; } else if (strcmp(s, IO_CAP_NO_IO) == 0) { *out = ESP_BT_IO_CAP_NONE; } else if (strcmp(s, IO_CAP_KEYBOARD_DISPLAY) == 0) { *out = ESP_BT_IO_CAP_IO; /* closest match in Bluedroid */ } else { return false; } return true; } /* ------------------------------------------------------------------ */ /* Command handlers */ /* ------------------------------------------------------------------ */ void cmd_classic_enable(const char *id, cJSON *params) { (void)params; if (classic_state.enabled) { uart_send_response(id, STATUS_OK, cJSON_CreateString("already enabled")); return; } /* Release BLE-only memory when running dual-mode. * esp_bt_controller_mem_release(ESP_BT_MODE_BLE) would be called * if we only wanted Classic; here we keep both available. * If the controller is already initialised (e.g. BLE brought it up), * skip controller init. */ esp_bt_controller_status_t ctrl_status = esp_bt_controller_get_status(); if (ctrl_status == ESP_BT_CONTROLLER_STATUS_IDLE) { esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT(); esp_err_t err = esp_bt_controller_init(&bt_cfg); if (err != ESP_OK) { ESP_LOGE(TAG, "controller init failed: %s", esp_err_to_name(err)); uart_send_response(id, STATUS_ERROR, cJSON_CreateString(esp_err_to_name(err))); return; } } if (esp_bt_controller_get_status() == ESP_BT_CONTROLLER_STATUS_INITED) { esp_err_t err = esp_bt_controller_enable(ESP_BT_MODE_BTDM); if (err != ESP_OK) { ESP_LOGE(TAG, "controller enable failed: %s", esp_err_to_name(err)); uart_send_response(id, STATUS_ERROR, cJSON_CreateString(esp_err_to_name(err))); return; } } /* Bluedroid init + enable */ esp_bluedroid_status_t bd_status = esp_bluedroid_get_status(); if (bd_status == ESP_BLUEDROID_STATUS_UNINITIALIZED) { esp_err_t err = esp_bluedroid_init(); if (err != ESP_OK) { ESP_LOGE(TAG, "bluedroid init failed: %s", esp_err_to_name(err)); uart_send_response(id, STATUS_ERROR, cJSON_CreateString(esp_err_to_name(err))); return; } } if (esp_bluedroid_get_status() == ESP_BLUEDROID_STATUS_INITIALIZED) { esp_err_t err = esp_bluedroid_enable(); if (err != ESP_OK) { ESP_LOGE(TAG, "bluedroid enable failed: %s", esp_err_to_name(err)); uart_send_response(id, STATUS_ERROR, cJSON_CreateString(esp_err_to_name(err))); return; } } /* Register GAP callback */ esp_bt_gap_register_callback(gap_cb); /* Configure SSP IO capability */ esp_bt_sp_param_t iocap = classic_state.io_cap; esp_bt_gap_set_security_param(ESP_BT_SP_IOCAP_MODE, &iocap, sizeof(iocap)); ESP_LOGI(TAG, "SSP io_cap set to %d", classic_state.io_cap); /* Initialise and register SPP */ esp_spp_register_callback(spp_cb); esp_spp_cfg_t spp_cfg = { .mode = ESP_SPP_MODE_CB, .enable_l2cap_ertm = false, }; esp_err_t err = esp_spp_enhanced_init(&spp_cfg); if (err != ESP_OK) { ESP_LOGW(TAG, "spp_enhanced_init: %s (trying legacy init)", esp_err_to_name(err)); /* Fall back for older IDF builds that lack enhanced init */ esp_spp_init(ESP_SPP_MODE_CB); } classic_state.enabled = true; ESP_LOGI(TAG, "Classic BT enabled"); uart_send_response(id, STATUS_OK, NULL); } void cmd_classic_disable(const char *id, cJSON *params) { (void)params; if (!classic_state.enabled) { uart_send_response(id, STATUS_OK, cJSON_CreateString("already disabled")); return; } esp_spp_deinit(); esp_bluedroid_disable(); classic_state.enabled = false; classic_state.discoverable = false; classic_state.pair_pending = false; classic_state.spp_handle = 0; ESP_LOGI(TAG, "Classic BT disabled"); uart_send_response(id, STATUS_OK, NULL); } void cmd_classic_set_discoverable(const char *id, cJSON *params) { if (!classic_state.enabled) { uart_send_response(id, STATUS_ERROR, cJSON_CreateString("classic BT not enabled")); return; } bool discoverable = true; const cJSON *j = cJSON_GetObjectItem(params, "discoverable"); if (cJSON_IsBool(j)) { discoverable = cJSON_IsTrue(j); } if (discoverable) { esp_bt_gap_set_scan_mode(ESP_BT_CONNECTABLE, ESP_BT_GENERAL_DISCOVERABLE); ESP_LOGI(TAG, "now discoverable + connectable"); } else { esp_bt_gap_set_scan_mode(ESP_BT_NON_CONNECTABLE, ESP_BT_NON_DISCOVERABLE); ESP_LOGI(TAG, "now non-discoverable + non-connectable"); } classic_state.discoverable = discoverable; cJSON *data = cJSON_CreateObject(); cJSON_AddBoolToObject(data, "discoverable", discoverable); uart_send_response(id, STATUS_OK, data); } void cmd_classic_pair_respond(const char *id, cJSON *params) { if (!classic_state.enabled) { uart_send_response(id, STATUS_ERROR, cJSON_CreateString("classic BT not enabled")); return; } const cJSON *j_addr = cJSON_GetObjectItem(params, "address"); const cJSON *j_accept = cJSON_GetObjectItem(params, "accept"); const cJSON *j_pass = cJSON_GetObjectItem(params, "passkey"); const cJSON *j_pin = cJSON_GetObjectItem(params, "pin"); if (!cJSON_IsString(j_addr)) { uart_send_response(id, STATUS_ERROR, cJSON_CreateString("missing 'address'")); return; } esp_bd_addr_t addr; if (!parse_bd_addr(j_addr->valuestring, addr)) { uart_send_response(id, STATUS_ERROR, cJSON_CreateString("invalid address format")); return; } bool accept = cJSON_IsBool(j_accept) ? cJSON_IsTrue(j_accept) : true; /* Determine which pairing reply to issue based on pending type */ switch (classic_state.pair_type) { case PAIR_TYPE_NUMERIC_COMPARISON: esp_bt_gap_ssp_confirm_reply(addr, accept); ESP_LOGI(TAG, "ssp_confirm_reply: %s accept=%d", j_addr->valuestring, accept); break; case PAIR_TYPE_PASSKEY_ENTRY: if (!cJSON_IsNumber(j_pass)) { uart_send_response(id, STATUS_ERROR, cJSON_CreateString("passkey_entry requires 'passkey'")); return; } esp_bt_gap_ssp_passkey_reply(addr, accept, (uint32_t)j_pass->valueint); ESP_LOGI(TAG, "ssp_passkey_reply: %s accept=%d passkey=%d", j_addr->valuestring, accept, j_pass->valueint); break; case PAIR_TYPE_PIN_REQUEST: { if (!cJSON_IsString(j_pin)) { uart_send_response(id, STATUS_ERROR, cJSON_CreateString("pin_request requires 'pin'")); return; } const char *pin_str = j_pin->valuestring; uint8_t pin_len = (uint8_t)strlen(pin_str); if (pin_len == 0 || pin_len > 16) { uart_send_response(id, STATUS_ERROR, cJSON_CreateString("pin must be 1-16 characters")); return; } esp_bt_pin_code_t pin; memset(pin, 0, sizeof(pin)); memcpy(pin, pin_str, pin_len); esp_bt_gap_pin_reply(addr, accept, pin_len, pin); ESP_LOGI(TAG, "pin_reply: %s accept=%d len=%d", j_addr->valuestring, accept, pin_len); break; } default: uart_send_response(id, STATUS_ERROR, cJSON_CreateString("no pending pair request")); return; } /* Stash the command id so AUTH_CMPL can send the final response */ strncpy(classic_state.pending_pair_cmd_id, id, sizeof(classic_state.pending_pair_cmd_id) - 1); classic_state.pending_pair_cmd_id[sizeof(classic_state.pending_pair_cmd_id) - 1] = '\0'; classic_state.pair_pending = true; /* Don't send the response yet -- AUTH_CMPL will do it. */ } void cmd_classic_set_ssp_mode(const char *id, cJSON *params) { if (!classic_state.enabled) { uart_send_response(id, STATUS_ERROR, cJSON_CreateString("classic BT not enabled")); return; } const cJSON *j_mode = cJSON_GetObjectItem(params, "mode"); if (!cJSON_IsString(j_mode)) { uart_send_response(id, STATUS_ERROR, cJSON_CreateString("missing 'mode'")); return; } const char *mode = j_mode->valuestring; esp_bt_io_cap_t new_cap; if (strcmp(mode, "just_works") == 0) { new_cap = ESP_BT_IO_CAP_NONE; } else if (strcmp(mode, "numeric_comparison") == 0) { new_cap = ESP_BT_IO_CAP_IO; } else if (strcmp(mode, "passkey_entry") == 0) { new_cap = ESP_BT_IO_CAP_IN; } else if (strcmp(mode, "passkey_display") == 0) { new_cap = ESP_BT_IO_CAP_OUT; } else { cJSON *err = cJSON_CreateObject(); cJSON_AddStringToObject(err, "error", "unknown mode"); cJSON_AddStringToObject(err, "valid", "just_works, numeric_comparison, passkey_entry, passkey_display"); uart_send_response(id, STATUS_ERROR, err); return; } classic_state.io_cap = new_cap; esp_bt_sp_param_t iocap = new_cap; esp_bt_gap_set_security_param(ESP_BT_SP_IOCAP_MODE, &iocap, sizeof(iocap)); ESP_LOGI(TAG, "SSP mode set to '%s' (io_cap=%d)", mode, new_cap); cJSON *data = cJSON_CreateObject(); cJSON_AddStringToObject(data, "mode", mode); cJSON_AddNumberToObject(data, "io_cap", new_cap); uart_send_response(id, STATUS_OK, data); } /* ------------------------------------------------------------------ */ /* State query */ /* ------------------------------------------------------------------ */ bool bt_classic_is_enabled(void) { return classic_state.enabled; } /* ------------------------------------------------------------------ */ /* Configure helpers (called from the configure command handler) */ /* ------------------------------------------------------------------ */ void bt_classic_set_io_cap(const char *io_cap_str) { esp_bt_io_cap_t cap; if (str_to_io_cap(io_cap_str, &cap)) { classic_state.io_cap = cap; ESP_LOGI(TAG, "io_cap configured: %s -> %d", io_cap_str, cap); /* Apply immediately if already enabled */ if (classic_state.enabled) { esp_bt_sp_param_t iocap = cap; esp_bt_gap_set_security_param(ESP_BT_SP_IOCAP_MODE, &iocap, sizeof(iocap)); } } else { ESP_LOGW(TAG, "unknown io_cap string: '%s'", io_cap_str); } } void bt_classic_set_device_class(uint32_t cod_raw) { esp_bt_cod_t cod; memset(&cod, 0, sizeof(cod)); cod.minor = (cod_raw >> 2) & 0x3F; cod.major = (cod_raw >> 8) & 0x1F; cod.service = (cod_raw >> 13) & 0x7FF; esp_err_t err = esp_bt_gap_set_cod(cod, ESP_BT_SET_COD_MAJOR_MINOR | ESP_BT_SET_COD_SERVICE_CLASS); if (err == ESP_OK) { ESP_LOGI(TAG, "CoD set to 0x%06" PRIx32, cod_raw); } else { ESP_LOGE(TAG, "set_cod failed: %s", esp_err_to_name(err)); } } /* ------------------------------------------------------------------ */ /* Init (called early, before any commands) */ /* ------------------------------------------------------------------ */ void bt_classic_init(void) { memset(&classic_state, 0, sizeof(classic_state)); classic_state.io_cap = ESP_BT_IO_CAP_IO; classic_state.ssp_enabled = true; ESP_LOGI(TAG, "Classic BT module ready"); }