5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ESP32にBluetooth3.0のキーボードをつなげる

Last updated at Posted at 2022-06-05

Bluetooth3.0すなわちBT Classicの10キーボードをESP32につないでみます。
世の中にはBLEのキーボードもありますが、手元にあったのがBT Classic接続でしたので、挑戦してみました。

ソースコードもろもろはGitHubに上げておきました。

ちなみに、今回採用したESP32は、M5StickCまたはM5Atomです。PlatformIOを使って開発しており、どちらのデバイスかを選択できるようにしてあります。

やりたかったこと

image.png

まずなぜ10キーボードにしたのか。それは、卓上リモコンにしたかったからです。
IoTでいろいろ遊んでいると、手元のリモコンボタンで、LINE通知をしたり、照明をつけたり、としたくなります。

今回使った10キーボードは以下です。

まずかっこいいというのと、充電式なのはありがたい。
赤外線リモコンのように、手で持って押すことなく、机上に置いたまま押せます。また、キーも大きめです。

キーを押した後の処理は、ESP32でいくらでもカスタマイズできます。
当然ながら、10キーボードは赤外線リモコンではないので、押しても何も起こりません。ですが、それをトリガにESP32が、HTTP Get呼び出ししたり、UDP送信でNode-REDに伝えたり、赤外線送受信ユニットを付ければそこから赤外線を送信することができます。

ESP32と10キーボード間の通信

今回は、BT Classicの10キーボードを採用しました。
BT Classicは、BLEとちょっと違います。
ESP32ではBLEを扱う記事は多いですが、BT Classicとなると情報はちょっと少なかったです。が、同じBT Classicで接続のPS3コントローラをESP32につないだ有志の方がいらっしゃり、そちらのサンプルコードを参考にさせていただきました。

10キーボードは、HIDに該当しますが、BT Classicで、HIDに接続するには、L2CAPの通信プロトコルに従う必要があります。L2CAPプロトコルはすでに実装されていて、さきほどのPS3コントローラ接続のサンプルコードでも使っています。しかしながら、PS3コントローラでは、接続開始はPS3コントローラから行われるようです。一般的なHIDデバイスは、ホスト側から行うので、接続順番を変える必要がありました。
結局のところ、L2CAPの接続処理は、以下の流れになります。

image.png

対向側のHIDデバイスが、どの順番で応答が返ってくるかは変わります。
上記の接続処理を、PSM=BT_PSM_HIDCとPSM=BT_PSM_HIDIの2つに対して完了させます。
以下、その処理のソースコードです。

src/hid_l2cap.cpp
#include <Arduino.h>
#include <esp_bt_main.h>
#include "esp32-hal-bt.h"

#include "stack/l2c_api.h"
#include "osi/allocator.h"
extern "C"{
  #include "stack/btm_api.h"
}
#include "hid_l2cap.h"

#define HID_L2CAP_ID_HIDC 0x40
#define HID_L2CAP_ID_HIDI 0x41

static long hid_l2cap_init_service( const char *name, uint16_t psm, uint8_t security_id);
static void hid_l2cap_deinit_service( const char *name, uint16_t psm );

static void hid_l2cap_connect_cfm_cback (uint16_t l2cap_cid, uint16_t result);
static void hid_l2cap_config_ind_cback (uint16_t l2cap_cid, tL2CAP_CFG_INFO *p_cfg);
static void hid_l2cap_config_cfm_cback (uint16_t l2cap_cid, tL2CAP_CFG_INFO *p_cfg);
static void hid_l2cap_disconnect_ind_cback (uint16_t l2cap_cid, bool ack_needed);
static void hid_l2cap_disconnect_cfm_cback (uint16_t l2cap_cid, uint16_t result);
static void hid_l2cap_data_ind_cback (uint16_t l2cap_cid, BT_HDR *p_msg);

static void dump_bin(const char *p_message, const uint8_t *p_bin, int len);

static BT_STATUS is_connected = BT_UNINITIALIZED;
static BD_ADDR g_bd_addr;
static HID_L2CAP_CALLBACK g_callback;

static uint16_t l2cap_cid_hidc;
static uint16_t l2cap_cid_hidi;

static tL2CAP_ERTM_INFO hid_ertm_info;
static tL2CAP_CFG_INFO hid_cfg_info;
static const tL2CAP_APPL_INFO dyn_info = {
    NULL,
    hid_l2cap_connect_cfm_cback,
    NULL,
    hid_l2cap_config_ind_cback,
    hid_l2cap_config_cfm_cback,
    hid_l2cap_disconnect_ind_cback,
    hid_l2cap_disconnect_cfm_cback,
    NULL,
    hid_l2cap_data_ind_cback,
    NULL,
    NULL
} ;

static long hid_l2cap_init_services(void)
{  
  long ret;
  ret = hid_l2cap_init_service( "HIDC", BT_PSM_HIDC, BTM_SEC_SERVICE_FIRST_EMPTY   );
  if( ret != 0 )
    return ret;
  ret = hid_l2cap_init_service( "HIDI", BT_PSM_HIDI, BTM_SEC_SERVICE_FIRST_EMPTY + 1 );
  if( ret != 0 )
    return ret;

  return 0;
}

static void hid_l2cap_deinit_services(void)
{
    hid_l2cap_deinit_service( "HIDC", BT_PSM_HIDC );
    hid_l2cap_deinit_service( "HIDI", BT_PSM_HIDI );
}

static long hid_l2cap_init_service( const char *name, uint16_t psm, uint8_t security_id)
{
    /* Register the PSM for incoming connections */
    if (!L2CA_Register(psm, (tL2CAP_APPL_INFO *) &dyn_info)) {
        Serial.printf("%s Registering service %s failed\n", __func__, name);
        return -1;
    }

    /* Register with the Security Manager for our specific security level (none) */
    if (!BTM_SetSecurityLevel (false, name, security_id, 0, psm, 0, 0)) {
        Serial.printf("%s Registering security service %s failed\n", __func__, name);\
        return -1;
    }

    Serial.printf("[%s] Service %s Initialized\n", __func__, name);

    return 0;
}

static void hid_l2cap_deinit_service( const char *name, uint16_t psm )
{
    L2CA_Deregister(psm);
    Serial.printf("[%s] Service %s Deinitialized\n", __func__, name);
}

BT_STATUS hid_l2cap_is_connected(void)
{
  return is_connected;
}

long hid_l2cap_reconnect(void)
{
  long ret;
  ret = L2CA_CONNECT_REQ(BT_PSM_HIDC, g_bd_addr, NULL, NULL);
  Serial.printf("L2CA_CONNECT_REQ ret=%d\n", ret);
  if( ret == 0 ){
    return -1;
  }
  l2cap_cid_hidc = ret;

  is_connected = BT_CONNECTING;

  return ret;
}

long hid_l2cap_connect(BD_ADDR addr)
{
  memmove(g_bd_addr, addr, sizeof(BD_ADDR));

  return hid_l2cap_reconnect();
}


long hid_l2cap_initialize(HID_L2CAP_CALLBACK callback)
{
  if(!btStarted() && !btStart()){
    Serial.println("btStart failed");
    return -1;
  }

  esp_bluedroid_status_t bt_state = esp_bluedroid_get_status();
  if(bt_state == ESP_BLUEDROID_STATUS_UNINITIALIZED){
      if (esp_bluedroid_init()) {
        Serial.println("esp_bluedroid_init failed");
        return -1;
      }
  }

  if(bt_state != ESP_BLUEDROID_STATUS_ENABLED){
      if (esp_bluedroid_enable()) {
        Serial.println("esp_bluedroid_enable failed");
        return -1;
      }
  }

  if( hid_l2cap_init_services() != 0 ){
    Serial.println("hid_l2cap_init_services failed");
    return -1;
  }

  g_callback = callback;

  is_connected = BT_DISCONNECTED;

  return 0;
}

static void hid_l2cap_connect_cfm_cback(uint16_t l2cap_cid, uint16_t result)
{
  Serial.printf("[%s] l2cap_cid: 0x%02x\n  result: %d\n", __func__, l2cap_cid, result );
}

static void hid_l2cap_config_cfm_cback(uint16_t l2cap_cid, tL2CAP_CFG_INFO *p_cfg)
{
  Serial.printf("[%s] l2cap_cid: 0x%02x\n  p_cfg->result: %d\n", __func__, l2cap_cid, p_cfg->result );
    
  if( l2cap_cid == l2cap_cid_hidc ){
    long ret;
    ret = L2CA_CONNECT_REQ(BT_PSM_HIDI, g_bd_addr, NULL, NULL);
    Serial.printf("L2CA_CONNECT_REQ ret=%d\n", ret);
    if( ret == 0 )
      return;
    l2cap_cid_hidi = ret;
  }else if( l2cap_cid == l2cap_cid_hidi ){
    is_connected = BT_CONNECTED;

    Serial.println("Hid Connected");
  }
}

static void hid_l2cap_config_ind_cback(uint16_t l2cap_cid, tL2CAP_CFG_INFO *p_cfg)
{
    Serial.printf("[%s] l2cap_cid: 0x%02x\n  p_cfg->result: %d\n  p_cfg->mtu_present: %d\n  p_cfg->mtu: %d\n", __func__, l2cap_cid, p_cfg->result, p_cfg->mtu_present, p_cfg->mtu );

    p_cfg->result = L2CAP_CFG_OK;

    L2CA_ConfigRsp(l2cap_cid, p_cfg);

    /* Send a Configuration Request. */
    L2CA_CONFIG_REQ(l2cap_cid, &hid_cfg_info);
}

static void hid_l2cap_disconnect_ind_cback(uint16_t l2cap_cid, bool ack_needed)
{
    Serial.printf("[%s] l2cap_cid: 0x%02x\n  ack_needed: %d\n", __func__, l2cap_cid, ack_needed );
    is_connected = BT_DISCONNECTED;
    g_callback = NULL;
}

static void hid_l2cap_disconnect_cfm_cback(uint16_t l2cap_cid, uint16_t result)
{
    Serial.printf("[%s] l2cap_cid: 0x%02x\n  result: %d\n", __func__, l2cap_cid, result );
}

static void hid_l2cap_data_ind_cback(uint16_t l2cap_cid, BT_HDR *p_buf)
{
    Serial.printf("[%s] l2cap_cid: 0x%02x\n", __func__, l2cap_cid );
    Serial.printf("event=%d len=%d offset=%d layer_specific=%d\n", p_buf->event, p_buf->len, p_buf->offset, p_buf->layer_specific);
    dump_bin("\tdata=", &p_buf->data[p_buf->offset], p_buf->len);

    if( p_buf->len == (HID_L2CAP_MESSAGE_SIZE + 2) && p_buf->data[p_buf->offset] == 0xa1 && p_buf->data[p_buf->offset + 1] == 0x01){
      if( g_callback != NULL )
          g_callback(&p_buf->data[p_buf->offset + 2]);
    }

    osi_free( p_buf );
}

static void dump_bin(const char *p_message, const uint8_t *p_bin, int len)
{
  Serial.printf("%s", p_message);
  for( int i = 0 ; i < len ; i++ ){
    Serial.printf("%02x ", p_bin[i]);
  }
  Serial.printf("\n");
}

呼び出し方

さきほどのL2CAP接続処理は、以下の呼び出しにまとまっています。

src/hid_l2cap.h
#define HID_L2CAP_MESSAGE_SIZE  8
typedef void (*HID_L2CAP_CALLBACK)(uint8_t *p_msg); 

long hid_l2cap_initialize(HID_L2CAP_CALLBACK callback);
long hid_l2cap_connect(BD_ADDR addr);
long hid_l2cap_reconnect(void);
BT_STATUS hid_l2cap_is_connected(void);

簡単に説明します。

〇hid_l2cap_initialize
L2CAP通信の初期化します。
引数に、キー押下状態を受け付けるコールバック関数を指定します。
コールバック関数は、キー押下状態が変化(押下やリリース)したときに呼び出されます。そのとき8バイトのバイト配列を受け取ります。

1バイト目:SHIFTやCTRLなどの装飾キーの押下状態
2バイト目:0x00
3バイト目以降:押下されているキー(最大6キー)

〇hid_l2cap_connect
HIDデバイスに接続します。
接続するHIDデバイスのBTのMACアドレスを6バイトのバイト配列で指定します。HIDデバイスはペアリング待ち状態である必要があります。
もし、HIDデバイスのMACアドレスがわからない場合は、例えばいったんAndroidにペアリングするとわかります。

〇hid_l2cap_reconnect
HIDデバイスに再接続します。hid_l2cap_connectを呼び出したときにはまだHIDデバイスがペアリング待ちではなかった場合がありますので、まだ接続完了していなければ、再度この関数を呼び出します。

〇hid_l2cap_is_connected
HIDデバイスとの接続状態を取得します。

ということで、以下接続のためのソースコード例です。

#define BT_CONNECT_TIMEOUT  10000
#define TARGET_BT_ADDR  { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05 } // HIDデバイスのBTのMacアドレス

static uint32_t start_tim;

void setup() {
  long ret;
  BD_ADDR addr = TARGET_BT_ADDR;
  start_tim = millis();
  ret = hid_l2cap_connect(addr);
  if( ret != 0 ){
    Serial.println("hid_l2cap_connect error");
    return;
  }
}

void loop() {
  BT_STATUS status = hid_l2cap_is_connected();
  if( status == BT_CONNECTING ){
    uint32_t tim = millis();
    if( (tim - start_tim) >= BT_CONNECT_TIMEOUT ){
      start_tim = tim;
      hid_l2cap_reconnect();
    }
  }else
  if( status == BT_DISCONNECTED ){
    start_tim = millis();
    hid_l2cap_reconnect();
  }

  delay(1);
}

メインのソースコード

卓上リモコンにするためのmain.cppのソースコードの例です。
以前の投稿で示した赤外線ゲートウェイの機能も混ぜています。

(参考)
 Node-REDから制御するESP32赤外線送受信ゲートウェイ

src/main.cpp
#include <Arduino.h>

#if defined(ARDUINO_M5Stick_C)
#include <M5StickC.h>
#elif defined(ARDUINO_M5Stack_ATOM)
#include <M5Atom.h>
#endif

#include <WiFi.h>
#include <WiFiUdp.h>
#include <ArduinoJson.h>

#include "hid_l2cap.h"

#define ENABLE_IR
//#define ENABLE_HTTP

#define TARGET_BT_ADDR  { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05 } // HIDデバイスのBTのMacアドレス

#define WIFI_SSID "【WiFiアクセスポイントのSSID 】"
#define WIFI_PASSWORD "【WiFIアクセスポイントのパスワード】"

#define UDP_HOST  "【Node-RED稼働ホスト名またはIPアドレス】"
#define UDP_SEND_PORT 1401

#ifdef ENABLE_IR
#include <IRsend.h>
#include <IRrecv.h>
#include <IRremoteESP8266.h>
#include <IRutils.h>

#define UDP_RECV_PORT 1402
#if defined(ARDUINO_M5Stick_C)
#define IR_SEND_PORT 32
#define IR_RECV_PORT 33
#elif defined(ARDUINO_M5Stack_ATOM)
#define IR_SEND_PORT 26
#define IR_RECV_PORT 32
#endif

static IRsend irsend(IR_SEND_PORT);
static IRrecv irrecv(IR_RECV_PORT);
static decode_results results;
#endif

#ifdef ENABLE_HTTP
#include <HTTPClient.h>
#define BASE_URL  "【HTTP Get呼び出し先】"
#endif

#define BT_CONNECT_TIMEOUT  10000
#define JSON_CAPACITY 256

static StaticJsonDocument<JSON_CAPACITY> jsonDoc;
static uint32_t start_tim;
static WiFiUDP udp;

static uint8_t key_message[HID_L2CAP_MESSAGE_SIZE - 2] = { 0 };

static long wifi_connect(const char *ssid, const char *password);

#ifdef ENABLE_HTTP
static String doHttpGet(const char *base_url, uint8_t key, uint8_t mod);
#endif

#ifdef ENABLE_IR
static long process_udp_receive(int packetSize);
static long process_ir_receive(void);
#endif

long udp_send(JsonDocument& json)
{
  int size = measureJson(json);
  char *p_buffer = (char*)malloc(size + 1);
  int len = serializeJson(json, p_buffer, size);
  p_buffer[len] = '\0';

  udp.beginPacket(UDP_HOST, UDP_SEND_PORT);
  udp.write((uint8_t*)p_buffer, len);
  udp.endPacket();

  free(p_buffer);

  return 0;
}

static char toC(uint8_t b)
{
  if( b >= 0 && b <= 9 )
    return '0' + b;
  else if( b >= 0x0a && b <= 0x0f )
    return 'A' + (b - 0x0a);
  else
    return '0';
}

void key_callback(uint8_t *p_msg)
{
  // キーが押されたときだけUDP送信

  for( int i = 0 ; i < HID_L2CAP_MESSAGE_SIZE - 2 ; i++ ){
    uint8_t target = p_msg[2 + i];
    if( target == 0 )
      continue;

    // すでに検知済みのキーか
    int j;
    for( j = 0 ; j < sizeof(key_message) ; j++ ){
      if( target == key_message[j] ){
        break;
      }
    }
    if( j < sizeof(key_message) )
      break; // すでに検知済み

    // 検知済みバッファに加えて、UDP送信
    for( int k = 0 ; k < sizeof(key_message) ; k++ ){
      // 検知済みバッファに空きがあるか
      if( key_message[k] == 0 ){
        key_message[k] = target;

        // UDP送信
        jsonDoc.clear();
        jsonDoc["type"] = "key_press";
        jsonDoc["key"] = target;
        jsonDoc["mod"] = p_msg[0];
        
        udp_send(jsonDoc);

#ifdef ENABLE_HTTP
        // HTTP GET送信
        doHttpGet(BASE_URL, target, p_msg[0]);
#endif
        break;
      }
    }
  }

  // 離されたキーを検知済みバッファから削除
  for( int i = 0 ; i < sizeof(key_message) ; i++ ){
    int j;
    for( j = 0 ; j < HID_L2CAP_MESSAGE_SIZE - 2 ; j++ ){
      if( p_msg[2 + j] == key_message[i] )
        break;
    }
    if( j >= HID_L2CAP_MESSAGE_SIZE - 2 )
      key_message[i] = 0;
  }

/*
  // キーの押下状態が変化したときにUDP送信
  
  char message[HID_L2CAP_MESSAGE_SIZE * 2 + 1];
  for( int i = 0 ; i < HID_L2CAP_MESSAGE_SIZE ; i++ ){
    message[i * 2] = toC((p_msg[i] >> 4) & 0x0f);
    message[i * 2 + 1] = toC(p_msg[i] & 0x0f);
  }
  message[HID_L2CAP_MESSAGE_SIZE * 2] = '\0';

  jsonDoc.clear();
  jsonDoc["type"] = "key_updated";
  jsonDoc["message"] = message;
  
  udp_send(jsonDoc);
*/
}

void setup() {
  // put your setup code here, to run once:

#if defined(ARDUINO_M5Stick_C)
  M5.begin(true, true, true);
#elif defined(ARDUINO_M5Stack_ATOM)
  M5.begin(true, true, false);
#endif
  long ret;

//  delay(5000);
  Serial.println("setup start");

  wifi_connect(WIFI_SSID, WIFI_PASSWORD);

#ifdef ENABLE_IR
  irsend.begin();
  irrecv.enableIRIn();
  udp.begin(UDP_RECV_PORT);
#endif

  ret = hid_l2cap_initialize(key_callback);
  if( ret != 0 ){
    Serial.println("hid_l2cap_initialize error");
    return;
  }

  BD_ADDR addr = TARGET_BT_ADDR;

  start_tim = millis();
  ret = hid_l2cap_connect(addr);
  if( ret != 0 ){
    Serial.println("hid_l2cap_connect error");
    return;
  }

  Serial.println("setup finished");
}

void loop() {
  // put your main code here, to run repeatedly:

  M5.update();

  if( WiFi.status() != WL_CONNECTED )
    return;

#ifdef ENABLE_IR
  if (irrecv.decode(&results)) {
    process_ir_receive();
    irrecv.resume(); 
  }

  int packetSize = udp.parsePacket();
  if( packetSize > 0 ){
    process_udp_receive(packetSize);
  }
#endif

  BT_STATUS status = hid_l2cap_is_connected();
  if( status == BT_CONNECTING ){
    uint32_t tim = millis();
    if( (tim - start_tim) >= BT_CONNECT_TIMEOUT ){
      start_tim = tim;
      hid_l2cap_reconnect();
    }
  }else
  if( status == BT_DISCONNECTED ){
    start_tim = millis();
    hid_l2cap_reconnect();
  }

  delay(1);
}

static long wifi_connect(const char *ssid, const char *password)
{
  Serial.println("");
  Serial.print("WiFi Connenting");

  if( ssid == NULL && password == NULL )
    WiFi.begin();
  else
    WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED){
    Serial.print(".");
    delay(500);
  }
  Serial.print("\nConnected : IP=");
  Serial.print(WiFi.localIP());
  Serial.print(" Mac=");
  Serial.println(WiFi.macAddress());

  return 0;
}

#ifdef ENABLE_HTTP
static String doHttpGet(const char *base_url, uint8_t key, uint8_t mod)
{
  char temp[4];

  String url(base_url);
  url += "?key=";
  itoa(key, temp, 10);
  url += String(temp);
  url += "&mod=";
  itoa(mod, temp, 10);
  url += String(temp);

  HTTPClient http;
  http.begin(url);
  int status_code = http.GET();
  if (status_code == 200){
    String result = http.getString();
    http.end();
    return result;
  }else{
    http.end();
    return String("");
  }
}
#endif

#ifdef ENABLE_IR
static long process_ir_receive(void)
{
  Serial.println("process_ir_receive");

  if(results.overflow){
    Serial.println("Overflow");
    return -1;
  }
  if( results.decode_type != decode_type_t::NEC || results.repeat ){
    Serial.println("not supported");
    return -1;
  }

  Serial.print(resultToHumanReadableBasic(&results));
  Serial.printf("address=%d, command=%d\n", results.address, results.command);

  jsonDoc.clear();
  jsonDoc["type"] = "ir_received";
  jsonDoc["address"] = results.address;
  jsonDoc["command"] = results.command;
  jsonDoc["value"] = results.value;

  udp_send(jsonDoc);

  return 0;
}

static long process_udp_receive(int packetSize)
{
  Serial.println("process_udp_receive");

  char *p_buffer = (char*)malloc(packetSize + 1);
  if( p_buffer == NULL )
    return -1;
  
  int len = udp.read(p_buffer, packetSize);
  if( len <= 0 ){
    free(p_buffer);
    return -1;
  }
  p_buffer[len] = '\0';

  DeserializationError err = deserializeJson(jsonDoc, p_buffer);
  if (err) {
    Serial.print(F("deserializeJson() failed with code "));
    Serial.println(err.f_str());

    free(p_buffer);
    return -1;
  }

  const char *p_type = jsonDoc["type"];
  Serial.printf("type=%s\n", p_type);
  if( strcmp(p_type, "ir_send") == 0 ){
    if( jsonDoc.containsKey("value") ){
      uint32_t value = jsonDoc["value"];
      irsend.sendNEC(value);
    }else{
      uint16_t address = jsonDoc["address"];
      uint16_t command = jsonDoc["command"];
      uint32_t value = irsend.encodeNEC(address, command);
      irsend.sendNEC(value);
    }
  }else{
    Serial.println("Not supported");
    free(p_buffer);
    return -1;
  }

  free(p_buffer);

  return 0;
}
#endif

参考

 ESP32でキーボードショートカットを作ってしまおう
 Node-REDから制御するESP32赤外線送受信ゲートウェイ
 ESP32(M5StickC)をPS3コントローラで操作する
 WiiリモコンをNode.jsから操ってみよう

以上

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?