博客 / 詳情

返回

將SignalR移植到Esp32—讓小智設備無縫連接.NET功能拓展MCP服務

前言

這段時間迷上了手搓Esp32的小智聊天機器人,也用.NET為小智AI開發了一些MCP轉接平台和MCP服務。小智ESP32本身就具備MCP能力,可以調用本地MCP工具和服務端MCP工具,並將結果返回給設備,這個功能一直都有。

如果你有手搓Esp32的硬件玩具打算,可以關注我的B站賬號(綠蔭阿廣)https://space.bilibili.com/25228512
帶你手搓玩具。

小智原本這套架構有個侷限性:MCP工具執行完之後,只能同步返回結果或者通過異步郵件通知,設備無法被動接收服務端的消息。比如我想讓服務端主動給設備推送一張圖片、播放一段語音、或者發送一個文本通知,在之前的架構下是做不到的。

所以我就決定改造小智客户端,集成SignalR實時通信框架。這次改造的核心價值是:通過SignalR消息通道,讓設備可以接收各種類型的消息(聲音、圖片、文本通知),服務端的MCP工具執行成功後,可以根據用户ID推送數據到對應的用户通道

整個改造涉及SignalR C++客户端的集成、JWT Token認證、掃碼登錄(基於ESP32本地MCP工具實現)、以及服務端消息推送邏輯。客户端代碼都是C++實現的,不過現在AI輔助編程很強大,幫我節省了大量時間。

img

問題解答

Q: 為什麼選擇SignalR而不是直接用WebSocket?

A: 起初我確實考慮過直接用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工具調用完成後,只能通過兩種方式通知:

  1. 同步返回:工具執行結果直接返回給調用方
  2. 異步郵件:通過郵件發送執行結果

這兩種方式都無法滿足實時推送的需求。比如我想讓服務端在生圖完成後立即推送圖片給設備顯示,或者播放一段語音提示,之前的架構做不到。

改造後,通過SignalR建立了一條服務端到設備的實時消息通道

  • 服務端的MCP工具執行成功後,可以調用_hubContext.Clients.Group($"Users:{userId}").SendAsync("ShowImage", imageData)將圖片推送給設備
  • 設備通過SignalR的事件監聽接收消息:connection->on("ShowImage", [](const std::vector<signalr::value>& args) { ... })
  • 支持推送任意類型的數據:文本、圖片(Base64)、語音URL、JSON通知等

這才是這次改造的核心價值:讓設備具備被動接收服務端消息的能力,而不僅僅是主動調用和同步返回。

Q: 掃碼登錄是怎麼實現的?

A: 掃碼登錄功能是基於ESP32本地MCP工具實現的,這是小智的固有功能,我只是進行了拓展:

  1. 設備啓動時檢查是否有JWT Token
  2. 如果沒有Token,調用本地MCP工具display_qrcode在屏幕上顯示二維碼
  3. 二維碼內容包含設備ID和服務端地址:https://mcp-server.com/device-login?deviceId=xxx
  4. 用户用手機掃碼,完成授權。
  5. 設備獲取Token後保存到NVS(Non-Volatile Storage),下次啓動直接使用

這樣就實現了設備的快速認證,用户體驗很好。掃碼認證的服務端是使用開源的keycloak做的,對接了設備認證類型。

img

名詞解釋

核心概念

  • SignalR:微軟提供的實時通信框架,封裝了WebSocket、Server‑Sent Events和長輪詢等傳輸方式,支持Hub模型、自動重連與消息序列化。適合實現雙向、低延遲的實時消息系統。將它移植到嵌入式設備時需考慮客户端實現的體積、內存消耗與線程模型。

  • Hub(集線器):SignalR的核心抽象,類似於MVC中的Controller。服務端通過Hub定義方法供客户端調用,客户端也可以註冊事件監聽服務端推送。例如ChatHub.SendMessage(user, message)就是一個典型的Hub方法。

  • MCP(Model Context Protocol):一種基於JSON-RPC 2.0的協議,用於定義客户端和服務端之間的工具調用規範。在IoT場景中,設備可以作為MCP Server暴露能力(如重啓、顯示圖片),而云端服務作為MCP Client調用這些能力。

  • JSON-RPC 2.0:一種輕量級的遠程過程調用協議,使用JSON編碼。MCP協議基於此標準,定義了initializetools/listtools/call等方法。每個請求必須包含jsonrpc: "2.0"methodid字段。

ESP32相關

  • FreeRTOS:一個開源、輕量級的實時操作系統內核,常用於微控制器平台(如ESP32)。提供任務調度、優先級、互斥鎖、信號量、隊列、軟件定時器等實時特性,便於在資源受限設備上實現併發與確定性行為。使用時需注意堆棧大小、中斷安全和任務優先級設計。

  • ESP32 PSRAM:ESP32可選的外部偽靜態RAM(Pseudo-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_token=YOUR_TOKEN

  • JWT(JSON Web Token):一種開放標準(RFC 7512),用於在各方之間安全地傳輸信息。在Verdure MCP中,使用Keycloak簽發的JWT進行用户認證,Token中包含用户ID、角色、過期時間等Claim信息。

  • API Token:一種簡單的認證方式,後續連接時攜帶此Token驗證身份。Verdure MCP同時支持API Token和JWT兩種方式。

img

核心技術架構

整個改造的架構可以用一張圖説明:

┌──────────────────────┐                          ┌──────────────────────┐
│   .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)      │  │ - 顯示通知     │  │
│  └────────────────┘  │                          │  └────────────────┘  │
└──────────────────────┘                          └──────────────────────┘

關鍵流程:

  1. 掃碼登錄:設備啓動後,如果沒有Token,調用本地MCP工具顯示二維碼,用户掃碼後獲取JWT Token
  2. 建立連接:攜帶JWT Token連接SignalR Hub,服務端驗證後加入用户羣組Users:{userId}
  3. 消息推送:服務端MCP工具執行完成後,通過SignalR將結果推送給設備
    • _hubContext.Clients.Group($"Users:{userId}").SendAsync("ShowImage", imageData)
    • 設備監聽事件並處理:connection->on("ShowImage", handler)

這套架構的核心價值就是讓服務端可以主動推送消息給設備,而不僅僅是等待設備輪詢或同步返回。

開發環境準備

ESP32開發環境(VS Code方式)

最簡單的方式是使用VS Code的ESP-IDF插件:

  1. 安裝VS Code和插件

    • 下載安裝 Visual Studio Code
    • 安裝擴展:Espressif IDF (搜索 esp-idf)
  2. 配置ESP-IDF

    • F1打開命令面板,輸入 ESP-IDF: Configure ESP-IDF Extension
    • 選擇 Express 快速配置
    • 選擇ESP-IDF版本(推薦v5.1或更高)
    • 等待安裝完成(會自動下載工具鏈、Python環境等)
  3. 創建/打開項目

    • F1ESP-IDF: Show Examples Projects
    • 或直接打開 esp-signalr-example 項目文件夾
  4. 編譯和燒錄

    • 點擊底部狀態欄的 BuildFlashMonitor 按鈕
    • 或按快捷鍵:Ctrl+E B(編譯)、Ctrl+E F(燒錄)

這種方式比命令行簡單很多,適合.NET開發者快速上手ESP32開發。

img

.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               # 項目説明

✨ 主要特性

  1. 開箱即用的服務器

    • 基於ASP.NET Core和SignalR構建
    • 支持消息廣播
    • 完整的連接管理和日誌輸出
    • 提供RESTful API用於設備控制
  2. 簡化的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 monitor

esp32的配置如下:

img

📊 運行效果

服務器輸出:
img

✓ 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.50

ESP32串口輸出:
img

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 ILogger<ChatHub> _logger;
    private static int _connectionCount = 0;
    
    // 存儲連接的設備信息
    private static readonly Dictionary<string, DeviceInfo> _connectedDevices = new();
    private static readonly object _devicesLock = new();

    public ChatHub(ILogger<ChatHub> 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.MapHub<ChatHub>("/chatHub");

// 監聽所有網絡接口(重要:局域網內ESP32能訪問)
app.Urls.Add("http://0.0.0.0:5000");

Console.WriteLine("SignalR Server: http://0.0.0.0:5000/chatHub");
app.Run();

關鍵點説明

  1. 連接確認機制:服務器在 OnConnectedAsync 中發送 Notification 消息,ESP32收到此消息才認為連接成功
  2. 消息廣播:使用 Clients.All.SendAsync() 向所有連接的客户端廣播消息
  3. 連接跟蹤:使用靜態字典 _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, 
    IHubContext<ChatHub> hubContext, 
    ILogger<Program> 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, 
    IHubContext<ChatHub> hubContext, 
    ILogger<Program> 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, 
    IHubContext<ChatHub> hubContext, 
    ILogger<Program> 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, 
    IHubContext<ChatHub> hubContext, 
    ILogger<Program> 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, 
    IHubContext<ChatHub> hubContext, 
    ILogger<Program> 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(
    IHubContext<ChatHub> hubContext, 
    ILogger<Program> 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; }
}

關鍵點説明

  1. IHubContext注入:使用 IHubContext<ChatHub> 在非Hub類中發送SignalR消息
  2. 消息格式:使用JSON格式的 CustomMessage 事件,包含 action 字段標識消息類型
  3. 定向推送
    • Clients.All.SendAsync() - 廣播給所有連接的設備
    • Clients.Client(connectionId).SendAsync() - 發送給指定設備
    • Clients.Group(groupName).SendAsync() - 發送給羣組(如 Users:{userId}
  4. RESTful API設計:提供HTTP端點控制設備,便於其他服務調用

服務端的接口圖片如下可以直接操作測試:

img

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_ptr<signalr::hub_connection> g_connection;
static bool g_is_connected = false;

// =============================================================================
// 消息處理器
// =============================================================================

/**
 * 處理服務器發送的消息
 */
static void on_receive_message(const std::vector<signalr::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());
    }
    
    ESP_LOGI(TAG, "==============================================");
}

/**
 * 處理通知消息(連接確認)
 */
static void on_notification(const std::vector<signalr::value>& args)
{
    if (args.empty()) return;
    
    std::string notification = args[0].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<signalr::value>& args)
{
    if (args.size() < 2) return;
    
    std::string sensor_id = args[0].as_string();
    double value = args[1].as_double();
    
    ESP_LOGI(TAG, "📊 Sensor Update: %s = %.2f", sensor_id.c_str(), value);
}

/**
 * 處理設備狀態更新
 */
static void on_device_status(const std::vector<signalr::value>& args)
{
    if (args.size() < 3) return;
    
    std::string device_id = args[0].as_string();
    std::string status = args[1].as_string();
    int free_heap = static_cast<int>(args[2].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<signalr::hub_connection>(
            signalr::hub_connection_builder::create(SIGNALR_HUB_URL)
                .with_websocket_factory([](const signalr::signalr_client_config& config) {
                    return std::make_shared<signalr::esp32_websocket_client>(config);
                })
                .with_http_client_factory([](const signalr::signalr_client_config& config) {
                    return std::make_shared<signalr::esp32_http_client>(config);
                })
                .with_automatic_reconnect()  // 啓用自動重連
                .skip_negotiation(true)      // 跳過協商,直接WebSocket
                .build());

        ESP_LOGI(TAG, "✓ SignalR connection object created");
        
    } catch (const std::exception& 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->on("ReceiveMessage", on_receive_message);
    ESP_LOGI(TAG, "✓ Registered handler: ReceiveMessage");

    // 註冊 "Notification" 事件(用於連接確認)
    g_connection->on("Notification", on_notification);
    ESP_LOGI(TAG, "✓ Registered handler: Notification");

    // 註冊 "UpdateSensorData" 事件
    g_connection->on("UpdateSensorData", on_sensor_update);
    ESP_LOGI(TAG, "✓ Registered handler: UpdateSensorData");

    // 註冊 "DeviceStatusUpdate" 事件
    g_connection->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->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& 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<signalr::value> args;
            args.push_back(signalr::value("ESP32-Device"));
            args.push_back(signalr::value(message));
            
            // 調用服務器的 SendMessage 方法
            g_connection->invoke("SendMessage", args, 
                [](const signalr::value& 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& 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.");
}

關鍵點説明

  1. 連接創建:使用 hub_connection_builder 構建連接,配置WebSocket客户端工廠
  2. 跳過協商skip_negotiation(true) 直接使用WebSocket,提高連接速度
  3. 消息處理器註冊:使用 connection->on("EventName", handler) 註冊事件監聽器
  4. 連接確認:通過接收 Notification 消息判斷連接成功(服務器在 OnConnectedAsync 中發送)
  5. 調用服務器方法:使用 invoke() 調用Hub方法,如 SendMessage

完整運行流程

1. WiFi連接成功
   ↓
2. 創建SignalR連接對象
   ↓
3. 註冊消息處理器(ReceiveMessage、Notification等)
   ↓
4. 調用 connection->start() 啓動連接
   ↓
5. 等待服務器發送 Notification 消息
   ↓
6. 收到 Notification,標記連接成功
   ↓
7. 定期調用 invoke("SendMessage") 發送消息
   ↓
8. 接收服務器廣播的消息,觸發對應處理器

示例輸出

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

5.2 實際整合代碼(生產環境完整實現)

説明:以下代碼來自小智AI項目的實際生產代碼,包含了完整的錯誤處理、狀態管理、JWT認證和自動重連機制。

實際項目代碼分為三個主要倉庫:

5.2.1 小智ESP32設備代碼

倉庫地址

  • 主倉庫:https://github.com/maker-community/xiaozhi-esp32
  • SignalR集成分支:signalrsignalr-update-audio
  • 完整示例工程:esp-signalr-example

注意:SignalR功能主要在 signalrsignalr-update-audio 兩個分支中實現,這兩個分支都是SignalR集成相關的開發分支。

核心文件

  • main/signalr_client.cc / main/signalr_client.h - SignalR客户端核心實現
  • main/application.cc / main/application.h - 主應用程序邏輯和狀態管理
  • main/protocols/websocket_protocol.cc - WebSocket協議實現
  • main/protocols/mqtt_protocol.cc - MQTT協議實現
  • main/mcp_server.cc - MCP服務器實現

實際實現特點

與示例代碼相比,生產環境實現增加了:

  1. 完整的生命週期管理

    • 連接建立、斷開重連、資源清理
    • 設備狀態機管理(空閒、連接中、監聽、説話等)
  2. 協議版本支持

    • 支持WebSocket和MQTT兩種傳輸協議
    • 協議層抽象,易於擴展新協議
  3. 音頻流處理

    • 實時音頻數據的編碼、傳輸和接收
    • 音頻分塊傳輸(重要!)- 解決大數據傳輸導致連接斷開的問題
    • 支持Opus編解碼
  4. MCP工具集成

    • 完整的MCP Server實現
    • 工具註冊、調用和響應機制
    • 支持異步工具執行
  5. SignalR客户端封裝

    • 完整的連接生命週期管理
    • JWT Token認證
    • 自動重連機制(指數退避)
    • 設備註冊和心跳保持
    • 自定義消息處理
SignalR客户端核心實現 (signalr_client.cc)

這是整個SignalR集成的核心代碼,封裝了所有與SignalR通信相關的邏輯。

完整代碼:signalr_client.cc (850行)

關鍵實現要點

1. 單例模式管理 - 全局唯一實例

SignalRClient& SignalRClient::GetInstance() {
    static SignalRClient instance;
    return instance;
}

2. JWT Token認證 - 通過Query String傳遞

bool SignalRClient::Initialize(const std::string& hub_url, const std::string& 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& config) {
        auto client = std::make_shared<signalr::esp32_websocket_client>(config);
        return client;
    });
    
    // Skip negotiation (direct WebSocket connection)
    builder.skip_negotiation(true);
    
    // Build connection
    connection_ = std::make_unique<signalr::hub_connection>(builder.build());
}

3. 超時和心跳配置

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_->set_client_config(cfg);

4. 連接確認和自動註冊

// Register Notification handler to confirm connection
connection_->on("Notification", [this](const std::vector<signalr::value>& args) {
    if (args.empty()) return;
    
    std::string message = args[0].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& result) {
            if (success) {
                ESP_LOGI(TAG, "✅ Device auto-registration successful");
            }
        });
    }
});

5. 自定義消息處理

connection_->on("CustomMessage", [this](const std::vector<signalr::value>& args) {
    if (args.empty()) return;
    
    try {
        std::string json_str = args[0].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& e) {
        ESP_LOGE(TAG, "Exception handling CustomMessage: %s", e.what());
    }
});

6. 自動重連機制 - 使用PSRAM棧的後台任務

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() && 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
        }
    }
}

7. 設備註冊和心跳

void SignalRClient::RegisterDevice(
    const std::string& mac_address,
    const std::string& device_token,
    const std::string& metadata,
    std::function<void(bool, const std::string&)> callback) {
    
    if (!IsConnected()) {
        if (callback) callback(false, "Not connected");
        return;
    }
    
    std::vector<signalr::value> args;
    args.push_back(signalr::value(mac_address));
    args.push_back(signalr::value(device_token));
    args.push_back(signalr::value(metadata));
    
    connection_->invoke("RegisterDevice", args,
        [callback](const signalr::value& 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<void(bool, const std::string&)> callback) {
    
    if (!IsConnected()) {
        if (callback) callback(false, "Not connected");
        return;
    }
    
    std::vector<signalr::value> args;
    connection_->invoke("Heartbeat", args,
        [callback](const signalr::value& result, std::exception_ptr ex) {
            if (!ex) {
                ESP_LOGD(TAG, "💓 Heartbeat sent");
                if (callback) callback(true, "Success");
            }
        });
}

SignalR客户端類定義 (signalr_client.h):

class SignalRClient {
public:
    static SignalRClient& GetInstance();
    
    // 連接管理
    bool Initialize(const std::string& hub_url, const std::string& 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<void(const cJSON*)> callback);
    void OnDeviceRegistered(std::function<void(const cJSON*)> callback);
    
    // Hub方法調用
    void RegisterDevice(const std::string& mac_address,
                       const std::string& device_token,
                       const std::string& metadata,
                       std::function<void(bool, const std::string&)> callback);
    void SendHeartbeat(std::function<void(bool, const std::string&)> callback);
    void InvokeHubMethod(const std::string& method_name,
                        const std::string& args_json,
                        std::function<void(bool, const std::string&)> callback);

private:
    SignalRClient();
    ~SignalRClient();
    
    std::unique_ptr<signalr::hub_connection> connection_;
    std::string hub_url_;
    std::string token_;
    bool initialized_ = false;
    bool connection_confirmed_ = false;
    std::atomic<bool> reconnect_requested_{false};
    
    // 回調函數
    std::function<void(const cJSON*)> on_custom_message_;
    std::function<void(const cJSON*)> on_device_registered_;
    
    // 重連任務
    TaskHandle_t reconnect_task_handle_ = nullptr;
    int reconnect_backoff_ms_ = 1000;
};

使用示例

// 在主應用中使用SignalR客户端
void Application::InitializeSignalR() {
    auto& client = SignalRClient::GetInstance();
    
    // 設置消息回調
    client.OnCustomMessage([this](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();  // 啓動自動重連任務
        }
    }
}
Application層集成代碼

核心代碼片段 (application.cc):


void Application::HandleSignalRMessage(const std::string& 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->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->valuestring : Lang::Strings::INFO;
            const char* content_str = cJSON_IsString(content) ? content->valuestring : "";
            const char* emotion_str = cJSON_IsString(emotion) ? emotion->valuestring : "bell";
            
            // Select sound based on "sound" field
            std::string_view sound_view = Lang::Sounds::OGG_POPUP;
            if (cJSON_IsString(sound)) {
                if (strcmp(sound->valuestring, "success") == 0) {
                    sound_view = Lang::Sounds::OGG_SUCCESS;
                } else if (strcmp(sound->valuestring, "vibration") == 0) {
                    sound_view = Lang::Sounds::OGG_VIBRATION;
                } else if (strcmp(sound->valuestring, "exclamation") == 0) {
                    sound_view = Lang::Sounds::OGG_EXCLAMATION;
                } else if (strcmp(sound->valuestring, "low_battery") == 0) {
                    sound_view = Lang::Sounds::OGG_LOW_BATTERY;
                } else if (strcmp(sound->valuestring, "none") == 0) {
                    sound_view = "";
                }
                // default: popup
            }
            
            Alert(title_str, content_str, emotion_str, sound_view);
            
        } else if (strcmp(action->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->valuestring, "reboot") == 0) {
                    Reboot();
                } else if (strcmp(cmd->valuestring, "wake") == 0) {
                    // Trigger wake word detection
                    xEventGroupSetBits(event_group_, MAIN_EVENT_WAKE_WORD_DETECTED);
                } else if (strcmp(cmd->valuestring, "listen") == 0) {
                    StartListening();
                } else if (strcmp(cmd->valuestring, "stop") == 0) {
                    StopListening();
                } else {
                    ESP_LOGW(TAG, "Unknown SignalR command: %s", cmd->valuestring);
                }
            }
            
        } else if (strcmp(action->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->valuestring : "system";
            if (cJSON_IsString(content)) {
                display->SetChatMessage(role_str, content->valuestring);
            }
            
        } else if (strcmp(action->valuestring, "emotion") == 0) {
            // Change emotion/expression
            // JSON: {"action":"emotion", "emotion":"happy"}
            auto emotion = cJSON_GetObjectItem(root, "emotion");
            if (cJSON_IsString(emotion)) {
                display->SetEmotion(emotion->valuestring);
            }
            
        } else if (strcmp(action->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->valuestring);
            } else {
                ESP_LOGW(TAG, "Image action requires 'url' field");
            }
            
        } else if (strcmp(action->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->valuestring);
            } else {
                ESP_LOGW(TAG, "Audio action requires 'url' field");
            }
            
        } else if (strcmp(action->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->valuestring : nullptr;
                const char* subtitle_str = cJSON_IsString(subtitle) ? subtitle->valuestring : nullptr;
                display->ShowQRCode(data->valuestring, title_str, subtitle_str);
            } else {
                ESP_LOGW(TAG, "QRCode action requires 'data' field");
            }
            
        } else if (strcmp(action->valuestring, "hide_qrcode") == 0) {
            // Hide QR code
            // JSON: {"action":"hide_qrcode"}
            display->HideQRCode();
            
        } else {
            // Default: display as system message
            char* display_str = cJSON_Print(root);
            if (display_str) {
                display->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->SetChatMessage("system", display_str);
            cJSON_free(display_str);
        }
    }
    
    cJSON_Delete(root);
}

完整的Application類功能

  • ✅ 設備狀態管理 (狀態機)
  • ✅ 網絡事件處理 (連接/斷開)
  • ✅ 音頻服務集成 (編解碼、流處理)
  • ✅ 喚醒詞檢測
  • ✅ 協議層抽象 (WebSocket/MQTT)
  • ✅ MCP消息路由
  • ✅ 錯誤處理和恢復
  • ✅ 資源管理和清理
  • ✅ 線程安全的消息調度

5.2.2 MCP服務器代碼 (verdure-mcp)

倉庫地址:verdure-mcp

目錄結構

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         # 工具調用日誌

DeviceHub完整實現 (Hubs/DeviceHub.cs):

生產環境的DeviceHub實際實現特點:

  • ✅ 數據庫持久化 (Entity Framework Core + PostgreSQL)
  • ✅ 設備註冊和狀態跟蹤
  • ✅ 用户和設備分組管理
  • ✅ 心跳檢測
  • ✅ 雙重認證 (JWT + API Token)
  • ✅ 完善的異常處理和日誌
using Microsoft.AspNetCore.SignalR;
using Verdure.Mcp.Infrastructure.Database;
using Verdure.Mcp.Infrastructure.Services;

namespace Verdure.Mcp.Server.Hubs;

/// <summary>
/// 設備連接Hub - 處理ESP32等IoT設備的SignalR連接
/// </summary>
public class DeviceHub : Hub
{
    private readonly McpDbContext _dbContext;
    private readonly ITokenValidationService _tokenValidationService;
    private readonly ILogger<DeviceHub> _logger;

    public DeviceHub(
        McpDbContext dbContext,
        ITokenValidationService tokenValidationService,
        ILogger<DeviceHub> logger)
    {
        _dbContext = dbContext;
        _tokenValidationService = tokenValidationService;
        _logger = logger;
    }

    /// <summary>
    /// 設備連接 - 支持JWT和API Token雙重認證
    /// </summary>
    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();
        }
    }

    /// <summary>
    /// 設備斷開連接
    /// </summary>
    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);
    }

    /// <summary>
    /// 設備註冊 - 保存設備MAC地址和元數據
    /// </summary>
    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 => 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");
        }
    }

    /// <summary>
    /// 心跳保持
    /// </summary>
    public async Task Heartbeat()
    {
        _logger.LogDebug("Heartbeat from ConnectionId={ConnectionId}", Context.ConnectionId);
        
        // 更新最後活躍時間
        // 注意:實際代碼中可以根據ConnectionId查找設備並更新LastSeenAt
        await Task.CompletedTask;
    }
}

實際的MCP工具實現 (不使用基類):

在verdure-mcp中,MCP工具不繼承任何基類,而是:

  1. 使用 [McpServerToolType] 特性標記
  2. 通過依賴注入獲取 IDevicePushService 服務
  3. 使用 IDevicePushService 的方法推送消息給設備

DevicePushService接口 (Infrastructure/Services/DevicePushService.cs):

namespace Verdure.Mcp.Infrastructure.Services;

/// <summary>
/// 設備推送服務接口
/// </summary>
public interface IDevicePushService
{
    /// <summary>
    /// 向用户的所有設備發送消息
    /// </summary>
    Task SendToUserAsync(string userId, string method, object message, 
        CancellationToken cancellationToken = default);

    /// <summary>
    /// 向指定設備發送消息
    /// </summary>
    Task SendToDeviceAsync(string deviceId, string method, object message, 
        CancellationToken cancellationToken = default);

    /// <summary>
    /// 發送自定義消息 (xiaozhi協議格式)
    /// </summary>
    Task SendCustomMessageAsync(string userId, object message, 
        CancellationToken cancellationToken = default);

    /// <summary>
    /// 發送通知消息
    /// </summary>
    Task SendNotificationAsync(string userId, string notificationMessage, 
        CancellationToken cancellationToken = default);
}

DevicePushService實現 (Server/Services/DevicePushServiceImpl.cs):

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<DeviceHub> _hubContext;
    private readonly ILogger<DevicePushServiceImpl> _logger;

    public DevicePushServiceImpl(
        IHubContext<DeviceHub> hubContext,
        ILogger<DevicePushServiceImpl> 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);
    }
}

音樂播放工具實際實現 (Tools/MusicTool.cs):

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;

/// <summary>
/// MCP Tool to pick a random audio file from wwwroot and push its URL to device(s).
/// </summary>
[McpServerToolType]
public class MusicTool
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly IWebHostEnvironment _env;
    private readonly IDevicePushService _devicePushService;
    private readonly ILogger<MusicTool> _logger;
    private readonly IBackgroundJobClient _backgroundJobClient;
    private readonly ImageStorageSettings _imageStorageSettings;

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

    /// <summary>
    /// 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 = "...").
    /// </summary>
    [McpServerTool(Name = "play_random_music")]
    [Description("Plays a random audio file from wwwroot/audio by pushing an audio message to the user's devices")]
    public async Task<MusicResponse> 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 => 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[rnd.Next(files.Length)];
            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<MusicPushBackgroundJob>(
                    job => 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; }
}

延遲推送後台任務 (Tools/MusicPushBackgroundJob.cs):

using Verdure.Mcp.Infrastructure.Services;
using Verdure.Mcp.Server.Services;

namespace Verdure.Mcp.Server.Tools;

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

    public MusicPushBackgroundJob(IDevicePushService devicePushService, ILogger<MusicPushBackgroundJob> 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);
        }
    }
}

5.2.3 圖片生成工具實現示例

倉庫地址:verdure-mcp - GenerateImageTool

這個工具展示瞭如何生成圖片並推送到設備:

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;

/// <summary>
/// 使用 Azure OpenAI DALL-E 生成圖片的 MCP 工具
/// </summary>
[McpServerToolType]
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<GenerateImageTool> _logger;

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

    /// <summary>
    /// 使用 Azure OpenAI DALL-E 根據提示詞生成圖片。
    /// 如果提供郵箱地址,會將生成的圖片發送到指定郵箱。
    /// 如果請求頭中包含用户信息(X-User-Email 和 X-User-Id),任務將異步運行。
    /// </summary>
    /// <param name="prompt">描述要生成圖片的文本提示詞</param>
    /// <param name="size">圖片尺寸:"1024x1024"、"1792x1024" 或 "1024x1792",默認為 "1024x1024"</param>
    /// <param name="quality">圖片質量:"standard" 或 "hd",默認為 "standard"</param>
    /// <param name="style">圖片風格:"vivid" 或 "natural",默認為 "vivid"</param>
    /// <param name="cancellationToken"></param>
    /// <returns>包含任務信息和圖片數據的 JSON 對象(同步模式下)</returns>
    [McpServerTool(Name = "generate_image")]
    [Description("使用 DALL-E 模型,根據文本提示詞生成圖片。支持郵件通知和異步處理。")]
    public async Task<ImageGenerationResponse> GenerateImage(
        [Description("描述要生成圖片的文本提示詞")] string prompt,
        [Description("圖片尺寸:'1024x1024'、'1792x1024' 或 '1024x1792',默認為 '1024x1024'")] string? size = null,
        [Description("圖片質量:'standard' 或 'hd',默認為 'standard'")] string? quality = null,
        [Description("圖片風格:'vivid' 或 'natural',默認為 'vivid'")] 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) && !string.IsNullOrEmpty(userId))
        {
            _logger.LogInformation("檢測到用户信息,使用異步處理任務 {TaskId}", task.Id);
            
            var jobId = _backgroundJobClient.Enqueue<ImageGenerationBackgroundJob>(
                job => 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) && !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,
                                "您的圖片已生成",
                                $"<h1>您的圖片已成功生成!</h1><p>提示詞:{encodedPrompt}</p><p>修訂後的提示詞:{encodedRevisedPrompt}</p>",
                                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) && 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
                };
            }
        }
    }

    /// <summary>
    /// 獲取圖片生成任務的狀態
    /// </summary>
    /// <param name="taskId">要查詢的任務 ID</param>
    /// <returns>任務狀態和結果(如果已完成)</returns>
    [McpServerTool(Name = "get_image_task_status")]
    [Description("獲取圖片生成任務的狀態")]
    public async Task<ImageGenerationResponse> GetImageTaskStatus(
        [Description("要查詢的任務 ID")] 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 => "任務等待中",
            ImageTaskStatus.Processing => "任務處理中",
            ImageTaskStatus.Completed => "圖片生成成功",
            ImageTaskStatus.Failed => "圖片生成失敗",
            ImageTaskStatus.Cancelled => "任務已取消",
            _ => "未知狀態"
        };
    }
}

/// <summary>
/// 圖片生成響應模型
/// </summary>
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; }
}

實際項目特點

  1. 雙模式支持

    • 同步模式:立即生成並返回結果
    • 異步模式:使用Hangfire後台任務,完成後通過郵件通知
  2. 多渠道推送

    • 通過SignalR推送到設備(CustomMessage)
    • 通過郵件發送鏈接
  3. 圖片存儲

    • 同時保存PNG和JPEG兩種格式
    • 存儲到Azure Blob Storage或本地文件系統
  4. 完整的錯誤處理和日誌記錄

獲取完整代碼

  • 🔧 ESP32設備端 (小智完整實現):
    https://github.com/maker-community/xiaozhi-esp32

    • SignalR集成分支:signalrsignalr-update-audio
  • 🔧 ESP32示例工程 (包含完整的SignalR集成示例):
    https://github.com/maker-community/esp-signalr-example

  • 🔧 SignalR C++客户端庫:
    https://github.com/maker-community/esp-signalr
    https://github.com/maker-community/esp-signalr-example

  • 🌐 MCP服務器端:
    https://github.com/maker-community/verdure-mcp

實際使用場景示例

場景1:AI生圖後推送到設備

用户通過小智對話:"幫我生成一張貓咪的圖片"

  1. 對話發送到.NET MCP服務
  2. MCP服務調用API生成圖片
  3. 生成完成後,後台任務調用hub發送消息
  4. 服務端通過SignalR推送圖片給該用户的所有設備
  5. ESP32設備接收到自定義消息事件,下載並顯示在屏幕上

場景2:播放音樂

用户通過語音説想要播放音樂,mcp被觸發,隨機選擇音樂url推送到設備

  1. 對話發送到.NET MCP服務
  2. 服務端通過SignalR推送音樂給該用户的所有設備
  3. ESP32設備接收到自定義消息事件,下載並播放語音

內存優化經驗分享

説明:本章節內容對於在ESP32上運行SignalR客户端非常重要,這些都是實踐中總結出的經驗。

在ESP32上集成SignalR確實遇到了內存問題,分享一些優化經驗:

1. 使用PSRAM存儲大對象

// 為圖片數據分配PSRAM內存
void* imageBuffer = heap_caps_malloc(imageSize, MALLOC_CAP_SPIRAM);
if (imageBuffer == NULL) {
    // 降級到內部RAM
    imageBuffer = malloc(imageSize);
}

在VS Code的menuconfig中啓用PSRAM:

  • F1ESP-IDF: SDK Configuration editor
  • Component configESP32-specificSupport for external, SPI-connected RAM

2. 減少JSON序列化次數

// 錯誤做法:頻繁創建/銷燬cJSON對象
for (int i = 0; i < 100; i++) {
    cJSON* root = cJSON_Parse(jsonString);
    // 處理...
    cJSON_Delete(root);  // 產生內存碎片
}

// 正確做法:複用對象
cJSON* root = cJSON_Parse(jsonString);
for (int i = 0; i < 100; i++) {
    // 處理...
}
cJSON_Delete(root);

3. 增加任務棧大小

SignalR的回調嵌套較深,需要增加棧:

在menuconfig中:Component configESP32-specificMain task stack size → 設置為8192

或在代碼中:

xTaskCreatePinnedToCore(
    signalr_task,
    "signalr",
    8192,  // 棧大小(字節)
    NULL,
    5,     // 優先級
    NULL,
    1      // CPU核心
);

總結與感悟

通過這次SignalR移植和集成的實踐,我深刻體會到:

  1. 選對框架很重要:SignalR的羣組管理、消息路由等特性,省去了大量基礎設施代碼。如果從頭手寫WebSocket,這些功能得花幾周時間。

  2. 內存管理是嵌入式永恆的主題:ESP32的RAM限制讓我對每一個malloc都格外小心。合理使用PSRAM、避免內存碎片、及時釋放資源,這些在PC上不用care的問題,在嵌入式上都是坑。

  3. AI輔助編程真香:這次項目中,SignalR C++客户端的移植、消息處理等大量代碼都是藉助AI生成的。雖然生成的代碼需要調試和優化,但確實大幅提高了開發效率。

  4. 消息推送解決實際問題:之前MCP工具只能同步返回結果,現在通過SignalR,服務端可以主動推送圖片、語音、通知給設備,用户體驗提升明顯。

  5. .NET生態的強大:SignalR、EF Core、JWT認證……微軟這套生態真的很完善。作為.NET開發者,能用熟悉的技術棧快速搭建生產級服務。

這個項目還有很多優化空間,比如:

  • WebSocket Binary Protocol替代JSON減少帶寬
  • 引入Redis存儲羣組信息,支持服務端橫向擴展
  • 繼續完善代碼質量,讓庫能夠被更多的人關注和參與,希望有更多的人來實際使用和優化。

但作為一個初步能用的方案,已經足夠支撐小智機器人的功能擴展了。後續我會繼續完善這套架構,歡迎大家一起探討和貢獻代碼!

希望這篇文章能給大家在.NET IoT開發、SignalR實時通信方面帶來一些啓發。如果有問題歡迎在評論區討論,讓我們一起探索.NET在IoT領域的更多可能性!

手搓ESP32小機器人

如果你有手搓Esp32的硬件打算,可以關注我的B站賬號(綠蔭阿廣)
https://space.bilibili.com/25228512

img

項目地址

ESP32相關

  • SignalR C++客户端庫
    https://github.com/maker-community/esp-signalr

  • 小智ESP32完整實現 (signalr-update-audio分支):
    https://github.com/maker-community/xiaozhi-esp32

  • ESP32 SignalR完整示例工程 (包含音頻分塊、設備控制API等):
    https://github.com/maker-community/esp-signalr-example

    • 服務端代碼
    • 客户端代碼

.NET服務端

  • Verdure MCP服務 (包含完整的Hub、Tools、Services實現):
    https://github.com/maker-community/verdure-mcp
  • 小智mcp轉接平台
  • https://github.com/maker-community/verdure-mcp-for-xiaozhi

參考資料

  • SignalR Client C++ (微軟官方):
    https://github.com/aspnet/SignalR-Client-Cpp
  • SignalR Client C# nanoframework:
    https://github.com/nanoframework/nanoFramework.SignalR.Client

參考文檔

  1. ASP.NET Core SignalR 官方文檔
  2. SignalR Hub 協議規範
  3. ESP-IDF 編程指南
  4. VS Code ESP-IDF 插件
  5. JWT 認證最佳實踐
  6. Keycloak 認證集成指南

本文首發於個人技術博客,轉載請註明出處。如果對.NET IoT開發、SignalR實時通信感興趣,歡迎關注我的博客獲取更多技術分享!

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.