Ryan Malloy 61da375a0c Add SPP (Serial Port Profile) support for bidirectional data transfer
Firmware:
- Add spp_connect, spp_disconnect, spp_data events
- Add spp_send, spp_disconnect, spp_status commands
- Track remote address for connected SPP peer
- Report received data as hex + optional text decode

Python MCP:
- esp32_spp_send(data/data_hex) - send text or binary
- esp32_spp_disconnect() - close SPP connection
- esp32_spp_status() - query connection state

Tested: Linux rfcomm connect → ESP32, bidirectional data transfer works
2026-02-03 13:39:17 -07:00

829 lines
28 KiB
C

/*
* 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 <string.h>
#include <stdio.h>
#include <stdlib.h>
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;
bool auto_accept; /* Auto-confirm SSP pairing (for testing) */
/* 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;
char spp_remote_addr[18]; /* Connected peer address */
} 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);
if (classic_state.auto_accept) {
/* Auto-confirm for automated E2E testing */
esp_bt_gap_ssp_confirm_reply(param->cfm_req.bda, true);
ESP_LOGI(TAG, "cfm_req: auto-accepted (auto_accept=true)");
} else {
/* 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;
strncpy(classic_state.spp_remote_addr, addr_str,
sizeof(classic_state.spp_remote_addr) - 1);
classic_state.spp_remote_addr[sizeof(classic_state.spp_remote_addr) - 1] = '\0';
ESP_LOGI(SPP_TAG, "SPP client connected: %s (handle=%" PRIu32 ")",
addr_str, param->srv_open.handle);
/* Report specific SPP connect event with more detail */
{
cJSON *d = cJSON_CreateObject();
cJSON_AddStringToObject(d, "address", addr_str);
cJSON_AddNumberToObject(d, "handle", (double)param->srv_open.handle);
cJSON_AddStringToObject(d, "transport", "spp");
event_report(EVT_SPP_CONNECT, d);
}
break;
case ESP_SPP_CLOSE_EVT:
ESP_LOGI(SPP_TAG, "SPP disconnected (handle=%" PRIu32 ")",
param->close.handle);
/* Report SPP disconnect with saved remote address */
{
cJSON *d = cJSON_CreateObject();
cJSON_AddNumberToObject(d, "handle", (double)param->close.handle);
cJSON_AddStringToObject(d, "transport", "spp");
if (classic_state.spp_remote_addr[0] != '\0') {
cJSON_AddStringToObject(d, "address", classic_state.spp_remote_addr);
}
event_report(EVT_SPP_DISCONNECT, d);
}
if (classic_state.spp_handle == param->close.handle) {
classic_state.spp_handle = 0;
classic_state.spp_remote_addr[0] = '\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 -- report as event */
ESP_LOGI(SPP_TAG, "SPP data: %d bytes on handle %" PRIu32,
param->data_ind.len, param->data_ind.handle);
{
cJSON *d = cJSON_CreateObject();
cJSON_AddNumberToObject(d, "handle", (double)param->data_ind.handle);
cJSON_AddNumberToObject(d, "length", param->data_ind.len);
if (classic_state.spp_remote_addr[0] != '\0') {
cJSON_AddStringToObject(d, "address", classic_state.spp_remote_addr);
}
/* Encode data as hex string */
size_t hex_len = param->data_ind.len * 2 + 1;
char *hex_str = malloc(hex_len);
if (hex_str) {
for (int i = 0; i < param->data_ind.len; i++) {
sprintf(hex_str + i * 2, "%02x", param->data_ind.data[i]);
}
hex_str[param->data_ind.len * 2] = '\0';
cJSON_AddStringToObject(d, "data_hex", hex_str);
free(hex_str);
}
/* Also try UTF-8 if printable */
bool printable = true;
for (int i = 0; i < param->data_ind.len && printable; i++) {
uint8_t c = param->data_ind.data[i];
if (c < 0x20 && c != '\n' && c != '\r' && c != '\t') {
printable = false;
}
}
if (printable && param->data_ind.len < 256) {
char *text = malloc(param->data_ind.len + 1);
if (text) {
memcpy(text, param->data_ind.data, param->data_ind.len);
text[param->data_ind.len] = '\0';
cJSON_AddStringToObject(d, "data_text", text);
free(text);
}
}
event_report(EVT_SPP_DATA, d);
}
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));
/* Optional auto_accept flag for automated testing */
const cJSON *j_auto = cJSON_GetObjectItem(params, "auto_accept");
if (cJSON_IsBool(j_auto)) {
classic_state.auto_accept = cJSON_IsTrue(j_auto);
}
ESP_LOGI(TAG, "SSP mode set to '%s' (io_cap=%d, auto_accept=%d)",
mode, new_cap, classic_state.auto_accept);
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "mode", mode);
cJSON_AddNumberToObject(data, "io_cap", new_cap);
cJSON_AddBoolToObject(data, "auto_accept", classic_state.auto_accept);
uart_send_response(id, STATUS_OK, data);
}
/* ------------------------------------------------------------------ */
/* SPP command handlers */
/* ------------------------------------------------------------------ */
static bool hex_to_bytes(const char *hex, uint8_t *out, size_t *out_len, size_t max_len)
{
size_t hex_len = strlen(hex);
if (hex_len % 2 != 0) return false;
size_t bytes = hex_len / 2;
if (bytes > max_len) return false;
for (size_t i = 0; i < bytes; i++) {
unsigned int b;
if (sscanf(hex + i * 2, "%2x", &b) != 1) return false;
out[i] = (uint8_t)b;
}
*out_len = bytes;
return true;
}
void cmd_spp_send(const char *id, cJSON *params)
{
if (!classic_state.enabled) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("classic BT not enabled"));
return;
}
if (classic_state.spp_handle == 0) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("no SPP connection"));
return;
}
const cJSON *j_data = cJSON_GetObjectItem(params, "data");
const cJSON *j_hex = cJSON_GetObjectItem(params, "data_hex");
uint8_t buf[512];
size_t len = 0;
if (cJSON_IsString(j_hex)) {
/* Hex-encoded data */
if (!hex_to_bytes(j_hex->valuestring, buf, &len, sizeof(buf))) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("invalid hex data"));
return;
}
} else if (cJSON_IsString(j_data)) {
/* Plain text data */
len = strlen(j_data->valuestring);
if (len > sizeof(buf)) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("data too long (max 512 bytes)"));
return;
}
memcpy(buf, j_data->valuestring, len);
} else {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("missing 'data' or 'data_hex'"));
return;
}
esp_err_t err = esp_spp_write(classic_state.spp_handle, len, buf);
if (err != ESP_OK) {
ESP_LOGE(SPP_TAG, "spp_write failed: %s", esp_err_to_name(err));
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString(esp_err_to_name(err)));
return;
}
ESP_LOGI(SPP_TAG, "SPP sent %zu bytes", len);
cJSON *data = cJSON_CreateObject();
cJSON_AddNumberToObject(data, "bytes_sent", (double)len);
uart_send_response(id, STATUS_OK, data);
}
void cmd_spp_disconnect(const char *id, cJSON *params)
{
(void)params;
if (!classic_state.enabled) {
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString("classic BT not enabled"));
return;
}
if (classic_state.spp_handle == 0) {
uart_send_response(id, STATUS_OK,
cJSON_CreateString("no active connection"));
return;
}
uint32_t handle = classic_state.spp_handle;
esp_err_t err = esp_spp_disconnect(handle);
if (err != ESP_OK) {
ESP_LOGE(SPP_TAG, "spp_disconnect failed: %s", esp_err_to_name(err));
uart_send_response(id, STATUS_ERROR,
cJSON_CreateString(esp_err_to_name(err)));
return;
}
ESP_LOGI(SPP_TAG, "SPP disconnect initiated (handle=%" PRIu32 ")", handle);
uart_send_response(id, STATUS_OK, NULL);
}
void cmd_spp_status(const char *id, cJSON *params)
{
(void)params;
cJSON *data = cJSON_CreateObject();
cJSON_AddBoolToObject(data, "connected", classic_state.spp_handle != 0);
if (classic_state.spp_handle != 0) {
cJSON_AddNumberToObject(data, "handle", (double)classic_state.spp_handle);
if (classic_state.spp_remote_addr[0] != '\0') {
cJSON_AddStringToObject(data, "remote_address", classic_state.spp_remote_addr);
}
}
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");
}