增加了通过网络主动上报图像的支持
增加了指令维护通道的支持
This commit is contained in:
@@ -1,40 +1,48 @@
|
||||
using NetMQ;
|
||||
using NetMQ.Sockets;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace SHH.CameraDashboard.Services;
|
||||
namespace SHH.CameraDashboard;
|
||||
|
||||
/// <summary>
|
||||
/// [Dashboard端] 指令控制服务
|
||||
/// 职责:双向通信通道。接收 Service 心跳/响应,向 Service 发送控制指令。
|
||||
/// 核心模式:ROUTER (Dashboard) <--> DEALER (Service)
|
||||
/// 职责:监听 6001 端口,接收 CameraService 的注册/心跳,并下发控制指令。
|
||||
/// </summary>
|
||||
public class CommandServer : IDisposable
|
||||
{
|
||||
// 单例模式
|
||||
public static CommandServer Instance { get; } = new CommandServer();
|
||||
|
||||
// 事件:收到消息时触发 (ServiceId, MessageContent)
|
||||
// =================================================================
|
||||
// 事件定义
|
||||
// =================================================================
|
||||
|
||||
// 当有新设备注册成功时触发 (UI 可以订阅这个来刷新列表)
|
||||
public event Action<ConnectedClient>? OnClientRegistered;
|
||||
|
||||
// 当收到通用业务消息时触发
|
||||
public event Action<string, string>? OnMessageReceived;
|
||||
|
||||
// =================================================================
|
||||
// 内部成员
|
||||
// =================================================================
|
||||
private RouterSocket? _routerSocket;
|
||||
private NetMQPoller? _poller;
|
||||
|
||||
// 【关键新增】发送队列:用于解决跨线程发送的安全问题
|
||||
// UI线程 -> Enqueue -> Poller线程 -> Socket.Send
|
||||
private NetMQQueue<CommandPacket>? _sendQueue;
|
||||
|
||||
// 在线设备表 (Key: Identity/AppId)
|
||||
// 线程安全字典,存储客户端的详细信息(包括视频地址)
|
||||
private readonly ConcurrentDictionary<string, ConnectedClient> _clients = new();
|
||||
|
||||
public int ListenPort { get; private set; }
|
||||
public bool IsRunning => _poller != null && _poller.IsRunning;
|
||||
|
||||
// 在线设备表 (可选,用于记录谁在线)
|
||||
// Key: ServiceId (Identity字符串)
|
||||
private readonly ConcurrentDictionary<string, DateTime> _onlineClients = new();
|
||||
// 获取当前所有在线客户端的副本
|
||||
public List<ConnectedClient> GetClients() => _clients.Values.ToList();
|
||||
|
||||
private CommandServer() { }
|
||||
|
||||
@@ -47,17 +55,15 @@ public class CommandServer : IDisposable
|
||||
{
|
||||
// 1. 初始化 Router Socket
|
||||
_routerSocket = new RouterSocket();
|
||||
_routerSocket.Bind($"tcp://*:{ListenPort}");
|
||||
_routerSocket.Bind($"tcp://*:{ListenPort}"); // 监听所有网卡
|
||||
_routerSocket.ReceiveReady += OnSocketReady;
|
||||
|
||||
// 2. 初始化发送队列
|
||||
// 2. 初始化发送队列 (确保 UI 线程可以安全发送)
|
||||
_sendQueue = new NetMQQueue<CommandPacket>();
|
||||
_sendQueue.ReceiveReady += OnQueueReady;
|
||||
|
||||
// 3. 启动 Poller (同时监听 Socket 接收 和 队列发送)
|
||||
// 3. 启动 Poller
|
||||
_poller = new NetMQPoller { _routerSocket, _sendQueue };
|
||||
|
||||
// RunAsync 会自动开启后台线程
|
||||
_poller.RunAsync();
|
||||
|
||||
Console.WriteLine($"[Dashboard] 指令服务启动,监听: tcp://*:{ListenPort}");
|
||||
@@ -65,33 +71,37 @@ public class CommandServer : IDisposable
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Dashboard] 指令端口绑定失败: {ex.Message}");
|
||||
throw; // 必须抛出,让 App 感知
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理来自 Service 的网络消息 (运行在 Poller 线程)
|
||||
/// [Poller线程] 处理网络接收
|
||||
/// </summary>
|
||||
private void OnSocketReady(object? sender, NetMQSocketEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. 读取身份帧 (Identity)
|
||||
// 只要 Service 端 DealerSocket 设置了 Identity,这里收到就是那个 ID
|
||||
// Router 接收逻辑:
|
||||
// Frame 1: 发送者的 Identity (NetMQ 自动处理)
|
||||
// Frame 2: 真实数据
|
||||
|
||||
// 1. 读取身份 (Identity)
|
||||
var identityBytes = e.Socket.ReceiveFrameBytes();
|
||||
string serviceId = Encoding.UTF8.GetString(identityBytes);
|
||||
string serviceId = Encoding.UTF8.GetString(identityBytes); // e.g., "CameraApp_01"
|
||||
|
||||
// 2. 读取内容帧 (假设 Dealer 直接发内容,中间无空帧)
|
||||
// 如果你使用了 REQ/REP 模式,中间可能会有空帧,需注意兼容
|
||||
// 2. 读取消息内容
|
||||
// 兼容性处理:有些 Dealer 实现可能会发空帧,这里做个简单尝试
|
||||
// 如果发现在 Identity 后紧跟的是空帧,则再读一帧
|
||||
// 但在我们目前的 Dealer 实现中,是直接发的 JSON
|
||||
string message = e.Socket.ReceiveFrameString();
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
if (e.Socket.HasIn) message = e.Socket.ReceiveFrameString();
|
||||
}
|
||||
|
||||
// 3. 简单的心跳保活逻辑
|
||||
_onlineClients[serviceId] = DateTime.Now;
|
||||
|
||||
// 4. 触发业务事件
|
||||
// 注意:这依然在 Poller 线程,UI 处理时需 Invoke
|
||||
Console.WriteLine($"[指令] From {serviceId}: {message}");
|
||||
OnMessageReceived?.Invoke(serviceId, message);
|
||||
// 3. 协议解析与业务分发
|
||||
ProcessMessage(serviceId, message, identityBytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -100,37 +110,99 @@ public class CommandServer : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理发送队列 (运行在 Poller 线程)
|
||||
/// 核心业务逻辑处理
|
||||
/// </summary>
|
||||
private void OnQueueReady(object? sender, NetMQQueueEventArgs<CommandPacket> e)
|
||||
private void ProcessMessage(string serviceId, string json, byte[] identityBytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_routerSocket == null) return;
|
||||
// 尝试解析基础结构
|
||||
var jObj = JObject.Parse(json);
|
||||
string action = jObj["Action"]?.ToString() ?? "Unknown";
|
||||
|
||||
// 从队列取出一个包
|
||||
if (e.Queue.TryDequeue(out var packet, TimeSpan.Zero))
|
||||
// 更新最后心跳时间 (如果已存在)
|
||||
if (_clients.TryGetValue(serviceId, out var existingClient))
|
||||
{
|
||||
// Router 发送标准三步走:
|
||||
// 1. 发送目标 Identity (More = true)
|
||||
// 2. 发送空帧 (可选,取决于协议约定,Router-Dealer 直连通常不需要空帧)
|
||||
// 3. 发送数据 (More = false)
|
||||
existingClient.LastHeartbeat = DateTime.Now;
|
||||
}
|
||||
|
||||
// 这里我们采用最简协议:[Identity][Data]
|
||||
_routerSocket.SendMoreFrame(packet.TargetId)
|
||||
.SendFrame(packet.JsonData);
|
||||
|
||||
Console.WriteLine($"[指令] To {packet.TargetId}: {packet.JsonData}");
|
||||
// ★★★ 处理注册握手 ★★★
|
||||
if (action == "Register")
|
||||
{
|
||||
HandleRegistration(serviceId, jObj, identityBytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 其他业务消息,透传给上层
|
||||
Console.WriteLine($"[指令] From {serviceId}: {json}");
|
||||
OnMessageReceived?.Invoke(serviceId, json);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (JsonException)
|
||||
{
|
||||
Debug.WriteLine($"[Command Send Error] {ex.Message}");
|
||||
Console.WriteLine($"[指令] 收到非 JSON 消息 From {serviceId}: {json}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发送指令 (线程安全,可由 UI 线程调用)
|
||||
/// 处理注册逻辑
|
||||
/// </summary>
|
||||
private void HandleRegistration(string serviceId, JObject jObj, byte[] identityBytes)
|
||||
{
|
||||
var payload = jObj["Payload"];
|
||||
if (payload == null) return;
|
||||
|
||||
// 1. 提取客户端信息
|
||||
var client = new ConnectedClient
|
||||
{
|
||||
ServiceId = serviceId,
|
||||
Ip = payload["Ip"]?.ToString() ?? "Unknown",
|
||||
|
||||
// ★★★ 解析新字段 ★★★
|
||||
WebPort = payload["WebPort"]?.Value<int>() ?? 5000,
|
||||
Version = payload["Version"]?.ToString() ?? "Unknown",
|
||||
Pid = payload["Pid"]?.Value<int>() ?? 0,
|
||||
|
||||
TargetVideoNodes = payload["TargetVideoNodes"]?.ToObject<List<string>>() ?? new List<string>(),
|
||||
LastHeartbeat = DateTime.Now
|
||||
};
|
||||
|
||||
// 2. 存入内存表 (Add or Update)
|
||||
_clients.AddOrUpdate(serviceId, client, (key, old) => client);
|
||||
|
||||
Console.WriteLine($"[注册成功] {serviceId}");
|
||||
|
||||
// 3. 回复 ACK (握手确认)
|
||||
// 告诉客户端:我收到你的注册了,连接建立成功
|
||||
var ackPacket = new { Action = "ACK", Message = $"Registered {serviceId}", Time = DateTime.Now };
|
||||
string ackJson = JsonConvert.SerializeObject(ackPacket);
|
||||
|
||||
// 直接在 Poller 线程发回,不需要走 Queue (因为拥有 Socket 所有权)
|
||||
_routerSocket?.SendMoreFrame(identityBytes).SendFrame(ackJson);
|
||||
|
||||
// 4. 通知 UI 更新列表
|
||||
OnClientRegistered?.Invoke(client);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [Poller线程] 处理发送队列
|
||||
/// </summary>
|
||||
private void OnQueueReady(object? sender, NetMQQueueEventArgs<CommandPacket> e)
|
||||
{
|
||||
if (_routerSocket == null) return;
|
||||
|
||||
if (e.Queue.TryDequeue(out var packet, TimeSpan.Zero))
|
||||
{
|
||||
// Router 发送:[Identity] [Data]
|
||||
_routerSocket.SendMoreFrame(packet.TargetId)
|
||||
.SendFrame(packet.JsonData);
|
||||
|
||||
Console.WriteLine($"[发送] To {packet.TargetId}: {packet.JsonData}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [公共API] 向指定 Service 发送指令
|
||||
/// </summary>
|
||||
public void SendCommand(string targetServiceId, object commandData)
|
||||
{
|
||||
@@ -138,7 +210,6 @@ public class CommandServer : IDisposable
|
||||
|
||||
var json = JsonConvert.SerializeObject(commandData);
|
||||
|
||||
// ★★★ 核心修复:不直接操作 Socket,而是入队 ★★★
|
||||
_sendQueue.Enqueue(new CommandPacket
|
||||
{
|
||||
TargetId = targetServiceId,
|
||||
@@ -152,13 +223,12 @@ public class CommandServer : IDisposable
|
||||
_poller?.Dispose();
|
||||
_routerSocket?.Dispose();
|
||||
_sendQueue?.Dispose();
|
||||
|
||||
_poller = null;
|
||||
_routerSocket = null;
|
||||
_sendQueue = null;
|
||||
}
|
||||
|
||||
// 内部数据包结构
|
||||
// =============================================================
|
||||
// 数据模型
|
||||
// =============================================================
|
||||
|
||||
private class CommandPacket
|
||||
{
|
||||
public string TargetId { get; set; } = "";
|
||||
|
||||
30
SHH.CameraDashboard/Invokes/ConnectedClient.cs
Normal file
30
SHH.CameraDashboard/Invokes/ConnectedClient.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
namespace SHH.CameraDashboard;
|
||||
|
||||
/// <summary>
|
||||
/// 在线客户端信息模型 (已更新)
|
||||
/// </summary>
|
||||
public class ConnectedClient
|
||||
{
|
||||
/// <summary> 唯一标识 (AppId) </summary>
|
||||
public string ServiceId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary> 版本号 </summary>
|
||||
public string Version { get; set; } = "1.0.0";
|
||||
|
||||
/// <summary> 远程进程 ID </summary>
|
||||
public int Pid { get; set; }
|
||||
|
||||
/// <summary> 客户端 IP </summary>
|
||||
public string Ip { get; set; } = string.Empty;
|
||||
|
||||
/// <summary> WebAPI 端口 (Dashboard 调用 REST 接口用) </summary>
|
||||
public int WebPort { get; set; }
|
||||
|
||||
/// <summary> 该客户端正在推流的目标地址 </summary>
|
||||
public List<string> TargetVideoNodes { get; set; } = new List<string>();
|
||||
|
||||
public DateTime LastHeartbeat { get; set; }
|
||||
|
||||
// 辅助属性:拼接出完整的 API BaseUrl
|
||||
public string WebApiUrl => $"http://{Ip}:{WebPort}";
|
||||
}
|
||||
@@ -1,67 +1,54 @@
|
||||
using NetMQ;
|
||||
using NetMQ.Sockets;
|
||||
using System.Diagnostics; // 用于 Debug 输出
|
||||
using Newtonsoft.Json;
|
||||
using SHH.Contracts; // ★★★ 必须引用契约库 ★★★
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace SHH.CameraDashboard.Services;
|
||||
namespace SHH.CameraDashboard;
|
||||
|
||||
public class StreamReceiverService : IDisposable
|
||||
{
|
||||
// 单例模式
|
||||
public static StreamReceiverService Instance { get; } = new StreamReceiverService();
|
||||
|
||||
public event Action<string, byte[]>? OnFrameReceived;
|
||||
// ★★★ 核心变更:使用强类型契约载体 ★★★
|
||||
public event Action<VideoPayload>? OnPayloadReceived;
|
||||
|
||||
private SubscriberSocket? _subSocket;
|
||||
private Task? _receiveTask;
|
||||
|
||||
// 【修复1】不要在这里初始化,改为在 Start 中初始化
|
||||
private CancellationTokenSource? _cts;
|
||||
|
||||
public int ListenPort { get; private set; }
|
||||
|
||||
// 增加运行状态标记
|
||||
// 运行状态检查
|
||||
public bool IsRunning => _receiveTask != null && !_receiveTask.IsCompleted;
|
||||
|
||||
private StreamReceiverService() { }
|
||||
|
||||
public void Start(int port = 6000)
|
||||
{
|
||||
// 1. 防止重复启动
|
||||
if (IsRunning) return;
|
||||
|
||||
ListenPort = port;
|
||||
|
||||
// 【修复1】每次启动时创建新的 TokenSource
|
||||
_cts = new CancellationTokenSource();
|
||||
|
||||
try
|
||||
{
|
||||
// 2. 初始化 Socket
|
||||
_subSocket = new SubscriberSocket();
|
||||
|
||||
// 【优化】设置高水位限制 (HWM)
|
||||
// 如果 UI 处理不过来,积压超过 1000 帧直接丢弃,防止内存爆炸
|
||||
// 设置高水位,防止 UI 卡顿时内存溢出
|
||||
_subSocket.Options.ReceiveHighWatermark = 1000;
|
||||
_subSocket.Bind($"tcp://*:{ListenPort}");
|
||||
_subSocket.Subscribe(""); // 订阅所有内容(这是 Dealer-Router/Pub-Sub 的基础)
|
||||
|
||||
string bindAddr = $"tcp://*:{ListenPort}";
|
||||
_subSocket.Bind(bindAddr);
|
||||
_subSocket.Subscribe("");
|
||||
|
||||
Console.WriteLine($"[Dashboard] 视频流接收服务启动: {bindAddr}");
|
||||
Console.WriteLine($"[Dashboard] 视频流接收服务启动: tcp://*:{ListenPort}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Dashboard] 致命错误 - 端口绑定失败: {ex.Message}");
|
||||
|
||||
// 清理资源
|
||||
// 明确抛出异常,让 App.xaml.cs 知道启动失败了
|
||||
_subSocket?.Dispose();
|
||||
_subSocket = null;
|
||||
|
||||
// 【修复4】抛出异常让上层知道启动失败了
|
||||
throw new Exception($"端口 {port} 绑定失败,可能被占用。", ex);
|
||||
}
|
||||
|
||||
// 3. 启动任务
|
||||
_receiveTask = Task.Run(ReceiveLoop, _cts.Token);
|
||||
}
|
||||
|
||||
@@ -73,61 +60,55 @@ public class StreamReceiverService : IDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
// 【修复2】线程安全检查
|
||||
if (_subSocket == null) break;
|
||||
|
||||
// 接收 Topic
|
||||
if (!_subSocket.TryReceiveFrameString(TimeSpan.FromMilliseconds(500), out string cameraId))
|
||||
// =========================================================
|
||||
// 核心解析逻辑:适配 Service 端的 4 帧复合协议
|
||||
// =========================================================
|
||||
NetMQMessage msg = new NetMQMessage();
|
||||
|
||||
// 1. 非阻塞接收多帧消息
|
||||
if (!_subSocket.TryReceiveMultipartMessage(TimeSpan.FromMilliseconds(500), ref msg))
|
||||
continue;
|
||||
|
||||
// 接收 Payload
|
||||
if (!_subSocket.TryReceiveFrameBytes(TimeSpan.FromMilliseconds(100), out byte[] jpgBytes))
|
||||
continue;
|
||||
// 2. 协议完整性检查
|
||||
if (msg.FrameCount < 4) continue;
|
||||
|
||||
// 触发事件
|
||||
OnFrameReceived?.Invoke(cameraId, jpgBytes);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// 【修复2】这是正常的退出流程(Socket被Dispose了),优雅退出循环
|
||||
break;
|
||||
// 3. 协议头校验 (Frame 0)
|
||||
if (msg[0].ConvertToString() != "SHH_V1") continue;
|
||||
|
||||
// 4. 反序列化元数据 (Frame 1)
|
||||
string json = msg[1].ConvertToString();
|
||||
var payload = JsonConvert.DeserializeObject<VideoPayload>(json);
|
||||
|
||||
if (payload == null) continue;
|
||||
|
||||
// 5. 填充二进制图像数据 (Frame 2 & 3)
|
||||
// 注意:NetMQ 的 msg 数据是非托管内存,转为 byte[] 实现了拷贝,安全供 UI 使用
|
||||
if (payload.HasOriginalImage)
|
||||
payload.OriginalImageBytes = msg[2].ToByteArray();
|
||||
|
||||
if (payload.HasTargetImage)
|
||||
payload.TargetImageBytes = msg[3].ToByteArray();
|
||||
|
||||
// 6. 触发事件
|
||||
OnPayloadReceived?.Invoke(payload);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 记录日志,但不崩溃
|
||||
Debug.WriteLine($"[ReceiverLoop Error] {ex.Message}");
|
||||
Debug.WriteLine($"[Receiver Error] {ex.Message}");
|
||||
}
|
||||
}
|
||||
Console.WriteLine("[Dashboard] 接收循环已停止");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止服务(支持停止后重新 Start)
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
// 1. 发出取消信号
|
||||
if (_cts != null && !_cts.IsCancellationRequested)
|
||||
{
|
||||
_cts.Cancel();
|
||||
}
|
||||
|
||||
// 2. 销毁 Socket (这会触发 ReceiveLoop 中的 ObjectDisposedException 从而退出循环)
|
||||
if (_subSocket != null)
|
||||
{
|
||||
try { _subSocket.Dispose(); } catch { }
|
||||
_subSocket = null;
|
||||
}
|
||||
|
||||
// 3. 清理 Token
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
|
||||
_cts?.Cancel();
|
||||
try { _subSocket?.Dispose(); } catch { }
|
||||
_subSocket = null;
|
||||
_receiveTask = null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
}
|
||||
public void Dispose() => Stop();
|
||||
}
|
||||
Reference in New Issue
Block a user