ESP-IDF で websocket を使ってみる

## まえがき

ESP32 に外からコマンドを送信してリモコンを操作する、みたいなことをやりたくて、websocket で遊んでみました。
Arduino向けの websocket ライブラリがいくつかありましたが、`loop()` をビジーループするような雰囲気で残念に思い、ESP-IDF の由緒正しい（？）websocket ライブラリを使ってみました。

ESP-IDF 4.1 を使っています。ESP-IDF は開発が活発なのはいいのですが、頻繁に API が変わるのが辛いですね。

## コード

ESP32 は Wi-Fi Station (子機) として動作します。
今回はデモとして、同一 LAN 上の PC で websocket サーバを動かします。

Websocket サーバと ESP32 を起動してから以下のように実行すると、ESP32 にコマンド文字列が渡されます。

```
curl -X POST -d 'command' http://{PCのIPアドレス}:8888/api
```

#### ESP32

ちょっと長いですが、前半が wifi 接続処理、後半が websocket 関連のコードになっています。
`do_something()` に実際の処理を実装する想定です。

```c++
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/event_groups.h>
#include <esp_system.h>
#include <esp_event.h>
#include <esp_wifi.h>
#include "esp_websocket_client.h"
#include <esp_log.h>
#include <nvs_flash.h>

#define WIFI_SSID "some ssid"
#define WIFI_PASS "Password"
#define WEBSOCKET_SERVER_URI "ws://192.168.0.25:8888/ws"

static EventGroupHandle_t wifi_event_group;
#define WIFI_CONNECTED_BIT BIT0
#define WIFI_FAIL_BIT BIT1

static const char *TAG = "WS_APP";

static int connect_retry = 0;
#define MAX_RETRY 5

static void wifi_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id,
                               void *event_data) {
    if (event_base == WIFI_EVENT) {
        if (connect_retry > MAX_RETRY) {
            ESP_LOGI(TAG, "retry over");
            xEventGroupSetBits(wifi_event_group, WIFI_FAIL_BIT);
        }
        switch (event_id) {
        case WIFI_EVENT_STA_START:
            esp_wifi_connect();
            break;
        case WIFI_EVENT_STA_DISCONNECTED:
            esp_wifi_connect();
            connect_retry++;
            ESP_LOGI(TAG, "retry to connect");
            break;
        default:
            break;
        }
    } else if (event_base == IP_EVENT) {
        ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
        ESP_LOGI(TAG, "got ip:" IPSTR, IP2STR(&event->ip_info.ip));
        connect_retry = 0;
        xEventGroupSetBits(wifi_event_group, WIFI_CONNECTED_BIT);
    }
}

void init_wifi_sta(void) {
    wifi_event_group = xEventGroupCreate();

    esp_netif_create_default_wifi_sta();

    wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
    ESP_ERROR_CHECK(esp_wifi_init(&cfg));

    ESP_ERROR_CHECK(
        esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL));
    ESP_ERROR_CHECK(
        esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &wifi_event_handler, NULL));

    wifi_config_t wifi_config = {
        .sta =
            {
                .ssid = WIFI_SSID,
                .password = WIFI_PASS,
                .threshold.authmode = WIFI_AUTH_WPA2_PSK,
                .pmf_cfg =
                    {
                        .capable = true,
                        .required = false,
                    },
            },
    };
    wifi_country_t wifi_country = {.cc = "JP", .schan = 1, .nchan = 14};
    ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
    ESP_ERROR_CHECK(esp_wifi_set_config(ESP_IF_WIFI_STA, &wifi_config));
    ESP_ERROR_CHECK(esp_wifi_set_country(&wifi_country));
    ESP_ERROR_CHECK(esp_wifi_start());

    ESP_LOGI(TAG, "succeeded to wifi_init_sta()");

    // 接続が成功するか、リトライオーバーになるまでブロックする
    EventBits_t bits = xEventGroupWaitBits(wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT,
                                           pdFALSE, pdFALSE, portMAX_DELAY);

    if (bits & WIFI_CONNECTED_BIT) {
        ESP_LOGI(TAG, "connected to SSID:%s", WIFI_SSID);
    } else if (bits & WIFI_FAIL_BIT) {
        ESP_LOGI(TAG, "failed to connect SSID:%s", WIFI_SSID);
    } else {
        ESP_LOGE(TAG, "UNEXPECTED EVENT");
    }
}

static void do_something(const char *command, int command_len) {
    ESP_LOGI(TAG, "recieved data:%.*s", command_len, command);
    // TODO
}

static void websocket_event_handler(void *args, esp_event_base_t base, int32_t event_id,
                                    void *event_data) {
    esp_websocket_event_data_t *data = (esp_websocket_event_data_t *)event_data;
    switch (event_id) {
    case WEBSOCKET_EVENT_CONNECTED:
        ESP_LOGI(TAG, "websocket connected");
        break;
    case WEBSOCKET_EVENT_DISCONNECTED:
        ESP_LOGI(TAG, "websocket disconnected");
        break;
    case WEBSOCKET_EVENT_DATA:
        // ping/pong フレームを無視する
        if (data->op_code == 0x9 || data->op_code == 0xA) {
            break;
        }
        do_something(data->data_ptr, data->data_len);
        break;
    case WEBSOCKET_EVENT_ERROR:
        ESP_LOGW(TAG, "WEBSOCKET_EVENT_ERROR");
        break;
    default:
        break;
    }
}

static void websocket_app_start() {
    esp_websocket_client_config_t cfg = {};
    cfg.uri = WEBSOCKET_SERVER_URI;

    ESP_LOGI(TAG, "Connecting to %s...", cfg.uri);

    esp_websocket_client_handle_t client = esp_websocket_client_init(&cfg);
    esp_websocket_register_events(client, WEBSOCKET_EVENT_ANY, websocket_event_handler,
                                  (void *)client);

    esp_websocket_client_start(client);
}

void app_main() {
    esp_err_t ret = nvs_flash_init();
    if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
        ESP_ERROR_CHECK(nvs_flash_erase());
        ret = nvs_flash_init();
    }
    ESP_ERROR_CHECK(ret);

    ESP_LOGI(TAG, "ESP_WIFI_MODE_STA");

    ESP_ERROR_CHECK(esp_netif_init());
    ESP_ERROR_CHECK(esp_event_loop_create_default());

    init_wifi_sta();

    websocket_app_start();
}
```

#### サーバ

以下のように実行します

```
pip install tornado
python3 ws-server.py
```

色々手抜きなので、接続中のクライアント全てにコマンドが送信されます

```python:ws-server.py
from logging import getLogger, StreamHandler, INFO

from tornado import websocket, web, ioloop

logger = getLogger(__name__)
handler = StreamHandler()
logger.setLevel(INFO)
handler.setLevel(INFO)
logger.addHandler(handler)


class WebsocketHandler(websocket.WebSocketHandler):
    clients = set()

    def open(self):
        logger.info("new connection: %s", self.request.remote_ip)
        WebsocketHandler.clients.add(self)
    
    def on_close(self):
        WebsocketHandler.clients.remove(self)

    def on_message(self, message):
        logger.info("got message: %r", message)


class ApiHandler(web.RequestHandler):
    async def post(self):
        msg = self.request.body
        for c in WebsocketHandler.clients:
            await c.write_message(msg)
        self.write("OK")


app = web.Application([
    (r'/api', ApiHandler),
    (r'/ws', WebsocketHandler),
])
app.listen(8888)
ioloop.IOLoop.instance().start()
```

## 参考
いずれも ESP-IDF v4.1 向けのリンクになっています

- [API Reference > Websocket](https://docs.espressif.com/projects/esp-idf/en/release-v4.1/api-reference/protocols/esp_websocket_client.html)
- [Websocket Client Sample](https://github.com/espressif/esp-idf/blob/release/v4.1/examples/protocols/websocket/main/websocket_example.c)
- [WiFi Client Sample](https://github.com/espressif/esp-idf/blob/release/v4.1/examples/wifi/getting_started/station/main/station_example_main.c)