将SignalR移植到Esp32—让小智设备无缝连接.NET功能拓展MCP服务

张开发
2026/5/31 2:51:36 15 分钟阅读
将SignalR移植到Esp32—让小智设备无缝连接.NET功能拓展MCP服务
所以我就决定改造小智客户端集成SignalR实时通信框架。这次改造的核心价值是通过SignalR消息通道让设备可以接收各种类型的消息声音、图片、文本通知服务端的MCP工具执行成功后可以根据用户ID推送数据到对应的用户通道。整个改造涉及SignalR C客户端的集成、JWT Token认证、扫码登录基于ESP32本地MCP工具实现、以及服务端消息推送逻辑。客户端代码都是C实现的不过现在AI辅助编程很强大帮我节省了大量时间。问题解答Q: 为什么选择SignalR而不是直接用WebSocketA: 起初我确实考虑过直接用WebSocket但SignalR提供了很多开箱即用的功能Hub抽象服务端可以轻松实现群组管理按用户ID推送消息比如Clients.Group($Users:{userId}).SendAsync(Notification, message)消息路由不需要自己写消息分发逻辑SignalR的Hub方法调用和事件推送已经很完善了类型化调用相比原始WebSocket的字符串消息SignalR提供了类似RPC的调用体验代码更清晰虽然ESP32没有现成的SignalR库但我找到了微软官方的C SignalR客户端半成品将它与ESP32的WebSocket组件整合后就能用上SignalR的这些特性了。至于SignalR自带的重连机制我没用小智有自己的循环重连逻辑更可控一些。Q: 改造的核心价值是什么解决了什么问题A: 改造前ESP32的MCP工具调用完成后只能通过两种方式通知同步返回工具执行结果直接返回给调用方异步邮件通过邮件发送执行结果这两种方式都无法满足实时推送的需求。比如我想让服务端在生图完成后立即推送图片给设备显示或者播放一段语音提示之前的架构做不到。改造后通过SignalR建立了一条服务端到设备的实时消息通道服务端的MCP工具执行成功后可以调用_hubContext.Clients.Group($Users:{userId}).SendAsync(ShowImage, imageData)将图片推送给设备设备通过SignalR的事件监听接收消息connection-on(ShowImage, [](const std::vectorsignalr::value args) { ... })支持推送任意类型的数据文本、图片Base64、语音URL、JSON通知等这才是这次改造的核心价值让设备具备被动接收服务端消息的能力而不仅仅是主动调用和同步返回。Q: 扫码登录是怎么实现的A: 扫码登录功能是基于ESP32本地MCP工具实现的这是小智的固有功能我只是进行了拓展设备启动时检查是否有JWT Token如果没有Token调用本地MCP工具display_qrcode在屏幕上显示二维码二维码内容包含设备ID和服务端地址https://mcp-server.com/device-login?deviceIdxxx用户用手机扫码完成授权。设备获取Token后保存到NVSNon-Volatile Storage下次启动直接使用这样就实现了设备的快速认证用户体验很好。扫码认证的服务端是使用开源的keycloak做的对接了设备认证类型。名词解释核心概念SignalR微软提供的实时通信框架封装了WebSocket、Server‑Sent Events和长轮询等传输方式支持Hub模型、自动重连与消息序列化。适合实现双向、低延迟的实时消息系统。将它移植到嵌入式设备时需考虑客户端实现的体积、内存消耗与线程模型。Hub集线器SignalR的核心抽象类似于MVC中的Controller。服务端通过Hub定义方法供客户端调用客户端也可以注册事件监听服务端推送。例如ChatHub.SendMessage(user, message)就是一个典型的Hub方法。MCPModel Context Protocol一种基于JSON-RPC 2.0的协议用于定义客户端和服务端之间的工具调用规范。在IoT场景中设备可以作为MCP Server暴露能力如重启、显示图片而云端服务作为MCP Client调用这些能力。JSON-RPC 2.0一种轻量级的远程过程调用协议使用JSON编码。MCP协议基于此标准定义了initialize、tools/list、tools/call等方法。每个请求必须包含jsonrpc: 2.0、method、id字段。ESP32相关FreeRTOS一个开源、轻量级的实时操作系统内核常用于微控制器平台如ESP32。提供任务调度、优先级、互斥锁、信号量、队列、软件定时器等实时特性便于在资源受限设备上实现并发与确定性行为。使用时需注意堆栈大小、中断安全和任务优先级设计。ESP32 PSRAMESP32可选的外部伪静态RAMPseudo-SRAM用于扩展设备可用内存常见4MB/8MB/16MB。适合存放大对象、图像缓存、网络缓冲和动态分配数据。在ESP-IDF中需启用并正确配置分配时也可使用不同的堆区域如heap_caps_malloc(size, MALLOC_CAP_SPIRAM)来控制放置与性能DMA限制。WebSocket一种基于TCP的全双工通信协议通过HTTP握手升级建立连接。SignalR默认优先使用WebSocket作为传输层在ESP32上通过esp_websocket_client组件实现。需要注意的是ESP32的WebSocket客户端不支持自动重连需要在应用层实现。认证相关Bearer Token一种HTTP认证方案将Token放在Authorization头中Authorization: Bearer token。在SignalR中通常将Token作为查询参数传递/hub?access_tokenYOUR_TOKENJWTJSON Web Token一种开放标准RFC 7512用于在各方之间安全地传输信息。在Verdure MCP中使用Keycloak签发的JWT进行用户认证Token中包含用户ID、角色、过期时间等Claim信息。API Token一种简单的认证方式后续连接时携带此Token验证身份。Verdure MCP同时支持API Token和JWT两种方式。核心技术架构整个改造的架构可以用一张图说明┌──────────────────────┐ ┌──────────────────────┐ │ .NET MCP Service │ │ ESP32 Device │ │ (Verdure MCP) │◄─────SignalR Hub────────►│ (小智客户端) │ │ │ │ │ │ ┌────────────────┐ │ ① JWT Token认证 │ ┌────────────────┐ │ │ │ DeviceHub.cs │ │◄─────────────────────────│ │ 扫码登录 │ │ │ │ │ │ │ │ (本地MCP工具) │ │ │ │ OnConnected │ │ │ └────────────────┘ │ │ │ (验证Token) │ │ │ ↓ │ │ └────────────────┘ │ ② 建立连接 │ ┌────────────────┐ │ │ ↓ │◄─────────────────────────│ │ SignalR Client │ │ │ ┌────────────────┐ │ │ │ - connection │ │ │ │ 群组管理 │ │ │ │ - on() events │ │ │ │ Users:{userId} │ │ │ └────────────────┘ │ │ └────────────────┘ │ │ │ │ ↓ │ │ │ │ ┌────────────────┐ │ ③ MCP工具执行后推送 │ ┌────────────────┐ │ │ │ 消息推送 │ │─────────────────────────►│ │ 消息接收处理 │ │ │ │ SendAsync() │ │ ShowImage(imageData) │ │ - 显示图片 │ │ │ │ │ │ PlayAudio(audioUrl) │ │ - 播放语音 │ │ │ │ │ │ Notification(text) │ │ - 显示通知 │ │ │ └────────────────┘ │ │ └────────────────┘ │ └──────────────────────┘ └──────────────────────┘关键流程扫码登录设备启动后如果没有Token调用本地MCP工具显示二维码用户扫码后获取JWT Token建立连接携带JWT Token连接SignalR Hub服务端验证后加入用户群组Users:{userId}消息推送服务端MCP工具执行完成后通过SignalR将结果推送给设备_hubContext.Clients.Group($Users:{userId}).SendAsync(ShowImage, imageData)设备监听事件并处理connection-on(ShowImage, handler)这套架构的核心价值就是让服务端可以主动推送消息给设备而不仅仅是等待设备轮询或同步返回。开发环境准备ESP32开发环境VS Code方式最简单的方式是使用VS Code的ESP-IDF插件安装VS Code和插件下载安装 Visual Studio Code安装扩展Espressif IDF(搜索esp-idf)配置ESP-IDF按F1打开命令面板输入ESP-IDF: Configure ESP-IDF Extension选择Express快速配置选择ESP-IDF版本推荐v5.1或更高等待安装完成会自动下载工具链、Python环境等创建/打开项目F1→ESP-IDF: Show Examples Projects或直接打开 esp-signalr-example 项目文件夹编译和烧录点击底部状态栏的Build、Flash、Monitor按钮或按快捷键CtrlE B编译、CtrlE F烧录这种方式比命令行简单很多适合.NET开发者快速上手ESP32开发。.NET开发环境服务端使用.NET 10开发# Windows: 下载安装器 https://dotnet.microsoft.com/download/dotnet/10.0 # 验证安装 dotnet --version # 应该输出 10.0.x核心代码实现本章节将代码分为示例代码和实际整合代码两个部分进行讲解示例代码用于理解核心概念的简化版本便于学习和快速上手实际整合代码生产环境中的完整实现包含完善的错误处理、状态管理等关于示例仓库为了帮助开发者快速上手ESP32的SignalR集成我创建了一个完整的示例仓库 仓库地址https://github.com/maker-community/esp-signalr-example 仓库结构esp-signalr-example/ ├── main/ # ESP32 C客户端代码 │ ├── main.cpp # 主程序WiFi连接、SignalR初始化 │ └── CMakeLists.txt # ESP-IDF构建配置 ├── signalr-server/ # .NET C# 服务端代码 │ ├── Program.cs # ASP.NET Core服务器配置 │ ├── ChatHub.cs # SignalR Hub实现 │ └── signalr-server.csproj ├── docs/ # 文档 │ ├── QUICKSTART.md # 5分钟快速开始指南 │ ├── TEST_SERVER_SETUP.md # 测试服务器详细设置 │ └── TROUBLESHOOTING.md # 常见问题排查 └── README.md # 项目说明✨ 主要特性开箱即用的服务器基于ASP.NET Core和SignalR构建支持消息广播完整的连接管理和日志输出提供RESTful API用于设备控制简化的ESP32客户端使用Microsoft官方的C SignalR客户端库移植版通过menuconfig配置WiFi和服务器地址演示消息发送/接收、传感器数据上报清晰的日志输出和错误处理 快速开始示例5分钟运行# 1. 克隆仓库 git clone https://github.com/maker-community/esp-signalr-example.git cd esp-signalr-example # 2. 启动服务器需要.NET 9.0 cd signalr-server dotnet run --urls http://:5000 这个运行可以用ip访问 # 服务器运行在: http://0.0.0.0:5000/chatHub # 3. 配置并烧录ESP32 cd ../ idf.py menuconfig # 配置WiFi SSID、密码和服务器地址 idf.py build flash monitoresp32的配置如下 运行效果服务器输出✓ Client connected: abc123 IP Address: 192.168.1.100 Total Connections: 1 [10:30:25] Received from ESP32-Device: Test message #1 from ESP32 [10:30:35] Sensor Update - Temperature: 25.50ESP32串口输出I (3520) SIGNALR_EXAMPLE: ✓✓✓ Connected to SignalR Hub! ✓✓✓ I (3525) SIGNALR_EXAMPLE: Notification: Welcome! I (14640) S 示例仓库的价值学习路径清晰从简单的连接到复杂的数据传输循序渐进可直接运行不需要依赖外部服务本地即可测试完整流程代码注释详细关键部分都有中英文注释说明易于扩展基于这个示例可以快速开发自己的应用接下来的 5.1 节将基于这个示例仓库的代码进行讲解。5.1 示例代码教学简化版说明以下代码来自开源示例仓库 esp-signalr-example经过精简突出核心概念方便理解SignalR与ESP32集成的基本原理。完整代码请参考仓库源码。5.1.1 服务端SignalR Hub基础实现这是服务端的核心代码实现了连接管理、消息广播和设备状态跟踪ChatHub.cs - Hub核心实现using Microsoft.AspNetCore.SignalR; public class ChatHub : Hub { private readonly ILoggerChatHub _logger; private static int _connectionCount 0; // 存储连接的设备信息 private static readonly Dictionarystring, DeviceInfo _connectedDevices new(); private static readonly object _devicesLock new(); public ChatHub(ILoggerChatHub logger) { _logger logger; } /// summary /// 处理来自ESP32的消息 /// /summary public async Task SendMessage(string user, string message) { _logger.LogInformation([{Time}] Received from {User}: {Message}, DateTime.Now.ToString(HH:mm:ss), user, message); // 广播到所有连接的客户端 await Clients.All.SendAsync(ReceiveMessage, user, message); } /// summary /// 处理传感器数据更新 /// /summary public async Task UpdateSensor(string sensorId, double value) { _logger.LogInformation([{Time}] Sensor Update - {SensorId}: {Value:F2}, DateTime.Now.ToString(HH:mm:ss), sensorId, value); // 广播传感器数据到所有客户端 await Clients.All.SendAsync(UpdateSensorData, sensorId, value); } /// summary /// 处理ESP32状态更新 /// /summary public async Task UpdateDeviceStatus(string deviceId, string status, int freeHeap) { _logger.LogInformation([{Time}] Device Status - {DeviceId}: {Status}, Free Heap: {FreeHeap} bytes, DateTime.Now.ToString(HH:mm:ss), deviceId, status, freeHeap); await Clients.All.SendAsync(DeviceStatusUpdate, deviceId, status, freeHeap); } /// summary /// 客户端连接时触发 /// /summary public override async Task OnConnectedAsync() { Interlocked.Increment(ref _connectionCount); var connectionId Context.ConnectionId; var httpContext Context.GetHttpContext(); var ipAddress httpContext?.Connection.RemoteIpAddress?.ToString(); var userAgent httpContext?.Request.Headers[User-Agent].ToString(); // 保存设备信息 lock (_devicesLock) { _connectedDevices[connectionId] new DeviceInfo { ConnectionId connectionId, IpAddress ipAddress, UserAgent userAgent, ConnectedAt DateTime.UtcNow }; } _logger.LogInformation(✓ Client connected: {ConnectionId}, connectionId); _logger.LogInformation( IP Address: {IpAddress}, ipAddress); _logger.LogInformation( Total Connections: {Count}, _connectionCount); await base.OnConnectedAsync(); // 发送欢迎消息ESP32通过此消息确认连接成功 await Clients.Caller.SendAsync(Notification, Welcome to SignalR Test Server!); } /// summary /// 客户端断开时触发 /// /summary public override async Task OnDisconnectedAsync(Exception? exception) { Interlocked.Decrement(ref _connectionCount); var connectionId Context.ConnectionId; // 移除设备信息 lock (_devicesLock) { _connectedDevices.Remove(connectionId); } _logger.LogInformation(✗ Client disconnected: {ConnectionId}, connectionId); if (exception ! null) { _logger.LogWarning( Disconnection reason: {Message}, exception.Message); } _logger.LogInformation( Remaining Connections: {Count}, _connectionCount); await base.OnDisconnectedAsync(exception); } } /// summary /// 设备连接信息 /// /summary public class DeviceInfo { public string ConnectionId { get; set; } ; public string? IpAddress { get; set; } public string? UserAgent { get; set; } public DateTime ConnectedAt { get; set; } }Program.cs - SignalR服务配置var builder WebApplication.CreateBuilder(args); // 添加SignalR服务 builder.Services.AddSignalR(options { options.EnableDetailedErrors true; // 开发环境启用 options.ClientTimeoutInterval TimeSpan.FromSeconds(30); // 客户端超时 options.KeepAliveInterval TimeSpan.FromSeconds(15); // 心跳间隔 }); // 添加CORS支持允许ESP32跨域连接 builder.Services.AddCors(options { options.AddDefaultPolicy(policy { policy.AllowAnyOrigin() .AllowAnyHeader() .AllowAnyMethod(); }); }); var app builder.Build(); app.UseCors(); app.MapHubChatHub(/chatHub); // 监听所有网络接口重要局域网内ESP32能访问 app.Urls.Add(http://0.0.0.0:5000); Console.WriteLine(SignalR Server: http://0.0.0.0:5000/chatHub); app.Run();关键点说明连接确认机制服务器在OnConnectedAsync中发送Notification消息ESP32收到此消息才认为连接成功消息广播使用Clients.All.SendAsync()向所有连接的客户端广播消息连接跟踪使用静态字典_connectedDevices跟踪所有连接的设备信息5.1.2 服务端设备控制API通过SignalR推送消息示例仓库提供了完整的设备控制API演示如何通过SignalR向ESP32推送各种类型的消息Program.cs - 设备控制API端点// // 设备控制 API - 用于向设备发送 CustomMessage // // 获取所有连接的设备 app.MapGet(/api/device/connections, () { return Results.Ok(ChatHub.ConnectedDevices); }) .WithName(GetConnections) .WithDescription(获取所有连接的设备列表); // 发送通知 app.MapPost(/api/device/notification, async ( NotificationRequest request, IHubContextChatHub hubContext, ILoggerProgram logger) { var message new { action notification, title request.Title ?? 通知, content request.Content ?? , emotion request.Emotion ?? bell, sound request.Sound ?? popup }; await SendCustomMessage(hubContext, logger, request.ConnectionId, message); return Results.Ok(new { success true, message Notification sent }); }) .WithDescription(发送通知到设备 (sound: popup/success/vibration/exclamation/low_battery/none)); // 发送图片 app.MapPost(/api/device/image, async ( ImageRequest request, IHubContextChatHub hubContext, ILoggerProgram logger) { var message new { action image, url request.Url }; await SendCustomMessage(hubContext, logger, request.ConnectionId, message); return Results.Ok(new { success true, message Image sent }); }) .WithDescription(发送图片URL到设备显示 (支持JPG/PNG, 最大1MB)); // 发送音频 app.MapPost(/api/device/audio, async ( AudioRequest request, IHubContextChatHub hubContext, ILoggerProgram logger) { var message new { action audio, url request.Url }; await SendCustomMessage(hubContext, logger, request.ConnectionId, message); return Results.Ok(new { success true, message Audio sent }); }) .WithDescription(发送音频URL到设备播放 (OGG格式, 最大512KB)); // 发送命令 app.MapPost(/api/device/command, async ( CommandRequest request, IHubContextChatHub hubContext, ILoggerProgram logger) { var message new { action command, command request.Command }; await SendCustomMessage(hubContext, logger, request.ConnectionId, message); return Results.Ok(new { success true, message Command sent }); }) .WithDescription(发送命令到设备 (command: reboot/wake/listen/stop)); // 显示二维码 app.MapPost(/api/device/qrcode, async ( QRCodeRequest request, IHubContextChatHub hubContext, ILoggerProgram logger) { var message new { action qrcode, content request.Content, title request.Title ?? 扫码 }; await SendCustomMessage(hubContext, logger, request.ConnectionId, message); return Results.Ok(new { success true, message QRCode sent }); }) .WithDescription(显示二维码到设备屏幕); // 辅助方法发送 CustomMessage async Task SendCustomMessage( IHubContextChatHub hubContext, ILoggerProgram logger, string? connectionId, object message) { var json JsonSerializer.Serialize(message); logger.LogInformation( Sending CustomMessage to {Target}: {Message}, string.IsNullOrEmpty(connectionId) ? ALL : connectionId, json); if (string.IsNullOrEmpty(connectionId)) { // 发送给所有连接的设备 await hubContext.Clients.All.SendAsync(CustomMessage, json); } else { // 发送给指定连接 await hubContext.Clients.Client(connectionId).SendAsync(CustomMessage, json); } } // // 请求模型 // record NotificationRequest { public string? ConnectionId { get; init; } public string? Title { get; init; } public string Content { get; init; } ; public string? Emotion { get; init; } public string? Sound { get; init; } } record ImageRequest { public string? ConnectionId { get; init; } public string Url { get; init; } ; } record AudioRequest { public string? ConnectionId { get; init; } public string Url { get; init; } ; } record CommandRequest { public string? ConnectionId { get; init; } public string Command { get; init; } ; } record QRCodeRequest { public string? ConnectionId { get; init; } public string Content { get; init; } ; public string? Title { get; init; } }关键点说明IHubContext注入使用IHubContextChatHub在非Hub类中发送SignalR消息消息格式使用JSON格式的CustomMessage事件包含action字段标识消息类型定向推送Clients.All.SendAsync()- 广播给所有连接的设备Clients.Client(connectionId).SendAsync()- 发送给指定设备Clients.Group(groupName).SendAsync()- 发送给群组如Users:{userId}RESTful API设计提供HTTP端点控制设备便于其他服务调用服务端的接口图片如下可以直接操作测试5.1.3 客户端ESP32连接SignalR并接收消息这是ESP32端的核心代码演示如何连接SignalR Hub并接收各种类型的消息main.cpp - SignalR连接与消息处理#include stdio.h #include memory #include freertos/FreeRTOS.h #include freertos/task.h #include esp_system.h #include esp_log.h #include nvs_flash.h #include hub_connection_builder.h #include esp32_websocket_client.h #include esp32_http_client.h // // 配置项通过menuconfig设置 // #define WIFI_SSID CONFIG_EXAMPLE_WIFI_SSID #define WIFI_PASSWORD CONFIG_EXAMPLE_WIFI_PASSWORD #define SIGNALR_HUB_URL CONFIG_EXAMPLE_SIGNALR_HUB_URL static const char* TAG SIGNALR_EXAMPLE; // SignalR连接对象 static std::unique_ptrsignalr::hub_connection g_connection; static bool g_is_connected false; // // 消息处理器 // /** * 处理服务器发送的消息 */ static void on_receive_message(const std::vectorsignalr::value args) { ESP_LOGI(TAG, ); ESP_LOGI(TAG, Message received from server:); if (args.size() 2) { std::string user args[0].as_string(); std::string message args[1].as_string(); ESP_LOGI(TAG, From: %s, user.c_str()); ESP_LOGI(TAG, Text: %s, message.c_str()); } else if (args.size() 1) { ESP_LOGI(TAG, Message: %s, args[0].as_string().c_str()); }

更多文章