安林 發表於 2026-2-1 15:34:00

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

<h2 id="前言">前言</h2>
<p>这段时间迷上了手搓Esp32的小智聊天机器人,也用.NET为小智AI开发了一些MCP转接平台和MCP服务。小智ESP32本身就具备MCP能力,可以调用本地MCP工具和服务端MCP工具,并将结果返回给设备,这个功能一直都有。</p>
<p>如果你有手搓Esp32的硬件玩具打算,可以关注我的B站账号(绿荫阿广)https://space.bilibili.com/25228512<br>
带你手搓玩具。</p>
<p>小智原本这套架构有个局限性:<strong>MCP工具执行完之后,只能同步返回结果或者通过异步邮件通知,设备无法被动接收服务端的消息</strong>。比如我想让服务端主动给设备推送一张图片、播放一段语音、或者发送一个文本通知,在之前的架构下是做不到的。</p>
<p>所以我就决定改造小智客户端,集成SignalR实时通信框架。这次改造的核心价值是:<strong>通过SignalR消息通道,让设备可以接收各种类型的消息(声音、图片、文本通知),服务端的MCP工具执行成功后,可以根据用户ID推送数据到对应的用户通道</strong>。</p>
<p>整个改造涉及SignalR C++客户端的集成、JWT Token认证、扫码登录(基于ESP32本地MCP工具实现)、以及服务端消息推送逻辑。客户端代码都是C++实现的,不过现在AI辅助编程很强大,帮我节省了大量时间。</p>
<p><img src="https://img2024.cnblogs.com/blog/1690009/202602/1690009-20260201121657114-327485878.png" alt="img" loading="lazy"></p>
<h2 id="问题解答">问题解答</h2>
<p><strong>Q: 为什么选择SignalR而不是直接用WebSocket?</strong></p>
<p>A: 起初我确实考虑过直接用WebSocket,但SignalR提供了很多开箱即用的功能:</p>
<ul>
<li><strong>Hub抽象</strong>:服务端可以轻松实现群组管理,按用户ID推送消息,比如<code>Clients.Group($"Users:{userId}").SendAsync("Notification", message)</code></li>
<li><strong>消息路由</strong>:不需要自己写消息分发逻辑,SignalR的Hub方法调用和事件推送已经很完善了</li>
<li><strong>类型化调用</strong>:相比原始WebSocket的字符串消息,SignalR提供了类似RPC的调用体验,代码更清晰</li>
</ul>
<p>虽然ESP32没有现成的SignalR库,但我找到了微软官方的C++ SignalR客户端(半成品),将它与ESP32的WebSocket组件整合后,就能用上SignalR的这些特性了。至于SignalR自带的重连机制,我没用,小智有自己的循环重连逻辑,更可控一些。</p>
<p><strong>Q: 改造的核心价值是什么?解决了什么问题?</strong></p>
<p>A: 改造前,ESP32的MCP工具调用完成后,只能通过两种方式通知:</p>
<ol>
<li><strong>同步返回</strong>:工具执行结果直接返回给调用方</li>
<li><strong>异步邮件</strong>:通过邮件发送执行结果</li>
</ol>
<p>这两种方式都无法满足实时推送的需求。比如我想让服务端在生图完成后立即推送图片给设备显示,或者播放一段语音提示,之前的架构做不到。</p>
<p>改造后,通过SignalR建立了一条<strong>服务端到设备的实时消息通道</strong>:</p>
<ul>
<li>服务端的MCP工具执行成功后,可以调用<code>_hubContext.Clients.Group($"Users:{userId}").SendAsync("ShowImage", imageData)</code>将图片推送给设备</li>
<li>设备通过SignalR的事件监听接收消息:<code>connection-&gt;on("ShowImage", [](const std::vector&lt;signalr::value&gt;&amp; args) { ... })</code></li>
<li>支持推送任意类型的数据:文本、图片(Base64)、语音URL、JSON通知等</li>
</ul>
<p>这才是这次改造的核心价值:<strong>让设备具备被动接收服务端消息的能力</strong>,而不仅仅是主动调用和同步返回。</p>
<p><strong>Q: 扫码登录是怎么实现的?</strong></p>
<p>A: 扫码登录功能是基于ESP32本地MCP工具实现的,这是小智的固有功能,我只是进行了拓展:</p>
<ol>
<li>设备启动时检查是否有JWT Token</li>
<li>如果没有Token,调用本地MCP工具<code>display_qrcode</code>在屏幕上显示二维码</li>
<li>二维码内容包含设备ID和服务端地址:<code>https://mcp-server.com/device-login?deviceId=xxx</code></li>
<li>用户用手机扫码,完成授权。</li>
<li>设备获取Token后保存到NVS(Non-Volatile Storage),下次启动直接使用</li>
</ol>
<p>这样就实现了设备的快速认证,用户体验很好。扫码认证的服务端是使用开源的keycloak做的,对接了设备认证类型。</p>
<p><img src="https://img2024.cnblogs.com/blog/1690009/202602/1690009-20260201121907430-924035727.png" alt="img" loading="lazy"></p>
<h2 id="名词解释">名词解释</h2>
<h3 id="核心概念">核心概念</h3>
<ul>
<li>
<p><strong>SignalR</strong>:微软提供的实时通信框架,封装了WebSocket、Server‑Sent Events和长轮询等传输方式,支持Hub模型、自动重连与消息序列化。适合实现双向、低延迟的实时消息系统。将它移植到嵌入式设备时需考虑客户端实现的体积、内存消耗与线程模型。</p>
</li>
<li>
<p><strong>Hub(集线器)</strong>:SignalR的核心抽象,类似于MVC中的Controller。服务端通过Hub定义方法供客户端调用,客户端也可以注册事件监听服务端推送。例如<code>ChatHub.SendMessage(user, message)</code>就是一个典型的Hub方法。</p>
</li>
<li>
<p><strong>MCP(Model Context Protocol)</strong>:一种基于JSON-RPC 2.0的协议,用于定义客户端和服务端之间的工具调用规范。在IoT场景中,设备可以作为MCP Server暴露能力(如重启、显示图片),而云端服务作为MCP Client调用这些能力。</p>
</li>
<li>
<p><strong>JSON-RPC 2.0</strong>:一种轻量级的远程过程调用协议,使用JSON编码。MCP协议基于此标准,定义了<code>initialize</code>、<code>tools/list</code>、<code>tools/call</code>等方法。每个请求必须包含<code>jsonrpc: "2.0"</code>、<code>method</code>、<code>id</code>字段。</p>
</li>
</ul>
<h3 id="esp32相关">ESP32相关</h3>
<ul>
<li>
<p><strong>FreeRTOS</strong>:一个开源、轻量级的实时操作系统内核,常用于微控制器平台(如ESP32)。提供任务调度、优先级、互斥锁、信号量、队列、软件定时器等实时特性,便于在资源受限设备上实现并发与确定性行为。使用时需注意堆栈大小、中断安全和任务优先级设计。</p>
</li>
<li>
<p><strong>ESP32 PSRAM</strong>:ESP32可选的外部伪静态RAM(Pseudo-SRAM),用于扩展设备可用内存(常见4MB/8MB/16MB)。适合存放大对象、图像缓存、网络缓冲和动态分配数据。在ESP-IDF中需启用并正确配置,分配时也可使用不同的堆区域(如<code>heap_caps_malloc(size, MALLOC_CAP_SPIRAM)</code>)来控制放置与性能/DMA限制。</p>
</li>
<li>
<p><strong>WebSocket</strong>:一种基于TCP的全双工通信协议,通过HTTP握手升级建立连接。SignalR默认优先使用WebSocket作为传输层,在ESP32上通过<code>esp_websocket_client</code>组件实现。需要注意的是ESP32的WebSocket客户端不支持自动重连,需要在应用层实现。</p>
</li>
</ul>
<h3 id="认证相关">认证相关</h3>
<ul>
<li>
<p><strong>Bearer Token</strong>:一种HTTP认证方案,将Token放在Authorization头中:<code>Authorization: Bearer &lt;token&gt;</code>。在SignalR中,通常将Token作为查询参数传递:<code>/hub?access_token=YOUR_TOKEN</code></p>
</li>
<li>
<p><strong>JWT(JSON Web Token)</strong>:一种开放标准(RFC 7512),用于在各方之间安全地传输信息。在Verdure MCP中,使用Keycloak签发的JWT进行用户认证,Token中包含用户ID、角色、过期时间等Claim信息。</p>
</li>
<li>
<p><strong>API Token</strong>:一种简单的认证方式,后续连接时携带此Token验证身份。Verdure MCP同时支持API Token和JWT两种方式。</p>
</li>
</ul>
<p><img src="https://img2024.cnblogs.com/blog/1690009/202602/1690009-20260201152548041-1540521880.png" alt="img" loading="lazy"></p>
<h2 id="核心技术架构">核心技术架构</h2>
<p>整个改造的架构可以用一张图说明:</p>
<pre><code>┌──────────────────────┐                        ┌──────────────────────┐
│   .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)      ││ - 显示通知   ││
│└────────────────┘│                        │└────────────────┘│
└──────────────────────┘                        └──────────────────────┘
</code></pre>
<p>关键流程:</p>
<ol>
<li><strong>扫码登录</strong>:设备启动后,如果没有Token,调用本地MCP工具显示二维码,用户扫码后获取JWT Token</li>
<li><strong>建立连接</strong>:携带JWT Token连接SignalR Hub,服务端验证后加入用户群组<code>Users:{userId}</code></li>
<li><strong>消息推送</strong>:服务端MCP工具执行完成后,通过SignalR将结果推送给设备
<ul>
<li><code>_hubContext.Clients.Group($"Users:{userId}").SendAsync("ShowImage", imageData)</code></li>
<li>设备监听事件并处理:<code>connection-&gt;on("ShowImage", handler)</code></li>
</ul>
</li>
</ol>
<p>这套架构的核心价值就是<strong>让服务端可以主动推送消息给设备</strong>,而不仅仅是等待设备轮询或同步返回。</p>
<h2 id="开发环境准备">开发环境准备</h2>
<h3 id="esp32开发环境vs-code方式">ESP32开发环境(VS Code方式)</h3>
<p>最简单的方式是使用VS Code的ESP-IDF插件:</p>
<ol>
<li>
<p><strong>安装VS Code和插件</strong></p>
<ul>
<li>下载安装 Visual Studio Code</li>
<li>安装扩展:<code>Espressif IDF</code> (搜索 <code>esp-idf</code>)</li>
</ul>
</li>
<li>
<p><strong>配置ESP-IDF</strong></p>
<ul>
<li>按<code>F1</code>打开命令面板,输入 <code>ESP-IDF: Configure ESP-IDF Extension</code></li>
<li>选择 <code>Express</code> 快速配置</li>
<li>选择ESP-IDF版本(推荐v5.1或更高)</li>
<li>等待安装完成(会自动下载工具链、Python环境等)</li>
</ul>
</li>
<li>
<p><strong>创建/打开项目</strong></p>
<ul>
<li><code>F1</code> → <code>ESP-IDF: Show Examples Projects</code></li>
<li>或直接打开 esp-signalr-example 项目文件夹</li>
</ul>
</li>
<li>
<p><strong>编译和烧录</strong></p>
<ul>
<li>点击底部状态栏的 <code>Build</code>、<code>Flash</code>、<code>Monitor</code> 按钮</li>
<li>或按快捷键:<code>Ctrl+E B</code>(编译)、<code>Ctrl+E F</code>(烧录)</li>
</ul>
</li>
</ol>
<p>这种方式比命令行简单很多,适合.NET开发者快速上手ESP32开发。</p>
<p><img src="https://img2024.cnblogs.com/blog/1690009/202602/1690009-20260201122055040-2075015821.png" alt="img" loading="lazy"></p>
<h3 id="net开发环境">.NET开发环境</h3>
<p>服务端使用.NET 10开发:</p>
<pre><code class="language-bash"># Windows: 下载安装器 https://dotnet.microsoft.com/download/dotnet/10.0

# 验证安装
dotnet --version# 应该输出 10.0.x

</code></pre>
<h2 id="核心代码实现">核心代码实现</h2>
<p>本章节将代码分为<strong>示例代码</strong>和<strong>实际整合代码</strong>两个部分进行讲解:</p>
<ul>
<li><strong>示例代码</strong>:用于理解核心概念的简化版本,便于学习和快速上手</li>
<li><strong>实际整合代码</strong>:生产环境中的完整实现,包含完善的错误处理、状态管理等</li>
</ul>
<h3 id="关于示例仓库">关于示例仓库</h3>
<p>为了帮助开发者快速上手ESP32的SignalR集成,我创建了一个完整的示例仓库:</p>
<p><strong>🔗 仓库地址</strong>:https://github.com/maker-community/esp-signalr-example</p>
<p><strong>📦 仓库结构</strong>:</p>
<pre><code>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               # 项目说明
</code></pre>
<p><strong>✨ 主要特性</strong>:</p>
<ol>
<li>
<p><strong>开箱即用的服务器</strong>:</p>
<ul>
<li>基于ASP.NET Core和SignalR构建</li>
<li>支持消息广播</li>
<li>完整的连接管理和日志输出</li>
<li>提供RESTful API用于设备控制</li>
</ul>
</li>
<li>
<p><strong>简化的ESP32客户端</strong>:</p>
<ul>
<li>使用Microsoft官方的C++ SignalR客户端库移植版</li>
<li>通过menuconfig配置WiFi和服务器地址</li>
<li>演示消息发送/接收、传感器数据上报</li>
<li>清晰的日志输出和错误处理</li>
</ul>
</li>
</ol>
<p><strong>🚀 快速开始示例</strong>(5分钟运行):</p>
<pre><code class="language-bash"># 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 monitor
</code></pre>
<p>esp32的配置如下:</p>
<p><img src="https://img2024.cnblogs.com/blog/1690009/202602/1690009-20260201134357961-636085039.png" alt="img" loading="lazy"></p>
<p><strong>📊 运行效果</strong>:</p>
<p>服务器输出:<br>
<img src="https://img2024.cnblogs.com/blog/1690009/202602/1690009-20260201135020573-402562452.png" alt="img" loading="lazy"></p>
<pre><code>✓ Client connected: abc123
IP Address: 192.168.1.100
Total Connections: 1

Received from ESP32-Device: Test message #1 from ESP32
Sensor Update - Temperature: 25.50
</code></pre>
<p>ESP32串口输出:<br>
<img src="https://img2024.cnblogs.com/blog/1690009/202602/1690009-20260201134940520-565577362.png" alt="img" loading="lazy"></p>
<pre><code>I (3520) SIGNALR_EXAMPLE: ✓✓✓ Connected to SignalR Hub! ✓✓✓
I (3525) SIGNALR_EXAMPLE: 🔔 Notification: Welcome!
I (14640) S
</code></pre>
<p><strong>🎯 示例仓库的价值</strong>:</p>
<ul>
<li><strong>学习路径清晰</strong>:从简单的连接到复杂的数据传输,循序渐进</li>
<li><strong>可直接运行</strong>:不需要依赖外部服务,本地即可测试完整流程</li>
<li><strong>代码注释详细</strong>:关键部分都有中英文注释说明</li>
<li><strong>易于扩展</strong>:基于这个示例可以快速开发自己的应用</li>
</ul>
<p>接下来的 5.1 节将基于这个示例仓库的代码进行讲解。</p>
<h3 id="51-示例代码教学简化版">5.1 示例代码(教学简化版)</h3>
<blockquote>
<p><strong>说明</strong>:以下代码来自开源示例仓库 esp-signalr-example,经过精简突出核心概念,方便理解SignalR与ESP32集成的基本原理。完整代码请参考仓库源码。</p>
</blockquote>
<h4 id="511-服务端signalr-hub基础实现">5.1.1 服务端:SignalR Hub基础实现</h4>
<p>这是服务端的核心代码,实现了连接管理、消息广播和设备状态跟踪:</p>
<p><strong>ChatHub.cs - Hub核心实现</strong>:</p>
<pre><code class="language-csharp">using Microsoft.AspNetCore.SignalR;

public class ChatHub : Hub
{
    private readonly ILogger&lt;ChatHub&gt; _logger;
    private static int _connectionCount = 0;
   
    // 存储连接的设备信息
    private static readonly Dictionary&lt;string, DeviceInfo&gt; _connectedDevices = new();
    private static readonly object _devicesLock = new();

    public ChatHub(ILogger&lt;ChatHub&gt; logger)
    {
      _logger = logger;
    }

    /// &lt;summary&gt;
    /// 处理来自ESP32的消息
    /// &lt;/summary&gt;
    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);
    }

    /// &lt;summary&gt;
    /// 处理传感器数据更新
    /// &lt;/summary&gt;
    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);
    }

    /// &lt;summary&gt;
    /// 处理ESP32状态更新
    /// &lt;/summary&gt;
    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);
    }

    /// &lt;summary&gt;
    /// 客户端连接时触发
    /// &lt;/summary&gt;
    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 = 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!");
    }

    /// &lt;summary&gt;
    /// 客户端断开时触发
    /// &lt;/summary&gt;
    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);
    }
}

/// &lt;summary&gt;
/// 设备连接信息
/// &lt;/summary&gt;
public class DeviceInfo
{
    public string ConnectionId { get; set; } = "";
    public string? IpAddress { get; set; }
    public string? UserAgent { get; set; }
    public DateTime ConnectedAt { get; set; }
}
</code></pre>
<p><strong>Program.cs - SignalR服务配置</strong>:</p>
<pre><code class="language-csharp">var builder = WebApplication.CreateBuilder(args);

// 添加SignalR服务
builder.Services.AddSignalR(options =&gt;
{
    options.EnableDetailedErrors = true;// 开发环境启用
    options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);// 客户端超时
    options.KeepAliveInterval = TimeSpan.FromSeconds(15);// 心跳间隔
});

// 添加CORS支持(允许ESP32跨域连接)
builder.Services.AddCors(options =&gt;
{
    options.AddDefaultPolicy(policy =&gt;
    {
      policy.AllowAnyOrigin()
            .AllowAnyHeader()
            .AllowAnyMethod();
    });
});

var app = builder.Build();

app.UseCors();
app.MapHub&lt;ChatHub&gt;("/chatHub");

// 监听所有网络接口(重要:局域网内ESP32能访问)
app.Urls.Add("http://0.0.0.0:5000");

Console.WriteLine("SignalR Server: http://0.0.0.0:5000/chatHub");
app.Run();
</code></pre>
<p><strong>关键点说明</strong>:</p>
<ol>
<li><strong>连接确认机制</strong>:服务器在 <code>OnConnectedAsync</code> 中发送 <code>Notification</code> 消息,ESP32收到此消息才认为连接成功</li>
<li><strong>消息广播</strong>:使用 <code>Clients.All.SendAsync()</code> 向所有连接的客户端广播消息</li>
<li><strong>连接跟踪</strong>:使用静态字典 <code>_connectedDevices</code> 跟踪所有连接的设备信息</li>
</ol>
<h4 id="512-服务端设备控制api通过signalr推送消息">5.1.2 服务端:设备控制API(通过SignalR推送消息)</h4>
<p>示例仓库提供了完整的设备控制API,演示如何通过SignalR向ESP32推送各种类型的消息:</p>
<p><strong>Program.cs - 设备控制API端点</strong>:</p>
<pre><code class="language-csharp">// ============================================================================
// 设备控制 API - 用于向设备发送 CustomMessage
// ============================================================================

// 获取所有连接的设备
app.MapGet("/api/device/connections", () =&gt;
{
    return Results.Ok(ChatHub.ConnectedDevices);
})
.WithName("GetConnections")
.WithDescription("获取所有连接的设备列表");

// 发送通知
app.MapPost("/api/device/notification", async (
    NotificationRequest request,
    IHubContext&lt;ChatHub&gt; hubContext,
    ILogger&lt;Program&gt; logger) =&gt;
{
    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,
    IHubContext&lt;ChatHub&gt; hubContext,
    ILogger&lt;Program&gt; logger) =&gt;
{
    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,
    IHubContext&lt;ChatHub&gt; hubContext,
    ILogger&lt;Program&gt; logger) =&gt;
{
    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,
    IHubContext&lt;ChatHub&gt; hubContext,
    ILogger&lt;Program&gt; logger) =&gt;
{
    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,
    IHubContext&lt;ChatHub&gt; hubContext,
    ILogger&lt;Program&gt; logger) =&gt;
{
    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(
    IHubContext&lt;ChatHub&gt; hubContext,
    ILogger&lt;Program&gt; 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; }
}
</code></pre>
<p><strong>关键点说明</strong>:</p>
<ol>
<li><strong>IHubContext注入</strong>:使用 <code>IHubContext&lt;ChatHub&gt;</code> 在非Hub类中发送SignalR消息</li>
<li><strong>消息格式</strong>:使用JSON格式的 <code>CustomMessage</code> 事件,包含 <code>action</code> 字段标识消息类型</li>
<li><strong>定向推送</strong>:
<ul>
<li><code>Clients.All.SendAsync()</code> - 广播给所有连接的设备</li>
<li><code>Clients.Client(connectionId).SendAsync()</code> - 发送给指定设备</li>
<li><code>Clients.Group(groupName).SendAsync()</code> - 发送给群组(如 <code>Users:{userId}</code>)</li>
</ul>
</li>
<li><strong>RESTful API设计</strong>:提供HTTP端点控制设备,便于其他服务调用</li>
</ol>
<p>服务端的接口图片如下可以直接操作测试:</p>
<p><img src="https://img2024.cnblogs.com/blog/1690009/202602/1690009-20260201134534230-1496642342.png" alt="img" loading="lazy"></p>
<h4 id="513-客户端esp32连接signalr并接收消息">5.1.3 客户端(ESP32):连接SignalR并接收消息</h4>
<p>这是ESP32端的核心代码,演示如何连接SignalR Hub并接收各种类型的消息:</p>
<p><strong>main.cpp - SignalR连接与消息处理</strong>:</p>
<pre><code class="language-cpp">#include &lt;stdio.h&gt;
#include &lt;memory&gt;
#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_PASSWORDCONFIG_EXAMPLE_WIFI_PASSWORD
#define SIGNALR_HUB_URL CONFIG_EXAMPLE_SIGNALR_HUB_URL

static const char* TAG = "SIGNALR_EXAMPLE";

// SignalR连接对象
static std::unique_ptr&lt;signalr::hub_connection&gt; g_connection;
static bool g_is_connected = false;

// =============================================================================
// 消息处理器
// =============================================================================

/**
* 处理服务器发送的消息
*/
static void on_receive_message(const std::vector&lt;signalr::value&gt;&amp; args)
{
    ESP_LOGI(TAG, "==============================================");
    ESP_LOGI(TAG, "📩 Message received from server:");
   
    if (args.size() &gt;= 2) {
      std::string user = args.as_string();
      std::string message = args.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.as_string().c_str());
    }
   
    ESP_LOGI(TAG, "==============================================");
}

/**
* 处理通知消息(连接确认)
*/
static void on_notification(const std::vector&lt;signalr::value&gt;&amp; args)
{
    if (args.empty()) return;
   
    std::string notification = args.as_string();
    ESP_LOGI(TAG, "🔔 Notification: %s", notification.c_str());
   
    // 通过Notification消息确认连接成功
    if (!g_is_connected) {
      g_is_connected = true;
      ESP_LOGI(TAG, "==============================================");
      ESP_LOGI(TAG, "✓✓✓ Connected to SignalR Hub! ✓✓✓");
      ESP_LOGI(TAG, "==============================================");
    }
}

/**
* 处理传感器数据更新
*/
static void on_sensor_update(const std::vector&lt;signalr::value&gt;&amp; args)
{
    if (args.size() &lt; 2) return;
   
    std::string sensor_id = args.as_string();
    double value = args.as_double();
   
    ESP_LOGI(TAG, "📊 Sensor Update: %s = %.2f", sensor_id.c_str(), value);
}

/**
* 处理设备状态更新
*/
static void on_device_status(const std::vector&lt;signalr::value&gt;&amp; args)
{
    if (args.size() &lt; 3) return;
   
    std::string device_id = args.as_string();
    std::string status = args.as_string();
    int free_heap = static_cast&lt;int&gt;(args.as_double());
   
    ESP_LOGI(TAG, "📱 Device Status: %s - %s (Free Heap: %d bytes)",
             device_id.c_str(), status.c_str(), free_heap);
}

// =============================================================================
// SignalR连接管理
// =============================================================================

/**
* 初始化SignalR连接
*/
static void init_signalr(void)
{
    ESP_LOGI(TAG, "Initializing SignalR connection to: %s", SIGNALR_HUB_URL);

    try {
      // 创建hub_connection(使用make_unique)
      g_connection = std::make_unique&lt;signalr::hub_connection&gt;(
            signalr::hub_connection_builder::create(SIGNALR_HUB_URL)
                .with_websocket_factory([](const signalr::signalr_client_config&amp; config) {
                  return std::make_shared&lt;signalr::esp32_websocket_client&gt;(config);
                })
                .with_http_client_factory([](const signalr::signalr_client_config&amp; config) {
                  return std::make_shared&lt;signalr::esp32_http_client&gt;(config);
                })
                .with_automatic_reconnect()// 启用自动重连
                .skip_negotiation(true)      // 跳过协商,直接WebSocket
                .build());

      ESP_LOGI(TAG, "✓ SignalR connection object created");
      
    } catch (const std::exception&amp; e) {
      ESP_LOGE(TAG, "Failed to create SignalR connection: %s", e.what());
    }
}

/**
* 注册消息处理器
*/
static void setup_message_handlers(void)
{
    if (!g_connection) {
      ESP_LOGE(TAG, "Connection not initialized");
      return;
    }

    // 注册 "ReceiveMessage" 事件
    g_connection-&gt;on("ReceiveMessage", on_receive_message);
    ESP_LOGI(TAG, "✓ Registered handler: ReceiveMessage");

    // 注册 "Notification" 事件(用于连接确认)
    g_connection-&gt;on("Notification", on_notification);
    ESP_LOGI(TAG, "✓ Registered handler: Notification");

    // 注册 "UpdateSensorData" 事件
    g_connection-&gt;on("UpdateSensorData", on_sensor_update);
    ESP_LOGI(TAG, "✓ Registered handler: UpdateSensorData");

    // 注册 "DeviceStatusUpdate" 事件
    g_connection-&gt;on("DeviceStatusUpdate", on_device_status);
    ESP_LOGI(TAG, "✓ Registered handler: DeviceStatusUpdate");
}

/**
* 启动SignalR连接
*/
static void start_signalr_connection(void)
{
    if (!g_connection) {
      ESP_LOGE(TAG, "Connection not initialized");
      return;
    }
   
    ESP_LOGI(TAG, "Starting SignalR connection...");

    try {
      // 启动连接(异步)
      g_connection-&gt;start([](std::exception_ptr exception) {
            if (exception) {
                ESP_LOGE(TAG, "Connection failed in callback");
            } else {
                ESP_LOGI(TAG, "Connection started successfully");
            }
      });
      
      ESP_LOGI(TAG, "Waiting for Notification message to confirm connection...");

    } catch (const std::exception&amp; e) {
      ESP_LOGE(TAG, "Exception starting connection: %s", e.what());
    }
}

// =============================================================================
// 测试任务:定期发送消息
// =============================================================================

static void signalr_test_task(void* param)
{
    int message_count = 1;
   
    while (true) {
      // 等待10秒
      vTaskDelay(pdMS_TO_TICKS(10000));
      
      // 检查连接状态
      if (!g_connection || !g_is_connected) {
            ESP_LOGW(TAG, "Not connected, skipping message send");
            continue;
      }
      
      // 发送消息到服务器
      std::string message = "Test message #" + std::to_string(message_count++) + " from ESP32";
      
      ESP_LOGI(TAG, "📤 Sending message...");
      ESP_LOGI(TAG, "   User: ESP32-Device");
      ESP_LOGI(TAG, "   Message: %s", message.c_str());
      
      try {
            std::vector&lt;signalr::value&gt; args;
            args.push_back(signalr::value("ESP32-Device"));
            args.push_back(signalr::value(message));
            
            // 调用服务器的 SendMessage 方法
            g_connection-&gt;invoke("SendMessage", args,
                [](const signalr::value&amp; result, std::exception_ptr exception) {
                  if (exception) {
                        ESP_LOGE(TAG, "✗ Failed to send message");
                  } else {
                        ESP_LOGI(TAG, "✓ Message sent successfully!");
                  }
                });
               
      } catch (const std::exception&amp; e) {
            ESP_LOGE(TAG, "Exception sending message: %s", e.what());
      }
    }
}

// =============================================================================
// 主程序
// =============================================================================

extern "C" void app_main(void)
{
    ESP_LOGI(TAG, "========================================");
    ESP_LOGI(TAG, " ESP32 SignalR Client Test Example");
    ESP_LOGI(TAG, "========================================");
   
    // 1. 初始化WiFi(省略WiFi连接代码,参考完整示例)
    // wifi_init_sta();
   
    // 2. 初始化SignalR连接对象
    ESP_LOGI(TAG, "Step 1: Initializing SignalR...");
    init_signalr();
   
    // 3. 注册消息处理器
    ESP_LOGI(TAG, "Step 2: Setting up message handlers...");
    setup_message_handlers();
   
    // 4. 启动连接
    ESP_LOGI(TAG, "Step 3: Starting connection...");
    start_signalr_connection();
   
    // 5. 创建测试任务(定期发送消息)
    ESP_LOGI(TAG, "Step 4: Creating test task...");
    xTaskCreate(signalr_test_task, "signalr_test", 8192, NULL, 5, NULL);
   
    ESP_LOGI(TAG, "Setup complete. Check logs for connection status.");
}
</code></pre>
<p><strong>关键点说明</strong>:</p>
<ol>
<li><strong>连接创建</strong>:使用 <code>hub_connection_builder</code> 构建连接,配置WebSocket客户端工厂</li>
<li><strong>跳过协商</strong>:<code>skip_negotiation(true)</code> 直接使用WebSocket,提高连接速度</li>
<li><strong>消息处理器注册</strong>:使用 <code>connection-&gt;on("EventName", handler)</code> 注册事件监听器</li>
<li><strong>连接确认</strong>:通过接收 <code>Notification</code> 消息判断连接成功(服务器在 <code>OnConnectedAsync</code> 中发送)</li>
<li><strong>调用服务器方法</strong>:使用 <code>invoke()</code> 调用Hub方法,如 <code>SendMessage</code></li>
</ol>
<p><strong>完整运行流程</strong>:</p>
<pre><code>1. WiFi连接成功
   ↓
2. 创建SignalR连接对象
   ↓
3. 注册消息处理器(ReceiveMessage、Notification等)
   ↓
4. 调用 connection-&gt;start() 启动连接
   ↓
5. 等待服务器发送 Notification 消息
   ↓
6. 收到 Notification,标记连接成功
   ↓
7. 定期调用 invoke("SendMessage") 发送消息
   ↓
8. 接收服务器广播的消息,触发对应处理器
</code></pre>
<p><strong>示例输出</strong>:</p>
<pre><code>I (3480) SIGNALR_EXAMPLE: ✓ Registered handler: ReceiveMessage
I (3485) SIGNALR_EXAMPLE: ✓ Registered handler: Notification
I (3490) SIGNALR_EXAMPLE: Starting SignalR connection...
I (4520) SIGNALR_EXAMPLE: ==============================================
I (4520) SIGNALR_EXAMPLE: ✓✓✓ Connected to SignalR Hub! ✓✓✓
I (4525) SIGNALR_EXAMPLE: ==============================================
I (4530) SIGNALR_EXAMPLE: 🔔 Notification: Welcome to SignalR!
I (14530) SIGNALR_EXAMPLE: 📤 Sending message...
I (14640) SIGNALR_EXAMPLE: ✓ Message sent successfully!
I (14650) SIGNALR_EXAMPLE: 📩 Message received from server:
I (14655) SIGNALR_EXAMPLE:    From: ESP32-Device
I (14660) SIGNALR_EXAMPLE:    Text: Test message #1 from ESP32
</code></pre>
<h3 id="52-实际整合代码生产环境完整实现">5.2 实际整合代码(生产环境完整实现)</h3>
<blockquote>
<p><strong>说明</strong>:以下代码来自小智AI项目的实际生产代码,包含了完整的错误处理、状态管理、JWT认证和自动重连机制。</p>
</blockquote>
<p>实际项目代码分为三个主要仓库:</p>
<h4 id="521-小智esp32设备代码">5.2.1 小智ESP32设备代码</h4>
<p><strong>仓库地址</strong>:</p>
<ul>
<li>主仓库:https://github.com/maker-community/xiaozhi-esp32</li>
<li>SignalR集成分支:<code>signalr</code> 和 <code>signalr-update-audio</code></li>
<li>完整示例工程:esp-signalr-example</li>
</ul>
<blockquote>
<p><strong>注意</strong>:SignalR功能主要在 <code>signalr</code> 和 <code>signalr-update-audio</code> 两个分支中实现,这两个分支都是SignalR集成相关的开发分支。</p>
</blockquote>
<p><strong>核心文件</strong>:</p>
<ul>
<li><code>main/signalr_client.cc</code> / <code>main/signalr_client.h</code> - <strong>SignalR客户端核心实现</strong></li>
<li><code>main/application.cc</code> / <code>main/application.h</code> - 主应用程序逻辑和状态管理</li>
<li><code>main/protocols/websocket_protocol.cc</code> - WebSocket协议实现</li>
<li><code>main/protocols/mqtt_protocol.cc</code> - MQTT协议实现</li>
<li><code>main/mcp_server.cc</code> - MCP服务器实现</li>
</ul>
<p><strong>实际实现特点</strong>:</p>
<p>与示例代码相比,生产环境实现增加了:</p>
<ol>
<li>
<p><strong>完整的生命周期管理</strong></p>
<ul>
<li>连接建立、断开重连、资源清理</li>
<li>设备状态机管理(空闲、连接中、监听、说话等)</li>
</ul>
</li>
<li>
<p><strong>协议版本支持</strong></p>
<ul>
<li>支持WebSocket和MQTT两种传输协议</li>
<li>协议层抽象,易于扩展新协议</li>
</ul>
</li>
<li>
<p><strong>音频流处理</strong></p>
<ul>
<li>实时音频数据的编码、传输和接收</li>
<li><strong>音频分块传输</strong>(重要!)- 解决大数据传输导致连接断开的问题</li>
<li>支持Opus编解码</li>
</ul>
</li>
<li>
<p><strong>MCP工具集成</strong></p>
<ul>
<li>完整的MCP Server实现</li>
<li>工具注册、调用和响应机制</li>
<li>支持异步工具执行</li>
</ul>
</li>
<li>
<p><strong>SignalR客户端封装</strong></p>
<ul>
<li>完整的连接生命周期管理</li>
<li>JWT Token认证</li>
<li>自动重连机制(指数退避)</li>
<li>设备注册和心跳保持</li>
<li>自定义消息处理</li>
</ul>
</li>
</ol>
<h5 id="signalr客户端核心实现-signalr_clientcc">SignalR客户端核心实现 (signalr_client.cc)</h5>
<p>这是整个SignalR集成的核心代码,封装了所有与SignalR通信相关的逻辑。</p>
<p><strong>完整代码</strong>:signalr_client.cc (850行)</p>
<p><strong>关键实现要点</strong>:</p>
<p><strong>1. 单例模式管理</strong> - 全局唯一实例</p>
<pre><code class="language-cpp">SignalRClient&amp; SignalRClient::GetInstance() {
    static SignalRClient instance;
    return instance;
}
</code></pre>
<p><strong>2. JWT Token认证</strong> - 通过Query String传递</p>
<pre><code class="language-cpp">bool SignalRClient::Initialize(const std::string&amp; hub_url, const std::string&amp; token) {
    // 🔐 Build URL with token as query parameter (ASP.NET Core SignalR standard method)
    std::string final_hub_url = hub_url;
    if (!token.empty()) {
      ESP_LOGI(TAG, "========== SignalR Token Authentication ==========");
      
      // Remove "Bearer " prefix if present
      std::string token_value = token;
      if (token_value.find("Bearer ") == 0) {
            token_value = token_value.substr(7);
      }
      
      // Append token to URL
      final_hub_url += "?access_token=" + token_value;
    }
   
    // Create hub connection builder
    auto builder = signalr::hub_connection_builder::create(final_hub_url);
   
    // Set WebSocket factory (使用ESP32的WebSocket实现)
    builder.with_websocket_factory([](const signalr::signalr_client_config&amp; config) {
      auto client = std::make_shared&lt;signalr::esp32_websocket_client&gt;(config);
      return client;
    });
   
    // Skip negotiation (direct WebSocket connection)
    builder.skip_negotiation(true);
   
    // Build connection
    connection_ = std::make_unique&lt;signalr::hub_connection&gt;(builder.build());
}
</code></pre>
<p><strong>3. 超时和心跳配置</strong></p>
<pre><code class="language-cpp">signalr::signalr_client_config cfg;
cfg.set_server_timeout(std::chrono::seconds(60));   // server expects 60s idle
cfg.set_keepalive_interval(std::chrono::seconds(15)); // send ping every 15s
cfg.set_handshake_timeout(std::chrono::seconds(5));   // short handshake timeout

// IMPORTANT: Disable library's auto-reconnect! It has race condition bugs
cfg.enable_auto_reconnect(false);
connection_-&gt;set_client_config(cfg);
</code></pre>
<p><strong>4. 连接确认和自动注册</strong></p>
<pre><code class="language-cpp">// Register Notification handler to confirm connection
connection_-&gt;on("Notification", (const std::vector&lt;signalr::value&gt;&amp; args) {
    if (args.empty()) return;
   
    std::string message = args.as_string();
    ESP_LOGI(TAG, "🔔 Notification from server: %s", message.c_str());
   
    if (!connection_confirmed_) {
      connection_confirmed_ = true;
      ESP_LOGI(TAG, "✓✓✓ SIGNALR CONNECTION CONFIRMED BY SERVER! ✓✓✓");
      
      // 🔄 Auto-register device info after connection confirmed
      std::string mac_address = DeviceInfo::GetMacAddress();
      std::string metadata = DeviceInfo::BuildMetadataJson();
      
      RegisterDevice(mac_address, "", metadata, [](bool success, const std::string&amp; result) {
            if (success) {
                ESP_LOGI(TAG, "✅ Device auto-registration successful");
            }
      });
    }
});
</code></pre>
<p><strong>5. 自定义消息处理</strong></p>
<pre><code class="language-cpp">connection_-&gt;on("CustomMessage", (const std::vector&lt;signalr::value&gt;&amp; args) {
    if (args.empty()) return;
   
    try {
      std::string json_str = args.as_string();
      ESP_LOGI(TAG, "📨 Received CustomMessage: %s", json_str.c_str());
      
      auto root = cJSON_Parse(json_str.c_str());
      if (root) {
            if (on_custom_message_) {
                on_custom_message_(root);// 调用用户设置的回调
            }
            cJSON_Delete(root);
      }
    } catch (const std::exception&amp; e) {
      ESP_LOGE(TAG, "Exception handling CustomMessage: %s", e.what());
    }
});
</code></pre>
<p><strong>6. 自动重连机制</strong> - 使用PSRAM栈的后台任务</p>
<pre><code class="language-cpp">void SignalRClient::StartReconnectTask() {
    ESP_LOGI(TAG, "Starting SignalR reconnect background task (PSRAM stack)...");
    reconnect_task_running_.store(true, std::memory_order_release);
   
    // Allocate task stack from PSRAM (reusable)
    reconnect_task_stack_ = (StackType_t*)heap_caps_malloc(
      RECONNECT_TASK_STACK_SIZE, MALLOC_CAP_SPIRAM);
   
    // Create task with static allocation (stack in PSRAM)
    reconnect_task_handle_ = xTaskCreateStatic(
      ReconnectTaskEntry, "signalr_reconn",
      RECONNECT_TASK_STACK_SIZE / sizeof(StackType_t),
      this, 2, reconnect_task_stack_, reconnect_task_buffer_
    );
}

void SignalRClient::ReconnectTaskLoop() {
    while (reconnect_task_running_.load(std::memory_order_acquire)) {
      vTaskDelay(pdMS_TO_TICKS(1000));
      
      if (!reconnect_requested_.load() || IsConnected()) {
            continue;
      }
      
      // Apply exponential backoff
      ESP_LOGI(TAG, "Attempting connection (backoff=%dms)...", reconnect_backoff_ms_);
      
      if (Connect() &amp;&amp; IsConnected()) {
            reconnect_backoff_ms_ = 1000;// Reset backoff on success
      } else {
            vTaskDelay(pdMS_TO_TICKS(reconnect_backoff_ms_));
            reconnect_backoff_ms_ = std::min(reconnect_backoff_ms_ * 2,
                MAX_RECONNECT_BACKOFF_MS);// Exponential backoff
      }
    }
}
</code></pre>
<p><strong>7. 设备注册和心跳</strong></p>
<pre><code class="language-cpp">void SignalRClient::RegisterDevice(
    const std::string&amp; mac_address,
    const std::string&amp; device_token,
    const std::string&amp; metadata,
    std::function&lt;void(bool, const std::string&amp;)&gt; callback) {
   
    if (!IsConnected()) {
      if (callback) callback(false, "Not connected");
      return;
    }
   
    std::vector&lt;signalr::value&gt; args;
    args.push_back(signalr::value(mac_address));
    args.push_back(signalr::value(device_token));
    args.push_back(signalr::value(metadata));
   
    connection_-&gt;invoke("RegisterDevice", args,
      (const signalr::value&amp; result, std::exception_ptr ex) {
            if (ex) {
                if (callback) callback(false, "Registration failed");
            } else {
                if (callback) callback(true, "Registration sent");
            }
      });
}

void SignalRClient::SendHeartbeat(
    std::function&lt;void(bool, const std::string&amp;)&gt; callback) {
   
    if (!IsConnected()) {
      if (callback) callback(false, "Not connected");
      return;
    }
   
    std::vector&lt;signalr::value&gt; args;
    connection_-&gt;invoke("Heartbeat", args,
      (const signalr::value&amp; result, std::exception_ptr ex) {
            if (!ex) {
                ESP_LOGD(TAG, "💓 Heartbeat sent");
                if (callback) callback(true, "Success");
            }
      });
}
</code></pre>
<p><strong>SignalR客户端类定义</strong> (signalr_client.h):</p>
<pre><code class="language-cpp">class SignalRClient {
public:
    static SignalRClient&amp; GetInstance();
   
    // 连接管理
    bool Initialize(const std::string&amp; hub_url, const std::string&amp; token);
    bool Connect();
    void Disconnect();
    void Reset();
    void RequestReconnect();
   
    // 状态查询
    bool IsInitialized() const;
    bool IsConnecting() const;
    bool IsConnected() const;
    std::string GetConnectionState() const;
   
    // 回调设置
    void OnCustomMessage(std::function&lt;void(const cJSON*)&gt; callback);
    void OnDeviceRegistered(std::function&lt;void(const cJSON*)&gt; callback);
   
    // Hub方法调用
    void RegisterDevice(const std::string&amp; mac_address,
                     const std::string&amp; device_token,
                     const std::string&amp; metadata,
                     std::function&lt;void(bool, const std::string&amp;)&gt; callback);
    void SendHeartbeat(std::function&lt;void(bool, const std::string&amp;)&gt; callback);
    void InvokeHubMethod(const std::string&amp; method_name,
                        const std::string&amp; args_json,
                        std::function&lt;void(bool, const std::string&amp;)&gt; callback);

private:
    SignalRClient();
    ~SignalRClient();
   
    std::unique_ptr&lt;signalr::hub_connection&gt; connection_;
    std::string hub_url_;
    std::string token_;
    bool initialized_ = false;
    bool connection_confirmed_ = false;
    std::atomic&lt;bool&gt; reconnect_requested_{false};
   
    // 回调函数
    std::function&lt;void(const cJSON*)&gt; on_custom_message_;
    std::function&lt;void(const cJSON*)&gt; on_device_registered_;
   
    // 重连任务
    TaskHandle_t reconnect_task_handle_ = nullptr;
    int reconnect_backoff_ms_ = 1000;
};
</code></pre>
<p><strong>使用示例</strong>:</p>
<pre><code class="language-cpp">// 在主应用中使用SignalR客户端
void Application::InitializeSignalR() {
    auto&amp; client = SignalRClient::GetInstance();
   
    // 设置消息回调
    client.OnCustomMessage((const cJSON* json) {
      ESP_LOGI(TAG, "Received message from server");
      HandleServerMessage(json);
    });
   
    // 初始化并连接
    std::string hub_url = "wss://your-server.com/devicehub";
    std::string token = GetJwtToken();// 从NVS读取或扫码获取
   
    if (client.Initialize(hub_url, token)) {
      if (client.Connect()) {
            client.RequestReconnect();// 启动自动重连任务
      }
    }
}
</code></pre>
<h5 id="application层集成代码">Application层集成代码</h5>
<p><strong>核心代码片段</strong> (application.cc):</p>
<pre><code class="language-cpp">
void Application::HandleSignalRMessage(const std::string&amp; message) {
    ESP_LOGI(TAG, "Handling SignalR message: %s", message.c_str());
   
    auto root = cJSON_Parse(message.c_str());
    if (!root) {
      ESP_LOGE(TAG, "Failed to parse SignalR message JSON");
      return;
    }
   
    auto display = Board::GetInstance().GetDisplay();
   
    // Check message action/type
    auto action = cJSON_GetObjectItem(root, "action");
    if (cJSON_IsString(action)) {
      if (strcmp(action-&gt;valuestring, "notification") == 0) {
            // Handle notification
            // JSON: {"action":"notification", "title":"标题", "content":"内容", "emotion":"bell", "sound":"popup"}
            auto title = cJSON_GetObjectItem(root, "title");
            auto content = cJSON_GetObjectItem(root, "content");
            auto emotion = cJSON_GetObjectItem(root, "emotion");
            auto sound = cJSON_GetObjectItem(root, "sound");
            
            const char* title_str = cJSON_IsString(title) ? title-&gt;valuestring : Lang::Strings::INFO;
            const char* content_str = cJSON_IsString(content) ? content-&gt;valuestring : "";
            const char* emotion_str = cJSON_IsString(emotion) ? emotion-&gt;valuestring : "bell";
            
            // Select sound based on "sound" field
            std::string_view sound_view = Lang::Sounds::OGG_POPUP;
            if (cJSON_IsString(sound)) {
                if (strcmp(sound-&gt;valuestring, "success") == 0) {
                  sound_view = Lang::Sounds::OGG_SUCCESS;
                } else if (strcmp(sound-&gt;valuestring, "vibration") == 0) {
                  sound_view = Lang::Sounds::OGG_VIBRATION;
                } else if (strcmp(sound-&gt;valuestring, "exclamation") == 0) {
                  sound_view = Lang::Sounds::OGG_EXCLAMATION;
                } else if (strcmp(sound-&gt;valuestring, "low_battery") == 0) {
                  sound_view = Lang::Sounds::OGG_LOW_BATTERY;
                } else if (strcmp(sound-&gt;valuestring, "none") == 0) {
                  sound_view = "";
                }
                // default: popup
            }
            
            Alert(title_str, content_str, emotion_str, sound_view);
            
      } else if (strcmp(action-&gt;valuestring, "command") == 0) {
            // Handle command
            // JSON: {"action":"command", "command":"reboot|wake|listen|stop"}
            auto cmd = cJSON_GetObjectItem(root, "command");
            if (cJSON_IsString(cmd)) {
                if (strcmp(cmd-&gt;valuestring, "reboot") == 0) {
                  Reboot();
                } else if (strcmp(cmd-&gt;valuestring, "wake") == 0) {
                  // Trigger wake word detection
                  xEventGroupSetBits(event_group_, MAIN_EVENT_WAKE_WORD_DETECTED);
                } else if (strcmp(cmd-&gt;valuestring, "listen") == 0) {
                  StartListening();
                } else if (strcmp(cmd-&gt;valuestring, "stop") == 0) {
                  StopListening();
                } else {
                  ESP_LOGW(TAG, "Unknown SignalR command: %s", cmd-&gt;valuestring);
                }
            }
            
      } else if (strcmp(action-&gt;valuestring, "display") == 0) {
            // Display custom content
            // JSON: {"action":"display", "content":"文本内容", "role":"system"}
            auto content = cJSON_GetObjectItem(root, "content");
            auto role = cJSON_GetObjectItem(root, "role");
            const char* role_str = cJSON_IsString(role) ? role-&gt;valuestring : "system";
            if (cJSON_IsString(content)) {
                display-&gt;SetChatMessage(role_str, content-&gt;valuestring);
            }
            
      } else if (strcmp(action-&gt;valuestring, "emotion") == 0) {
            // Change emotion/expression
            // JSON: {"action":"emotion", "emotion":"happy"}
            auto emotion = cJSON_GetObjectItem(root, "emotion");
            if (cJSON_IsString(emotion)) {
                display-&gt;SetEmotion(emotion-&gt;valuestring);
            }
            
      } else if (strcmp(action-&gt;valuestring, "image") == 0) {
            // Display image from URL
            // JSON: {"action":"image", "url":"https://example.com/image.jpg"}
            auto url = cJSON_GetObjectItem(root, "url");
            if (cJSON_IsString(url)) {
                HandleSignalRImageMessage(url-&gt;valuestring);
            } else {
                ESP_LOGW(TAG, "Image action requires 'url' field");
            }
            
      } else if (strcmp(action-&gt;valuestring, "audio") == 0) {
            // Play audio from URL (OGG format)
            // JSON: {"action":"audio", "url":"https://example.com/sound.ogg"}
            auto url = cJSON_GetObjectItem(root, "url");
            if (cJSON_IsString(url)) {
                HandleSignalRAudioMessage(url-&gt;valuestring);
            } else {
                ESP_LOGW(TAG, "Audio action requires 'url' field");
            }
            
      } else if (strcmp(action-&gt;valuestring, "qrcode") == 0) {
            // Show QR code
            // JSON: {"action":"qrcode", "data":"https://...", "title":"标题", "subtitle":"副标题"}
            auto data = cJSON_GetObjectItem(root, "data");
            auto title = cJSON_GetObjectItem(root, "title");
            auto subtitle = cJSON_GetObjectItem(root, "subtitle");
            if (cJSON_IsString(data)) {
                const char* title_str = cJSON_IsString(title) ? title-&gt;valuestring : nullptr;
                const char* subtitle_str = cJSON_IsString(subtitle) ? subtitle-&gt;valuestring : nullptr;
                display-&gt;ShowQRCode(data-&gt;valuestring, title_str, subtitle_str);
            } else {
                ESP_LOGW(TAG, "QRCode action requires 'data' field");
            }
            
      } else if (strcmp(action-&gt;valuestring, "hide_qrcode") == 0) {
            // Hide QR code
            // JSON: {"action":"hide_qrcode"}
            display-&gt;HideQRCode();
            
      } else {
            // Default: display as system message
            char* display_str = cJSON_Print(root);
            if (display_str) {
                display-&gt;SetChatMessage("system", display_str);
                cJSON_free(display_str);
            }
      }
    } else {
      // No action specified, display raw message
      char* display_str = cJSON_Print(root);
      if (display_str) {
            display-&gt;SetChatMessage("system", display_str);
            cJSON_free(display_str);
      }
    }
   
    cJSON_Delete(root);
}

</code></pre>
<p><strong>完整的Application类功能</strong>:</p>
<ul>
<li>✅ 设备状态管理 (状态机)</li>
<li>✅ 网络事件处理 (连接/断开)</li>
<li>✅ 音频服务集成 (编解码、流处理)</li>
<li>✅ 唤醒词检测</li>
<li>✅ 协议层抽象 (WebSocket/MQTT)</li>
<li>✅ MCP消息路由</li>
<li>✅ 错误处理和恢复</li>
<li>✅ 资源管理和清理</li>
<li>✅ 线程安全的消息调度</li>
</ul>
<h4 id="522-mcp服务器代码-verdure-mcp">5.2.2 MCP服务器代码 (verdure-mcp)</h4>
<p><strong>仓库地址</strong>:verdure-mcp</p>
<p><strong>目录结构</strong>:</p>
<pre><code>src/Verdure.Mcp.Server/
├── Hubs/
│   └── DeviceHub.cs          # SignalR Hub实现
├── Tools/
│   ├── MusicTool.cs          # 音乐播放控制
│   ├── EmailTool.cs          # 邮件发送
│   ├── WeatherTool.cs      # 天气查询
│   └── SmartHomeTool.cs      # 智能家居控制
├── Services/
│   ├── DeviceService.cs      # 设备管理服务
│   ├── McpExecutor.cs      # MCP工具执行器
│   └── TokenService.cs       # JWT令牌服务
└── Models/
    ├── DeviceConnection.cs   # 设备连接记录
    ├── DeviceInfo.cs         # 设备信息
    └── McpToolLog.cs         # 工具调用日志
</code></pre>
<p><strong>DeviceHub完整实现</strong> (Hubs/DeviceHub.cs):</p>
<p>生产环境的DeviceHub实际实现特点:</p>
<ul>
<li>✅ 数据库持久化 (Entity Framework Core + PostgreSQL)</li>
<li>✅ 设备注册和状态跟踪</li>
<li>✅ 用户和设备分组管理</li>
<li>✅ 心跳检测</li>
<li>✅ 双重认证 (JWT + API Token)</li>
<li>✅ 完善的异常处理和日志</li>
</ul>
<pre><code class="language-csharp">using Microsoft.AspNetCore.SignalR;
using Verdure.Mcp.Infrastructure.Database;
using Verdure.Mcp.Infrastructure.Services;

namespace Verdure.Mcp.Server.Hubs;

/// &lt;summary&gt;
/// 设备连接Hub - 处理ESP32等IoT设备的SignalR连接
/// &lt;/summary&gt;
public class DeviceHub : Hub
{
    private readonly McpDbContext _dbContext;
    private readonly ITokenValidationService _tokenValidationService;
    private readonly ILogger&lt;DeviceHub&gt; _logger;

    public DeviceHub(
      McpDbContext dbContext,
      ITokenValidationService tokenValidationService,
      ILogger&lt;DeviceHub&gt; logger)
    {
      _dbContext = dbContext;
      _tokenValidationService = tokenValidationService;
      _logger = logger;
    }

    /// &lt;summary&gt;
    /// 设备连接 - 支持JWT和API Token双重认证
    /// &lt;/summary&gt;
    public override async Task OnConnectedAsync()
    {
      try
      {
            var httpContext = Context.GetHttpContext();
            if (httpContext == null)
            {
                _logger.LogWarning("HttpContext is null");
                Context.Abort();
                return;
            }

            // 从查询参数获取token
            var token = httpContext.Request.Query["access_token"].ToString();
            if (string.IsNullOrEmpty(token))
            {
                _logger.LogWarning("Connection attempt without token");
                Context.Abort();
                return;
            }

            // 验证token - 支持JWT和API Token
            var validationResult = await _tokenValidationService.ValidateTokenAsync(token);
            if (!validationResult.IsValid)
            {
                _logger.LogWarning("Invalid token: {Reason}", validationResult.FailureReason);
                Context.Abort();
                return;
            }

            var userId = validationResult.UserId;
            if (string.IsNullOrEmpty(userId))
            {
                _logger.LogWarning("Token valid but userId is missing");
                Context.Abort();
                return;
            }

            // 将连接加入用户组 (格式: Users:{userId})
            await Groups.AddToGroupAsync(Context.ConnectionId, $"Users:{userId}");

            _logger.LogInformation(
                "Device connected: ConnectionId={ConnectionId}, UserId={UserId}",
                Context.ConnectionId, userId);

            // 发送欢迎通知
            await Clients.Caller.SendAsync("Notification",
                $"Welcome! Connected to Verdure MCP Server at {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}");

            await base.OnConnectedAsync();
      }
      catch (Exception ex)
      {
            _logger.LogError(ex, "Error in OnConnectedAsync");
            Context.Abort();
      }
    }

    /// &lt;summary&gt;
    /// 设备断开连接
    /// &lt;/summary&gt;
    public override async Task OnDisconnectedAsync(Exception? exception)
    {
      if (exception != null)
      {
            _logger.LogWarning(exception,
                "Device disconnected with error: ConnectionId={ConnectionId}",
                Context.ConnectionId);
      }
      else
      {
            _logger.LogInformation(
                "Device disconnected normally: ConnectionId={ConnectionId}",
                Context.ConnectionId);
      }

      await base.OnDisconnectedAsync(exception);
    }

    /// &lt;summary&gt;
    /// 设备注册 - 保存设备MAC地址和元数据
    /// &lt;/summary&gt;
    public async Task RegisterDevice(string macAddress, string deviceToken, string metadata)
    {
      try
      {
            var userId = Context.Items["UserId"]?.ToString();
            if (string.IsNullOrEmpty(userId))
            {
                _logger.LogWarning("RegisterDevice called without userId");
                return;
            }

            _logger.LogInformation(
                "Device registration: UserId={UserId}, MAC={MacAddress}, Metadata={Metadata}",
                userId, macAddress, metadata);

            // 将设备加入设备组 (格式: Device:{macAddress})
            await Groups.AddToGroupAsync(Context.ConnectionId, $"Device:{macAddress}");

            // 保存设备信息到数据库
            var existingDevice = await _dbContext.Devices
                .FirstOrDefaultAsync(d =&gt; d.MacAddress == macAddress);

            if (existingDevice != null)
            {
                existingDevice.LastSeenAt = DateTime.UtcNow;
                existingDevice.Metadata = metadata;
                existingDevice.IsOnline = true;
            }
            else
            {
                _dbContext.Devices.Add(new Device
                {
                  MacAddress = macAddress,
                  UserId = userId,
                  Metadata = metadata,
                  IsOnline = true,
                  CreatedAt = DateTime.UtcNow,
                  LastSeenAt = DateTime.UtcNow
                });
            }

            await _dbContext.SaveChangesAsync();

            // 确认注册成功
            await Clients.Caller.SendAsync("Notification",
                $"Device registered successfully: {macAddress}");
      }
      catch (Exception ex)
      {
            _logger.LogError(ex, "Error in RegisterDevice");
      }
    }

    /// &lt;summary&gt;
    /// 心跳保持
    /// &lt;/summary&gt;
    public async Task Heartbeat()
    {
      _logger.LogDebug("Heartbeat from ConnectionId={ConnectionId}", Context.ConnectionId);
      
      // 更新最后活跃时间
      // 注意:实际代码中可以根据ConnectionId查找设备并更新LastSeenAt
      await Task.CompletedTask;
    }
}
</code></pre>
<p><strong>实际的MCP工具实现</strong> (不使用基类):</p>
<p>在verdure-mcp中,MCP工具<strong>不继承任何基类</strong>,而是:</p>
<ol>
<li>使用 <code></code> 特性标记</li>
<li>通过依赖注入获取 <code>IDevicePushService</code> 服务</li>
<li>使用 <code>IDevicePushService</code> 的方法推送消息给设备</li>
</ol>
<p><strong>DevicePushService接口</strong> (Infrastructure/Services/DevicePushService.cs):</p>
<pre><code class="language-csharp">namespace Verdure.Mcp.Infrastructure.Services;

/// &lt;summary&gt;
/// 设备推送服务接口
/// &lt;/summary&gt;
public interface IDevicePushService
{
    /// &lt;summary&gt;
    /// 向用户的所有设备发送消息
    /// &lt;/summary&gt;
    Task SendToUserAsync(string userId, string method, object message,
      CancellationToken cancellationToken = default);

    /// &lt;summary&gt;
    /// 向指定设备发送消息
    /// &lt;/summary&gt;
    Task SendToDeviceAsync(string deviceId, string method, object message,
      CancellationToken cancellationToken = default);

    /// &lt;summary&gt;
    /// 发送自定义消息 (xiaozhi协议格式)
    /// &lt;/summary&gt;
    Task SendCustomMessageAsync(string userId, object message,
      CancellationToken cancellationToken = default);

    /// &lt;summary&gt;
    /// 发送通知消息
    /// &lt;/summary&gt;
    Task SendNotificationAsync(string userId, string notificationMessage,
      CancellationToken cancellationToken = default);
}
</code></pre>
<p><strong>DevicePushService实现</strong> (Server/Services/DevicePushServiceImpl.cs):</p>
<pre><code class="language-csharp">using Microsoft.AspNetCore.SignalR;
using Verdure.Mcp.Server.Hubs;
using Verdure.Mcp.Infrastructure.Services;
using System.Text.Json;

namespace Verdure.Mcp.Server.Services;

public class DevicePushServiceImpl : IDevicePushService
{
    private readonly IHubContext&lt;DeviceHub&gt; _hubContext;
    private readonly ILogger&lt;DevicePushServiceImpl&gt; _logger;

    public DevicePushServiceImpl(
      IHubContext&lt;DeviceHub&gt; hubContext,
      ILogger&lt;DevicePushServiceImpl&gt; logger)
    {
      _hubContext = hubContext;
      _logger = logger;
    }

    public async Task SendToUserAsync(string userId, string method, object message,
      CancellationToken cancellationToken = default)
    {
      var groupName = $"Users:{userId}";
      await _hubContext.Clients.Group(groupName)
            .SendAsync(method, message, cancellationToken);
      
      _logger.LogInformation("Sent {Method} to user {UserId}", method, userId);
    }

    public async Task SendToDeviceAsync(string deviceId, string method, object message,
      CancellationToken cancellationToken = default)
    {
      var groupName = $"Device:{deviceId}";
      await _hubContext.Clients.Group(groupName)
            .SendAsync(method, message, cancellationToken);
      
      _logger.LogInformation("Sent {Method} to device {DeviceId}", method, deviceId);
    }

    public async Task SendCustomMessageAsync(string userId, object message,
      CancellationToken cancellationToken = default)
    {
      var groupName = $"Users:{userId}";
      
      // 重要!ESP32客户端期望接收JSON字符串,而不是对象
      var jsonString = JsonSerializer.Serialize(message);
      
      await _hubContext.Clients.Group(groupName)
            .SendAsync("CustomMessage", jsonString, cancellationToken);
      
      _logger.LogInformation("Sent CustomMessage to user {UserId}: {Message}",
            userId, jsonString);
    }

    public async Task SendNotificationAsync(string userId, string notificationMessage,
      CancellationToken cancellationToken = default)
    {
      var groupName = $"Users:{userId}";
      await _hubContext.Clients.Group(groupName)
            .SendAsync("Notification", notificationMessage, cancellationToken);
      
      _logger.LogInformation("Sent notification to user {UserId}: {Message}",
            userId, notificationMessage);
    }
}
</code></pre>
<p><strong>音乐播放工具实际实现</strong> (Tools/MusicTool.cs):</p>
<pre><code class="language-csharp">using System;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Verdure.Mcp.Server.Settings;
using ModelContextProtocol.Server;
using Verdure.Mcp.Infrastructure.Services;
using Verdure.Mcp.Server.Services;
using Hangfire;

namespace Verdure.Mcp.Server.Tools;

/// &lt;summary&gt;
/// MCP Tool to pick a random audio file from wwwroot and push its URL to device(s).
/// &lt;/summary&gt;

public class MusicTool
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly IWebHostEnvironment _env;
    private readonly IDevicePushService _devicePushService;
    private readonly ILogger&lt;MusicTool&gt; _logger;
    private readonly IBackgroundJobClient _backgroundJobClient;
    private readonly ImageStorageSettings _imageStorageSettings;

    public MusicTool(
      IHttpContextAccessor httpContextAccessor,
      IWebHostEnvironment env,
      IDevicePushService devicePushService,
      ILogger&lt;MusicTool&gt; logger,
      IBackgroundJobClient backgroundJobClient,
      IOptions&lt;ImageStorageSettings&gt;? imageSettings = null)
    {
      _httpContextAccessor = httpContextAccessor;
      _env = env;
      _devicePushService = devicePushService;
      _logger = logger;
      _backgroundJobClient = backgroundJobClient;
      _imageStorageSettings = imageSettings?.Value ?? new ImageStorageSettings();
    }

    /// &lt;summary&gt;
    /// Select a random audio file from the `wwwroot/audio` folder and push it to the user
    /// identified by the `X-User-Id` request header.
    /// The pushed message follows the same shape as used in `test-send-message.ps1` (action = "audio", url = "...").
    /// &lt;/summary&gt;
   
   
    public async Task&lt;MusicResponse&gt; PlayRandomMusic(CancellationToken cancellationToken = default)
    {
      try
      {
            var httpContext = _httpContextAccessor.HttpContext;
            var effectiveUserId = httpContext?.Request.Headers["X-User-Id"].FirstOrDefault();
            if (string.IsNullOrEmpty(effectiveUserId))
            {
                _logger.LogWarning("No userId provided and X-User-Id header is missing");
                return new MusicResponse { Success = false, Message = "Missing userId or X-User-Id header" };
            }

            var webRoot = _env.WebRootPath ?? _env.ContentRootPath;
            var folder = "audios";
            var audioFolder = Path.Combine(webRoot, folder);

            if (!Directory.Exists(audioFolder))
            {
                _logger.LogWarning("Audio folder does not exist: {AudioFolder}", audioFolder);
                return new MusicResponse { Success = false, Message = $"Audio folder not found: {folder}" };
            }

            // Find audio files (ogg, mp3) and pick a random one
            var files = Directory.GetFiles(audioFolder)
                .Where(f =&gt; f.EndsWith('.' + "ogg") || f.EndsWith('.' + "mp3") || f.EndsWith('.' + "wav"))
                .ToArray();

            if (files.Length == 0)
            {
                _logger.LogWarning("No audio files found in {AudioFolder}", audioFolder);
                return new MusicResponse { Success = false, Message = "No audio files found" };
            }

            var rnd = new Random();
            var chosen = files;
            var fileName = Path.GetFileName(chosen);

            string url;
            // Prefer configured ImageStorage BaseUrl (keeps image and audio base URL consistent)
            if (!string.IsNullOrWhiteSpace(_imageStorageSettings.BaseUrl))
            {
                var cfgBase = _imageStorageSettings.BaseUrl.TrimEnd('/');
                url = $"{cfgBase}/{folder}/{Uri.EscapeDataString(fileName)}";
            }
            else
            {
                var req = httpContext?.Request;
                var hostBase = req != null ? $"{req.Scheme}://{req.Host.Value}" : string.Empty;
                url = string.IsNullOrEmpty(hostBase)
                  ? $"/{folder}/{Uri.EscapeDataString(fileName)}"
                  : $"{hostBase}/{folder}/{Uri.EscapeDataString(fileName)}";
            }

            var title = Path.GetFileNameWithoutExtension(fileName);

            var message = new
            {
                action = "audio",
                url,
                title
            };

            // Schedule push as a delayed background job so device can play result first.
            try
            {

                var jobDelay = TimeSpan.FromSeconds(5);

                _logger.LogInformation("Scheduling audio push to user {UserId} after {Delay}s: {Url}",
                  effectiveUserId, jobDelay.TotalSeconds, url);

                _backgroundJobClient.Schedule&lt;MusicPushBackgroundJob&gt;(
                  job =&gt; job.ExecuteAsync(effectiveUserId, url, title, CancellationToken.None),
                  jobDelay);

                return new MusicResponse { Success = true, Message = "Audio scheduled", Url = url, FileName = fileName };
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to schedule audio push for user {UserId}", effectiveUserId);
                return new MusicResponse { Success = false, Message = ex.Message };
            }
      }
      catch (Exception ex)
      {
            _logger.LogError(ex, "Failed to play random music");
            return new MusicResponse { Success = false, Message = ex.Message };
      }
    }
}

public class MusicResponse
{
    public bool Success { get; set; }
    public required string Message { get; set; }
    public string? Url { get; set; }
    public string? FileName { get; set; }
}

</code></pre>
<p><strong>延迟推送后台任务</strong> (Tools/MusicPushBackgroundJob.cs):</p>
<pre><code class="language-csharp">using Verdure.Mcp.Infrastructure.Services;
using Verdure.Mcp.Server.Services;

namespace Verdure.Mcp.Server.Tools;

/// &lt;summary&gt;
/// Background job to push music/audio messages to user devices after a delay.
/// &lt;/summary&gt;
public class MusicPushBackgroundJob
{
    private readonly IDevicePushService _devicePushService;
    private readonly ILogger&lt;MusicPushBackgroundJob&gt; _logger;

    public MusicPushBackgroundJob(IDevicePushService devicePushService, ILogger&lt;MusicPushBackgroundJob&gt; logger)
    {
      _devicePushService = devicePushService;
      _logger = logger;
    }

    public async Task ExecuteAsync(string userId, string url, string title, CancellationToken cancellationToken)
    {
      _logger.LogInformation("Executing MusicPushBackgroundJob: user={UserId}, url={Url}", userId, url);

      var message = new
      {
            action = "audio",
            url,
            title
      };

      try
      {
            await _devicePushService.SendCustomMessageAsync(userId, message, cancellationToken);
            _logger.LogInformation("Music pushed to user {UserId}", userId);
      }
      catch (Exception ex)
      {
            _logger.LogError(ex, "Failed to push music to user {UserId}", userId);
      }
    }
}

</code></pre>
<h4 id="523-图片生成工具实现示例">5.2.3 图片生成工具实现示例</h4>
<p><strong>仓库地址</strong>:verdure-mcp - GenerateImageTool</p>
<p>这个工具展示了如何生成图片并推送到设备:</p>
<pre><code class="language-csharp">using System.ComponentModel;
using System.Net;
using Hangfire;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server;
using Verdure.Mcp.Domain.Entities;
using Verdure.Mcp.Domain.Enums;
using Verdure.Mcp.Infrastructure.Data;
using Verdure.Mcp.Infrastructure.Services;
using Verdure.Mcp.Server.Services;

namespace Verdure.Mcp.Server.Tools;

/// &lt;summary&gt;
/// 使用 Azure OpenAI DALL-E 生成图片的 MCP 工具
/// &lt;/summary&gt;

public class GenerateImageTool
{
    private readonly IImageGenerationService _imageGenerationService;
    private readonly IEmailService _emailService;
    private readonly McpDbContext _dbContext;
    private readonly IBackgroundJobClient _backgroundJobClient;
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly IImageStorageService _imageStorageService;
    private readonly IDevicePushService _devicePushService;
    private readonly ILogger&lt;GenerateImageTool&gt; _logger;

    public GenerateImageTool(
      IImageGenerationService imageGenerationService,
      IEmailService emailService,
      McpDbContext dbContext,
      IBackgroundJobClient backgroundJobClient,
      IHttpContextAccessor httpContextAccessor,
      IImageStorageService imageStorageService,
      IDevicePushService devicePushService,
      ILogger&lt;GenerateImageTool&gt; logger)
    {
      _imageGenerationService = imageGenerationService;
      _emailService = emailService;
      _dbContext = dbContext;
      _backgroundJobClient = backgroundJobClient;
      _httpContextAccessor = httpContextAccessor;
      _imageStorageService = imageStorageService;
      _devicePushService = devicePushService;
      _logger = logger;
    }

    /// &lt;summary&gt;
    /// 使用 Azure OpenAI DALL-E 根据提示词生成图片。
    /// 如果提供邮箱地址,会将生成的图片发送到指定邮箱。
    /// 如果请求头中包含用户信息(X-User-Email 和 X-User-Id),任务将异步运行。
    /// &lt;/summary&gt;
    /// &lt;param name="prompt"&gt;描述要生成图片的文本提示词&lt;/param&gt;
    /// &lt;param name="size"&gt;图片尺寸:"1024x1024"、"1792x1024" 或 "1024x1792",默认为 "1024x1024"&lt;/param&gt;
    /// &lt;param name="quality"&gt;图片质量:"standard" 或 "hd",默认为 "standard"&lt;/param&gt;
    /// &lt;param name="style"&gt;图片风格:"vivid" 或 "natural",默认为 "vivid"&lt;/param&gt;
    /// &lt;param name="cancellationToken"&gt;&lt;/param&gt;
    /// &lt;returns&gt;包含任务信息和图片数据的 JSON 对象(同步模式下)&lt;/returns&gt;
   
   
    public async Task&lt;ImageGenerationResponse&gt; GenerateImage(
       string prompt,
       string? size = null,
       string? quality = null,
       string? style = null,
      CancellationToken cancellationToken = default)
    {
      var httpContext = _httpContextAccessor.HttpContext;
      
      // 从请求头提取邮箱地址 (X-User-Email)
      var email = httpContext?.Request.Headers["X-User-Email"].FirstOrDefault();
      
      // 从请求头提取用户 ID (X-User-Id)
      var userId = httpContext?.Request.Headers["X-User-Id"].FirstOrDefault();

      _logger.LogInformation("收到图片生成请求。提示词: {Prompt}, 邮箱: {Email}, 用户ID: {UserId}",
            prompt, email ?? "无", userId ?? "无");

      // 创建任务记录
      var task = new ImageGenerationTask
      {
            Id = Guid.NewGuid(),
            Prompt = prompt,
            Size = size ?? "1024x1024",
            Quality = quality ?? "standard",
            Style = style ?? "vivid",
            Status = ImageTaskStatus.Pending,
            Email = email,
            UserId = userId,
            CreatedAt = DateTime.UtcNow
      };

      _dbContext.ImageGenerationTasks.Add(task);
      await _dbContext.SaveChangesAsync(cancellationToken);

      // 如果存在用户信息(X-User-Email 和 X-User-Id),使用 Hangfire 异步处理
      if (!string.IsNullOrEmpty(email) &amp;&amp; !string.IsNullOrEmpty(userId))
      {
            _logger.LogInformation("检测到用户信息,使用异步处理任务 {TaskId}", task.Id);
            
            var jobId = _backgroundJobClient.Enqueue&lt;ImageGenerationBackgroundJob&gt;(
                job =&gt; job.ExecuteAsync(task.Id, CancellationToken.None));
            
            task.HangfireJobId = jobId;
            task.Status = ImageTaskStatus.Processing;
            await _dbContext.SaveChangesAsync(cancellationToken);

            return new ImageGenerationResponse
            {
                TaskId = task.Id,
                Status = "处理中",
                Message = "图片生成任务已加入队列。如果您提供了邮箱地址,稍后会收到生成结果。",
                IsAsync = true
            };
      }
      else
      {
            // 同步处理
            _logger.LogInformation("未检测到完整用户信息,使用同步处理任务 {TaskId}", task.Id);
            
            task.Status = ImageTaskStatus.Processing;
            await _dbContext.SaveChangesAsync(cancellationToken);

            try
            {
                var result = await _imageGenerationService.GenerateImageAsync(
                  prompt, size, quality, style, cancellationToken);

                if (result.Success)
                {
                  task.Status = ImageTaskStatus.Completed;
                  task.ImageData = result.ImageBase64;
                  task.CompletedAt = DateTime.UtcNow;
                  task.UpdatedAt = DateTime.UtcNow;

                  // 保存图片到本地文件系统(PNG + JPEG)
                  ImageStorageResult? storageResult = null;
                  if (!string.IsNullOrEmpty(result.ImageBase64))
                  {
                        try
                        {
                            storageResult = await _imageStorageService.SaveImageAsync(
                              result.ImageBase64,
                              task.Id,
                              cancellationToken);
                            task.ImageUrl = storageResult.PngUrl; // 数据库保存 PNG URL
                            _logger.LogInformation(
                              "图片已保存 - PNG: {PngUrl}, JPEG: {JpegUrl}, 压缩率: {CompressionRatio:F1}%",
                              storageResult.PngUrl, storageResult.JpegUrl, storageResult.CompressionRatio);
                        }
                        catch (Exception ex)
                        {
                            _logger.LogError(ex, "保存图片到本地失败,任务 {TaskId}", task.Id);
                            // 即使保存失败,仍然继续流程
                        }
                  }

                  await _dbContext.SaveChangesAsync(cancellationToken);

                  // 如果提供了邮箱,发送邮件
                  if (!string.IsNullOrEmpty(email) &amp;&amp; !string.IsNullOrEmpty(result.ImageBase64))
                  {
                        try
                        {
                            var imageBytes = Convert.FromBase64String(result.ImageBase64);
                            var encodedPrompt = WebUtility.HtmlEncode(prompt);
                            var encodedRevisedPrompt = WebUtility.HtmlEncode(result.RevisedPrompt ?? "无");
                            await _emailService.SendImageEmailAsync(
                              email,
                              "您的图片已生成",
                              $"&lt;h1&gt;您的图片已成功生成!&lt;/h1&gt;&lt;p&gt;提示词:{encodedPrompt}&lt;/p&gt;&lt;p&gt;修订后的提示词:{encodedRevisedPrompt}&lt;/p&gt;",
                              imageBytes,
                              $"image_{task.Id}.png",
                              cancellationToken);
                           
                            task.EmailSent = true;
                            await _dbContext.SaveChangesAsync(cancellationToken);
                        }
                        catch (Exception ex)
                        {
                            _logger.LogError(ex, "发送邮件失败,任务 {TaskId}", task.Id);
                        }
                  }

                  // 如果有用户 ID,推送到用户设备(使用 JPEG 版本,符合 xiaozhi 协议)
                  if (!string.IsNullOrEmpty(userId) &amp;&amp; storageResult != null)
                  {
                        try
                        {
                            // 1. 先发送通知消息
                            var notificationMessage = new
                            {
                              action = "notification",
                              title = "图片生成完成",
                              content = $"您的图片已生成:{prompt.Substring(0, Math.Min(30, prompt.Length))}...",
                              emotion = "happy",
                              sound = "success"
                            };
                            await _devicePushService.SendCustomMessageAsync(userId, notificationMessage, cancellationToken);
                           
                            // 2. 再发送图片消息(ESP32 期望的格式 - xiaozhi 协议)
                            var imageMessage = new
                            {
                              action = "image",
                              url = storageResult.JpegUrl,// 使用 JPEG URL(体积小)
                              // 扩展信息(可选,ESP32 可以忽略)
                              taskId = task.Id.ToString(),
                              pngUrl = storageResult.PngUrl,
                              prompt = prompt,
                              jpegSize = storageResult.JpegSize,
                              timestamp = DateTime.UtcNow
                            };

                            await _devicePushService.SendCustomMessageAsync(userId, imageMessage, cancellationToken);
                            _logger.LogInformation(
                              "已推送图片到用户 {UserId} 的设备,任务 {TaskId},JPEG URL: {JpegUrl} ({JpegSize} bytes)",
                              userId, task.Id, storageResult.JpegUrl, storageResult.JpegSize);
                        }
                        catch (Exception ex)
                        {
                            _logger.LogError(ex, "推送消息到设备失败,用户 {UserId},任务 {TaskId}", userId, task.Id);
                        }
                  }

                  // 同步模式:返回 PNG URL(完整质量)
                  return new ImageGenerationResponse
                  {
                        TaskId = task.Id,
                        Status = "已完成",
                        Message = "图片生成成功",
                        ImageUrl = storageResult?.PngUrl ?? result.ImageUrl,
                        RevisedPrompt = result.RevisedPrompt,
                        IsAsync = false
                  };
                }
                else
                {
                  task.Status = ImageTaskStatus.Failed;
                  task.ErrorMessage = result.ErrorMessage;
                  task.UpdatedAt = DateTime.UtcNow;
                  await _dbContext.SaveChangesAsync(cancellationToken);

                  return new ImageGenerationResponse
                  {
                        TaskId = task.Id,
                        Status = "失败",
                        Message = result.ErrorMessage ?? "图片生成失败",
                        IsAsync = false
                  };
                }
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "同步生成图片时出错,任务 {TaskId}", task.Id);
               
                task.Status = ImageTaskStatus.Failed;
                task.ErrorMessage = ex.Message;
                task.UpdatedAt = DateTime.UtcNow;
                await _dbContext.SaveChangesAsync(cancellationToken);

                return new ImageGenerationResponse
                {
                  TaskId = task.Id,
                  Status = "失败",
                  Message = ex.Message,
                  IsAsync = false
                };
            }
      }
    }

    /// &lt;summary&gt;
    /// 获取图片生成任务的状态
    /// &lt;/summary&gt;
    /// &lt;param name="taskId"&gt;要查询的任务 ID&lt;/param&gt;
    /// &lt;returns&gt;任务状态和结果(如果已完成)&lt;/returns&gt;
   
   
    public async Task&lt;ImageGenerationResponse&gt; GetImageTaskStatus(
       string taskId,
      CancellationToken cancellationToken = default)
    {
      if (!Guid.TryParse(taskId, out var id))
      {
            return new ImageGenerationResponse
            {
                Status = "错误",
                Message = "任务 ID 格式无效"
            };
      }

      var task = await _dbContext.ImageGenerationTasks.FindAsync(new object[] { id }, cancellationToken);
      
      if (task == null)
      {
            return new ImageGenerationResponse
            {
                Status = "错误",
                Message = "未找到任务"
            };
      }

      return new ImageGenerationResponse
      {
            TaskId = task.Id,
            Status = task.Status.ToString().ToLowerInvariant(),
            Message = task.ErrorMessage ?? GetStatusMessage(task.Status),
            ImageBase64 = task.ImageData,
            ImageUrl = task.ImageUrl,
            IsAsync = !string.IsNullOrEmpty(task.HangfireJobId)
      };
    }

    private static string GetStatusMessage(ImageTaskStatus status)
    {
      return status switch
      {
            ImageTaskStatus.Pending =&gt; "任务等待中",
            ImageTaskStatus.Processing =&gt; "任务处理中",
            ImageTaskStatus.Completed =&gt; "图片生成成功",
            ImageTaskStatus.Failed =&gt; "图片生成失败",
            ImageTaskStatus.Cancelled =&gt; "任务已取消",
            _ =&gt; "未知状态"
      };
    }
}

/// &lt;summary&gt;
/// 图片生成响应模型
/// &lt;/summary&gt;
public class ImageGenerationResponse
{
    public Guid? TaskId { get; set; }
    public required string Status { get; set; }
    public string? Message { get; set; }
    public string? ImageBase64 { get; set; }
    public string? ImageUrl { get; set; }
    public string? RevisedPrompt { get; set; }
    public bool IsAsync { get; set; }
}

</code></pre>
<p><strong>实际项目特点</strong>:</p>
<ol>
<li>
<p><strong>双模式支持</strong>:</p>
<ul>
<li>同步模式:立即生成并返回结果</li>
<li>异步模式:使用Hangfire后台任务,完成后通过邮件通知</li>
</ul>
</li>
<li>
<p><strong>多渠道推送</strong>:</p>
<ul>
<li>通过SignalR推送到设备(CustomMessage)</li>
<li>通过邮件发送链接</li>
</ul>
</li>
<li>
<p><strong>图片存储</strong>:</p>
<ul>
<li>同时保存PNG和JPEG两种格式</li>
<li>存储到Azure Blob Storage或本地文件系统</li>
</ul>
</li>
<li>
<p><strong>完整的错误处理和日志记录</strong></p>
</li>
</ol>
<p><strong>获取完整代码</strong>:</p>
<ul>
<li>
<p>🔧 ESP32设备端 (小智完整实现):<br>
https://github.com/maker-community/xiaozhi-esp32</p>
<ul>
<li>SignalR集成分支:<code>signalr</code> 和 <code>signalr-update-audio</code></li>
</ul>
</li>
<li>
<p>🔧 ESP32示例工程 (包含完整的SignalR集成示例):<br>
https://github.com/maker-community/esp-signalr-example</p>
</li>
<li>
<p>🔧 SignalR C++客户端库:<br>
https://github.com/maker-community/esp-signalr<br>
https://github.com/maker-community/esp-signalr-example</p>
</li>
<li>
<p>🌐 MCP服务器端:<br>
https://github.com/maker-community/verdure-mcp</p>
</li>
</ul>
<h2 id="实际使用场景示例">实际使用场景示例</h2>
<h3 id="场景1ai生图后推送到设备">场景1:AI生图后推送到设备</h3>
<p>用户通过小智对话:"帮我生成一张猫咪的图片"</p>
<ol>
<li>对话发送到.NET MCP服务</li>
<li>MCP服务调用API生成图片</li>
<li>生成完成后,后台任务调用hub发送消息</li>
<li>服务端通过SignalR推送图片给该用户的所有设备</li>
<li>ESP32设备接收到<code>自定义消息</code>事件,下载并显示在屏幕上</li>
</ol>
<h3 id="场景2播放音乐">场景2:播放音乐</h3>
<p>用户通过语音说想要播放音乐,mcp被触发,随机选择音乐url推送到设备</p>
<ol>
<li>对话发送到.NET MCP服务</li>
<li>服务端通过SignalR推送音乐给该用户的所有设备</li>
<li>ESP32设备接收到<code>自定义消息</code>事件,下载并播放语音</li>
</ol>
<h2 id="内存优化经验分享">内存优化经验分享</h2>
<blockquote>
<p><strong>说明</strong>:本章节内容对于在ESP32上运行SignalR客户端非常重要,这些都是实践中总结出的经验。</p>
</blockquote>
<p>在ESP32上集成SignalR确实遇到了内存问题,分享一些优化经验:</p>
<h3 id="1-使用psram存储大对象">1. 使用PSRAM存储大对象</h3>
<pre><code class="language-cpp">// 为图片数据分配PSRAM内存
void* imageBuffer = heap_caps_malloc(imageSize, MALLOC_CAP_SPIRAM);
if (imageBuffer == NULL) {
    // 降级到内部RAM
    imageBuffer = malloc(imageSize);
}
</code></pre>
<p>在VS Code的menuconfig中启用PSRAM:</p>
<ul>
<li><code>F1</code> → <code>ESP-IDF: SDK Configuration editor</code></li>
<li><code>Component config</code> → <code>ESP32-specific</code> → <code>Support for external, SPI-connected RAM</code></li>
</ul>
<h3 id="2-减少json序列化次数">2. 减少JSON序列化次数</h3>
<pre><code class="language-cpp">// 错误做法:频繁创建/销毁cJSON对象
for (int i = 0; i &lt; 100; i++) {
    cJSON* root = cJSON_Parse(jsonString);
    // 处理...
    cJSON_Delete(root);// 产生内存碎片
}

// 正确做法:复用对象
cJSON* root = cJSON_Parse(jsonString);
for (int i = 0; i &lt; 100; i++) {
    // 处理...
}
cJSON_Delete(root);
</code></pre>
<h3 id="3-增加任务栈大小">3. 增加任务栈大小</h3>
<p>SignalR的回调嵌套较深,需要增加栈:</p>
<p>在menuconfig中:<code>Component config</code> → <code>ESP32-specific</code> → <code>Main task stack size</code> → 设置为<code>8192</code></p>
<p>或在代码中:</p>
<pre><code class="language-cpp">xTaskCreatePinnedToCore(
    signalr_task,
    "signalr",
    8192,// 栈大小(字节)
    NULL,
    5,   // 优先级
    NULL,
    1      // CPU核心
);
</code></pre>
<h2 id="总结与感悟">总结与感悟</h2>
<p>通过这次SignalR移植和集成的实践,我深刻体会到:</p>
<ol>
<li>
<p><strong>选对框架很重要</strong>:SignalR的群组管理、消息路由等特性,省去了大量基础设施代码。如果从头手写WebSocket,这些功能得花几周时间。</p>
</li>
<li>
<p><strong>内存管理是嵌入式永恒的主题</strong>:ESP32的RAM限制让我对每一个malloc都格外小心。合理使用PSRAM、避免内存碎片、及时释放资源,这些在PC上不用care的问题,在嵌入式上都是坑。</p>
</li>
<li>
<p><strong>AI辅助编程真香</strong>:这次项目中,SignalR C++客户端的移植、消息处理等大量代码都是借助AI生成的。虽然生成的代码需要调试和优化,但确实大幅提高了开发效率。</p>
</li>
<li>
<p><strong>消息推送解决实际问题</strong>:之前MCP工具只能同步返回结果,现在通过SignalR,服务端可以主动推送图片、语音、通知给设备,用户体验提升明显。</p>
</li>
<li>
<p><strong>.NET生态的强大</strong>:SignalR、EF Core、JWT认证……微软这套生态真的很完善。作为.NET开发者,能用熟悉的技术栈快速搭建生产级服务。</p>
</li>
</ol>
<p>这个项目还有很多优化空间,比如:</p>
<ul>
<li>WebSocket Binary Protocol替代JSON减少带宽</li>
<li>引入Redis存储群组信息,支持服务端横向扩展</li>
<li>继续完善代码质量,让库能够被更多的人关注和参与,希望有更多的人来实际使用和优化。</li>
</ul>
<p>但作为一个初步能用的方案,已经足够支撑小智机器人的功能扩展了。后续我会继续完善这套架构,欢迎大家一起探讨和贡献代码!</p>
<p>希望这篇文章能给大家在.NET IoT开发、SignalR实时通信方面带来一些启发。如果有问题欢迎在评论区讨论,让我们一起探索.NET在IoT领域的更多可能性!</p>
<h2 id="手搓esp32小机器人">手搓ESP32小机器人</h2>
<p>如果你有手搓Esp32的硬件打算,可以关注我的B站账号(绿荫阿广)<br>
https://space.bilibili.com/25228512</p>
<p><img src="https://img2024.cnblogs.com/blog/1690009/202602/1690009-20260201141614219-406532144.png" alt="img" loading="lazy"></p>
<h2 id="项目地址">项目地址</h2>
<h3 id="esp32相关-1">ESP32相关</h3>
<ul>
<li>
<p><strong>SignalR C++客户端库</strong>:<br>
https://github.com/maker-community/esp-signalr</p>
</li>
<li>
<p><strong>小智ESP32完整实现</strong> (signalr-update-audio分支):<br>
https://github.com/maker-community/xiaozhi-esp32</p>
</li>
<li>
<p><strong>ESP32 SignalR完整示例工程</strong> (包含音频分块、设备控制API等):<br>
https://github.com/maker-community/esp-signalr-example</p>
<ul>
<li>服务端代码</li>
<li>客户端代码</li>
</ul>
</li>
</ul>
<h3 id="net服务端">.NET服务端</h3>
<ul>
<li><strong>Verdure MCP服务</strong> (包含完整的Hub、Tools、Services实现):<br>
https://github.com/maker-community/verdure-mcp</li>
<li><strong>小智mcp转接平台</strong>:</li>
<li>https://github.com/maker-community/verdure-mcp-for-xiaozhi</li>
</ul>
<h3 id="参考资料">参考资料</h3>
<ul>
<li><strong>SignalR Client C++ (微软官方)</strong>:<br>
https://github.com/aspnet/SignalR-Client-Cpp</li>
<li><strong>SignalR Client C# nanoframework</strong>:<br>
https://github.com/nanoframework/nanoFramework.SignalR.Client</li>
</ul>
<h2 id="参考文档">参考文档</h2>
<ol>
<li>ASP.NET Core SignalR 官方文档</li>
<li>SignalR Hub 协议规范</li>
<li>ESP-IDF 编程指南</li>
<li>VS Code ESP-IDF 插件</li>
<li>JWT 认证最佳实践</li>
<li>Keycloak 认证集成指南</li>
</ol>
<hr>
<p><em>本文首发于个人技术博客,转载请注明出处。如果对.NET IoT开发、SignalR实时通信感兴趣,欢迎关注我的博客获取更多技术分享!</em></p><br><br>
来源:https://www.cnblogs.com/GreenShade/p/19560338
頁: [1]
查看完整版本: 将SignalR移植到Esp32—让小智设备无缝连接.NET功能拓展MCP服务