feat: add WebSocket gateway on port 18789
JSON protocol server using esp_http_server with WS upgrade. Supports up to 4 concurrent clients, auto-assigned chat_id, routes messages through the agent via message bus. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
217
main/gateway/ws_server.c
Normal file
217
main/gateway/ws_server.c
Normal file
@@ -0,0 +1,217 @@
|
||||
#include "ws_server.h"
|
||||
#include "mimi_config.h"
|
||||
#include "bus/message_bus.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include "esp_log.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "cJSON.h"
|
||||
|
||||
static const char *TAG = "ws";
|
||||
|
||||
static httpd_handle_t s_server = NULL;
|
||||
|
||||
/* Simple client tracking */
|
||||
typedef struct {
|
||||
int fd;
|
||||
char chat_id[32];
|
||||
bool active;
|
||||
} ws_client_t;
|
||||
|
||||
static ws_client_t s_clients[MIMI_WS_MAX_CLIENTS];
|
||||
|
||||
static ws_client_t *find_client_by_fd(int fd)
|
||||
{
|
||||
for (int i = 0; i < MIMI_WS_MAX_CLIENTS; i++) {
|
||||
if (s_clients[i].active && s_clients[i].fd == fd) {
|
||||
return &s_clients[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static ws_client_t *find_client_by_chat_id(const char *chat_id)
|
||||
{
|
||||
for (int i = 0; i < MIMI_WS_MAX_CLIENTS; i++) {
|
||||
if (s_clients[i].active && strcmp(s_clients[i].chat_id, chat_id) == 0) {
|
||||
return &s_clients[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static ws_client_t *add_client(int fd)
|
||||
{
|
||||
for (int i = 0; i < MIMI_WS_MAX_CLIENTS; i++) {
|
||||
if (!s_clients[i].active) {
|
||||
s_clients[i].fd = fd;
|
||||
snprintf(s_clients[i].chat_id, sizeof(s_clients[i].chat_id), "ws_%d", fd);
|
||||
s_clients[i].active = true;
|
||||
ESP_LOGI(TAG, "Client connected: %s (fd=%d)", s_clients[i].chat_id, fd);
|
||||
return &s_clients[i];
|
||||
}
|
||||
}
|
||||
ESP_LOGW(TAG, "Max clients reached, rejecting fd=%d", fd);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void remove_client(int fd)
|
||||
{
|
||||
for (int i = 0; i < MIMI_WS_MAX_CLIENTS; i++) {
|
||||
if (s_clients[i].active && s_clients[i].fd == fd) {
|
||||
ESP_LOGI(TAG, "Client disconnected: %s", s_clients[i].chat_id);
|
||||
s_clients[i].active = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static esp_err_t ws_handler(httpd_req_t *req)
|
||||
{
|
||||
if (req->method == HTTP_GET) {
|
||||
/* WebSocket handshake — register client */
|
||||
int fd = httpd_req_to_sockfd(req);
|
||||
add_client(fd);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* Receive WebSocket frame */
|
||||
httpd_ws_frame_t ws_pkt = {0};
|
||||
ws_pkt.type = HTTPD_WS_TYPE_TEXT;
|
||||
|
||||
/* Get frame length */
|
||||
esp_err_t ret = httpd_ws_recv_frame(req, &ws_pkt, 0);
|
||||
if (ret != ESP_OK) return ret;
|
||||
|
||||
if (ws_pkt.len == 0) return ESP_OK;
|
||||
|
||||
ws_pkt.payload = calloc(1, ws_pkt.len + 1);
|
||||
if (!ws_pkt.payload) return ESP_ERR_NO_MEM;
|
||||
|
||||
ret = httpd_ws_recv_frame(req, &ws_pkt, ws_pkt.len);
|
||||
if (ret != ESP_OK) {
|
||||
free(ws_pkt.payload);
|
||||
return ret;
|
||||
}
|
||||
|
||||
int fd = httpd_req_to_sockfd(req);
|
||||
ws_client_t *client = find_client_by_fd(fd);
|
||||
|
||||
/* Parse JSON message */
|
||||
cJSON *root = cJSON_Parse((char *)ws_pkt.payload);
|
||||
free(ws_pkt.payload);
|
||||
|
||||
if (!root) {
|
||||
ESP_LOGW(TAG, "Invalid JSON from fd=%d", fd);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
cJSON *type = cJSON_GetObjectItem(root, "type");
|
||||
cJSON *content = cJSON_GetObjectItem(root, "content");
|
||||
|
||||
if (type && cJSON_IsString(type) && strcmp(type->valuestring, "message") == 0
|
||||
&& content && cJSON_IsString(content)) {
|
||||
|
||||
/* Determine chat_id */
|
||||
const char *chat_id = client ? client->chat_id : "ws_unknown";
|
||||
cJSON *cid = cJSON_GetObjectItem(root, "chat_id");
|
||||
if (cid && cJSON_IsString(cid)) {
|
||||
chat_id = cid->valuestring;
|
||||
/* Update client's chat_id if provided */
|
||||
if (client) {
|
||||
strncpy(client->chat_id, chat_id, sizeof(client->chat_id) - 1);
|
||||
}
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "WS message from %s: %.40s...", chat_id, content->valuestring);
|
||||
|
||||
/* Push to inbound bus */
|
||||
mimi_msg_t msg = {0};
|
||||
strncpy(msg.channel, MIMI_CHAN_WEBSOCKET, sizeof(msg.channel) - 1);
|
||||
strncpy(msg.chat_id, chat_id, sizeof(msg.chat_id) - 1);
|
||||
msg.content = strdup(content->valuestring);
|
||||
if (msg.content) {
|
||||
message_bus_push_inbound(&msg);
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t ws_server_start(void)
|
||||
{
|
||||
memset(s_clients, 0, sizeof(s_clients));
|
||||
|
||||
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
||||
config.server_port = MIMI_WS_PORT;
|
||||
config.ctrl_port = MIMI_WS_PORT + 1;
|
||||
config.max_open_sockets = MIMI_WS_MAX_CLIENTS;
|
||||
|
||||
esp_err_t ret = httpd_start(&s_server, &config);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to start WebSocket server: %s", esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* Register WebSocket URI */
|
||||
httpd_uri_t ws_uri = {
|
||||
.uri = "/",
|
||||
.method = HTTP_GET,
|
||||
.handler = ws_handler,
|
||||
.is_websocket = true,
|
||||
};
|
||||
httpd_register_uri_handler(s_server, &ws_uri);
|
||||
|
||||
ESP_LOGI(TAG, "WebSocket server started on port %d", MIMI_WS_PORT);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t ws_server_send(const char *chat_id, const char *text)
|
||||
{
|
||||
if (!s_server) return ESP_ERR_INVALID_STATE;
|
||||
|
||||
ws_client_t *client = find_client_by_chat_id(chat_id);
|
||||
if (!client) {
|
||||
ESP_LOGW(TAG, "No WS client with chat_id=%s", chat_id);
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
/* Build response JSON */
|
||||
cJSON *resp = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(resp, "type", "response");
|
||||
cJSON_AddStringToObject(resp, "content", text);
|
||||
cJSON_AddStringToObject(resp, "chat_id", chat_id);
|
||||
|
||||
char *json_str = cJSON_PrintUnformatted(resp);
|
||||
cJSON_Delete(resp);
|
||||
|
||||
if (!json_str) return ESP_ERR_NO_MEM;
|
||||
|
||||
httpd_ws_frame_t ws_pkt = {
|
||||
.type = HTTPD_WS_TYPE_TEXT,
|
||||
.payload = (uint8_t *)json_str,
|
||||
.len = strlen(json_str),
|
||||
};
|
||||
|
||||
esp_err_t ret = httpd_ws_send_frame_async(s_server, client->fd, &ws_pkt);
|
||||
free(json_str);
|
||||
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Failed to send to %s: %s", chat_id, esp_err_to_name(ret));
|
||||
remove_client(client->fd);
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
esp_err_t ws_server_stop(void)
|
||||
{
|
||||
if (s_server) {
|
||||
httpd_stop(s_server);
|
||||
s_server = NULL;
|
||||
ESP_LOGI(TAG, "WebSocket server stopped");
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
25
main/gateway/ws_server.h
Normal file
25
main/gateway/ws_server.h
Normal file
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
/**
|
||||
* Initialize and start the WebSocket server on MIMI_WS_PORT.
|
||||
* Allows external clients to interact with the Agent via JSON messages.
|
||||
*
|
||||
* Protocol:
|
||||
* Inbound: {"type":"message","content":"hello","chat_id":"ws_client1"}
|
||||
* Outbound: {"type":"response","content":"Hi!","chat_id":"ws_client1"}
|
||||
*/
|
||||
esp_err_t ws_server_start(void);
|
||||
|
||||
/**
|
||||
* Send a text message to a specific WebSocket client by chat_id.
|
||||
* @param chat_id Client identifier (assigned on connection)
|
||||
* @param text Message text
|
||||
*/
|
||||
esp_err_t ws_server_send(const char *chat_id, const char *text);
|
||||
|
||||
/**
|
||||
* Stop the WebSocket server.
|
||||
*/
|
||||
esp_err_t ws_server_stop(void);
|
||||
Reference in New Issue
Block a user