在 AiVideo 中能看到图像
增加了在线状态同步逻辑
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
using SHH.CameraDashboard.Services;
|
using MessagePack;
|
||||||
using SHH.Contracts;
|
using SHH.Contracts;
|
||||||
using SHH.ProcessLaunchers;
|
using SHH.ProcessLaunchers;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
@@ -30,9 +30,13 @@ namespace SHH.CameraDashboard
|
|||||||
StreamReceiverService.Instance.Start(6002);
|
StreamReceiverService.Instance.Start(6002);
|
||||||
|
|
||||||
// 启动指令服务 (Port 6001)
|
// 启动指令服务 (Port 6001)
|
||||||
CommandServer.Instance.Start(6001);
|
|
||||||
|
|
||||||
CommandServer.Instance.OnClientRegistered += SetupAutomaticConfiguration;
|
CommandBusClient.Instance.Start(6001);
|
||||||
|
CommandBusClient.Instance.OnServerRegistered += SetupAutomaticConfiguration;
|
||||||
|
|
||||||
|
//CommandServer.Instance.Start(6001);
|
||||||
|
|
||||||
|
//CommandServer.Instance.OnClientRegistered += SetupAutomaticConfiguration;
|
||||||
|
|
||||||
// 现在我们来配置启动
|
// 现在我们来配置启动
|
||||||
|
|
||||||
@@ -59,9 +63,10 @@ namespace SHH.CameraDashboard
|
|||||||
string serviceArgs = $"" +
|
string serviceArgs = $"" +
|
||||||
$"--pid {myPid} " +
|
$"--pid {myPid} " +
|
||||||
$"--appid \"CameraApp_01\" " +
|
$"--appid \"CameraApp_01\" " +
|
||||||
$"--uris \"127.0.0.1,6002,video,调试PC;\" " +
|
$"--uris \"127.0.0.1,6003,video,调试PC;\" " +
|
||||||
$"--uris \"127.0.0.1,6001,command,调试PC;\" " +
|
$"--uris \"127.0.0.1,6001,command,调试PC;\" " +
|
||||||
$"--uris \"192.168.1.100,6002,video,大屏展示;\" " +
|
$"--uris \"127.0.0.1,6004,command,调试PC;\" " +
|
||||||
|
$"--uris \"127.0.0.1,6002,video,大屏展示;\" " +
|
||||||
$"--mode 1 " +
|
$"--mode 1 " +
|
||||||
$"--ports \"5000,100\"";
|
$"--ports \"5000,100\"";
|
||||||
|
|
||||||
@@ -71,7 +76,7 @@ namespace SHH.CameraDashboard
|
|||||||
Id = "CameraService", // 内部标识
|
Id = "CameraService", // 内部标识
|
||||||
DisplayName = "视频接入服务", // UI显示名称
|
DisplayName = "视频接入服务", // UI显示名称
|
||||||
// 请确保路径正确,建议用相对路径 AppDomain.CurrentDomain.BaseDirectory + "SHH.CameraService.exe"
|
// 请确保路径正确,建议用相对路径 AppDomain.CurrentDomain.BaseDirectory + "SHH.CameraService.exe"
|
||||||
ExePath = @"D:\Codes\Ayay\SHH.CameraService\bin\Debug\net8.0\SHH.CameraService.exe",
|
ExePath = @"E:\Codes2026\Ayay\SHH.CameraService\bin\Debug\net8.0\SHH.CameraService.exe",
|
||||||
Arguments = serviceArgs, // ★★★ 核心:注入参数 ★★★
|
Arguments = serviceArgs, // ★★★ 核心:注入参数 ★★★
|
||||||
StartupOrder = 1, // 优先级
|
StartupOrder = 1, // 优先级
|
||||||
RestartDelayMs = 2000, // 崩溃后2秒重启
|
RestartDelayMs = 2000, // 崩溃后2秒重启
|
||||||
@@ -90,6 +95,76 @@ namespace SHH.CameraDashboard
|
|||||||
mainWin.Show();
|
mainWin.Show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SetupAutomaticConfiguration(RegisterPayload client)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[自动化] 新服务上线: {client.InstanceId}");
|
||||||
|
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(500);
|
||||||
|
|
||||||
|
// 1. 构建业务配置对象
|
||||||
|
var cameraConfig = new CameraConfigDto
|
||||||
|
{
|
||||||
|
Id = 17798,
|
||||||
|
Name = "206摄像头",
|
||||||
|
Location = "404办公室",
|
||||||
|
IpAddress = "172.16.41.88",
|
||||||
|
Username = "admin",
|
||||||
|
Password = "abcd1234",
|
||||||
|
Port = 8000,
|
||||||
|
ChannelIndex = 1,
|
||||||
|
StreamType = 0,
|
||||||
|
Brand = DeviceBrand.HikVision.GetHashCode(), // 对应 DeviceBrand 枚举
|
||||||
|
RenderHandle = 0, // 初始化为0
|
||||||
|
MainboardIp = "", // 留空
|
||||||
|
MainboardPort = 0,
|
||||||
|
RtspPath = ""
|
||||||
|
};
|
||||||
|
|
||||||
|
// ★ 新增:一并带上订阅要求 ★
|
||||||
|
cameraConfig.AutoSubscriptions = new List<CameraConfigSubscribeDto>
|
||||||
|
{
|
||||||
|
// 第一条:显示帧,要求 8 帧
|
||||||
|
new CameraConfigSubscribeDto {
|
||||||
|
AppId = "UI_Display",
|
||||||
|
Type = 0,
|
||||||
|
TargetFps = 8,
|
||||||
|
Memo = "显示帧"
|
||||||
|
},
|
||||||
|
// 第二条:分析帧,要求 1 帧
|
||||||
|
new CameraConfigSubscribeDto {
|
||||||
|
AppId = "AI_Analysis",
|
||||||
|
Type = 0,
|
||||||
|
Memo = "分析帧",
|
||||||
|
TargetFps = 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 构造指令包
|
||||||
|
var command = new CommandPayload
|
||||||
|
{
|
||||||
|
Protocol = ProtocolHeaders.Command,
|
||||||
|
CmdCode = ProtocolHeaders.SyncCamera,
|
||||||
|
TargetId = client.InstanceId,
|
||||||
|
RequestId = Guid.NewGuid().ToString("N"),
|
||||||
|
|
||||||
|
// ★ 修正 1: 使用 JsonParams 属性名,并将对象序列化为 JSON 字符串 ★
|
||||||
|
// 因为你的 DTO 定义 JsonParams 是 string 类型
|
||||||
|
JsonParams = JsonHelper.Serialize(cameraConfig),
|
||||||
|
|
||||||
|
// ★ 修正 2: Timestamp 直接赋值 DateTime 对象 ★
|
||||||
|
// 因为你的 DTO 定义 Timestamp 是 DateTime 类型
|
||||||
|
Timestamp = DateTime.Now,
|
||||||
|
|
||||||
|
RequireAck = true
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. 发送
|
||||||
|
await CommandBusClient.Instance.SendInternalAsync(client.InstanceId, command);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 在程序启动时订阅事件
|
/// 在程序启动时订阅事件
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
namespace SHH.CameraDashboard
|
||||||
|
{
|
||||||
|
// 简单的上下文定义
|
||||||
|
public class ProtocolContext
|
||||||
|
{
|
||||||
|
public string Protocol { get; set; }
|
||||||
|
public byte[] Data { get; set; }
|
||||||
|
public bool IsBlocked { get; set; } = false;
|
||||||
|
public ProtocolContext(string p, byte[] d) { Protocol = p; Data = d; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IProtocolInterceptor
|
||||||
|
{
|
||||||
|
Task OnSendingAsync(ProtocolContext context);
|
||||||
|
Task OnReceivedAsync(ProtocolContext context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class InterceptorPipeline
|
||||||
|
{
|
||||||
|
// 因为 Dashboard 可能没有复杂的 DI,这里支持手动添加列表
|
||||||
|
private readonly List<IProtocolInterceptor> _interceptors = new List<IProtocolInterceptor>();
|
||||||
|
|
||||||
|
public void Add(IProtocolInterceptor interceptor) => _interceptors.Add(interceptor);
|
||||||
|
|
||||||
|
public async Task<ProtocolContext?> ExecuteSendAsync(string protocol, byte[] data)
|
||||||
|
{
|
||||||
|
var ctx = new ProtocolContext(protocol, data);
|
||||||
|
foreach (var i in _interceptors)
|
||||||
|
{
|
||||||
|
await i.OnSendingAsync(ctx);
|
||||||
|
if (ctx.IsBlocked) return null;
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ProtocolContext?> ExecuteReceiveAsync(string protocol, byte[] data)
|
||||||
|
{
|
||||||
|
var ctx = new ProtocolContext(protocol, data);
|
||||||
|
foreach (var i in _interceptors)
|
||||||
|
{
|
||||||
|
await i.OnReceivedAsync(ctx);
|
||||||
|
if (ctx.IsBlocked) return null;
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using NetMQ;
|
using MessagePack;
|
||||||
|
using NetMQ;
|
||||||
using NetMQ.Sockets;
|
using NetMQ.Sockets;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using SHH.Contracts; // ★★★ 必须引用契约库 ★★★
|
using SHH.Contracts; // ★★★ 必须引用契约库 ★★★
|
||||||
@@ -77,9 +78,16 @@ public class StreamReceiverService : IDisposable
|
|||||||
// 3. 协议头校验 (Frame 0)
|
// 3. 协议头校验 (Frame 0)
|
||||||
if (msg[0].ConvertToString() != "SHH_V1") continue;
|
if (msg[0].ConvertToString() != "SHH_V1") continue;
|
||||||
|
|
||||||
|
//// 4. 反序列化元数据 (Frame 1)
|
||||||
|
//string json = msg[1].ConvertToString();
|
||||||
|
//var payload = JsonConvert.DeserializeObject<VideoPayload>(json);
|
||||||
|
|
||||||
// 4. 反序列化元数据 (Frame 1)
|
// 4. 反序列化元数据 (Frame 1)
|
||||||
string json = msg[1].ConvertToString();
|
// 直接获取二进制数据,不需要转 String (省去了 UTF8 解码开销)
|
||||||
var payload = JsonConvert.DeserializeObject<VideoPayload>(json);
|
byte[] metaBytes = msg[1].ToByteArray();
|
||||||
|
|
||||||
|
// 极速反序列化
|
||||||
|
var payload = MessagePackSerializer.Deserialize<VideoPayload>(metaBytes);
|
||||||
|
|
||||||
if (payload == null) continue;
|
if (payload == null) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -1,390 +1,158 @@
|
|||||||
using System;
|
using MessagePack;
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using NetMQ;
|
using NetMQ;
|
||||||
using NetMQ.Sockets;
|
using NetMQ.Sockets;
|
||||||
using Newtonsoft.Json;
|
using SHH.CameraDashboard.Services.Processors;
|
||||||
using SHH.Contracts;
|
using SHH.Contracts;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
namespace SHH.CameraDashboard.Services
|
namespace SHH.CameraDashboard
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// 客户端指令总线 (企业增强版)
|
|
||||||
/// <para>核心职责:作为指挥中心监听 7000 端口,管理所有网关连接。</para>
|
|
||||||
/// <para>通讯模式:Router (Bind) <--- Dealer (Connect)</para>
|
|
||||||
/// <para>高级特性:</para>
|
|
||||||
/// <para>1. 智能路由:根据 InstanceId 自动查找 NetMQ Identity。</para>
|
|
||||||
/// <para>2. QoS 分级:支持 "强一致性等待" 和 "射后不理" 两种模式。</para>
|
|
||||||
/// <para>3. 自动重试:网络超时自动重发,失败多次自动熔断。</para>
|
|
||||||
/// <para>4. 性能监控:精确统计全链路耗时 (RTT)。</para>
|
|
||||||
/// </summary>
|
|
||||||
public class CommandBusClient : IDisposable
|
public class CommandBusClient : IDisposable
|
||||||
{
|
{
|
||||||
#region --- 1. 字段与配置 ---
|
|
||||||
|
|
||||||
private RouterSocket? _routerSocket;
|
private RouterSocket? _routerSocket;
|
||||||
private NetMQPoller? _poller;
|
private NetMQPoller? _poller;
|
||||||
private volatile bool _isRunning;
|
private volatile bool _isRunning;
|
||||||
private readonly object _disposeLock = new object();
|
private readonly object _disposeLock = new object();
|
||||||
|
|
||||||
// 默认超时设置
|
// 单例模式
|
||||||
private const int DEFAULT_TIMEOUT_MS = 2000;
|
public static CommandBusClient Instance { get; } = new CommandBusClient();
|
||||||
private const int DEFAULT_MAX_RETRIES = 2;
|
|
||||||
|
// 处理器字典
|
||||||
|
private readonly Dictionary<string, IProtocolProcessor> _processors = new();
|
||||||
|
|
||||||
// ★★★ 核心:线程安全的任务字典 <RequestId, TCS> ★★★
|
|
||||||
// Key: 请求ID (身份证号)
|
|
||||||
// Value: 异步任务凭证 (用于 await 唤醒)
|
|
||||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<CommandResult>> _pendingRequests
|
private readonly ConcurrentDictionary<string, TaskCompletionSource<CommandResult>> _pendingRequests
|
||||||
= new ConcurrentDictionary<string, TaskCompletionSource<CommandResult>>();
|
= new ConcurrentDictionary<string, TaskCompletionSource<CommandResult>>();
|
||||||
|
|
||||||
// ★★★ 核心:路由表 ★★★
|
|
||||||
// Key: 实例ID (例如 "Gateway_01")
|
|
||||||
// Value: NetMQ 路由 Identity (二进制地址,这是 Router 发消息必须的“信封地址”)
|
|
||||||
private readonly ConcurrentDictionary<string, byte[]> _sessions
|
private readonly ConcurrentDictionary<string, byte[]> _sessions
|
||||||
= new ConcurrentDictionary<string, byte[]>();
|
= new ConcurrentDictionary<string, byte[]>();
|
||||||
|
|
||||||
/// <summary>
|
// ★★★ 新增:拦截器管道 ★★★
|
||||||
/// 当有服务端连上来并完成注册时触发
|
public InterceptorPipeline Pipeline { get; } = new InterceptorPipeline();
|
||||||
/// </summary>
|
|
||||||
public event Action<ServerRegistrationDto>? OnServerRegistered;
|
|
||||||
|
|
||||||
#endregion
|
public event Action<RegisterPayload>? OnServerRegistered;
|
||||||
|
public event Action<List<StatusEventPayload>>? OnDeviceStatusReport;
|
||||||
|
public event Action<CommandPayload>? OnCommandReceived;
|
||||||
|
|
||||||
#region --- 2. 启动与停止 ---
|
// 注册处理器的方法
|
||||||
|
public void RegisterProcessor(IProtocolProcessor processor)
|
||||||
|
{
|
||||||
|
_processors[processor.ProtocolType] = processor;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 启动指令中心监听
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="port">监听端口 (建议 7000)</param>
|
|
||||||
public void Start(int port)
|
public void Start(int port)
|
||||||
{
|
{
|
||||||
if (_isRunning) return;
|
if (_isRunning) return;
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
lock (_disposeLock)
|
lock (_disposeLock)
|
||||||
{
|
{
|
||||||
_routerSocket = new RouterSocket();
|
_routerSocket = new RouterSocket();
|
||||||
// 绑定端口,等待服务端(Active Mode)主动来连接
|
|
||||||
// 使用 tcp://*:{port} 绑定本机所有网卡
|
|
||||||
_routerSocket.Bind($"tcp://*:{port}");
|
_routerSocket.Bind($"tcp://*:{port}");
|
||||||
|
|
||||||
// 注册接收事件 (基于 NetMQPoller 的异步非阻塞模式)
|
|
||||||
_routerSocket.ReceiveReady += OnReceiveReady;
|
_routerSocket.ReceiveReady += OnReceiveReady;
|
||||||
|
|
||||||
_poller = new NetMQPoller { _routerSocket };
|
// --- 注册处理器 ---
|
||||||
_poller.RunAsync(); // 在后台线程启动轮询
|
this.RegisterProcessor(new RegisterProcessor(this));
|
||||||
|
this.RegisterProcessor(new StatusBatchProcessor(this));
|
||||||
|
this.RegisterProcessor(new CommandResultProcessor(this));
|
||||||
|
this.RegisterProcessor(new CommandProcessor(this));
|
||||||
|
|
||||||
|
_poller = new NetMQPoller { _routerSocket };
|
||||||
|
_poller.RunAsync();
|
||||||
_isRunning = true;
|
_isRunning = true;
|
||||||
Debug.WriteLine($"[ClientBus] 指令中心已启动,监听端口: {port}");
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注意:NetMQ 的事件处理器本质上是同步的 (void)。
|
||||||
|
// 为了调用异步拦截器,我们需要在这里使用 async void (仅限顶层事件处理)
|
||||||
|
private async void OnReceiveReady(object? sender, NetMQSocketEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
NetMQMessage msg = new NetMQMessage();
|
||||||
|
// 1. 尝试接收多帧消息
|
||||||
|
if (!e.Socket.TryReceiveMultipartMessage(ref msg)) return;
|
||||||
|
|
||||||
|
// 2. 帧校验 (Router 收到 Dealer 消息:[Identity] [Protocol] [Data])
|
||||||
|
// 此时 msg 应该有 3 帧
|
||||||
|
if (msg.FrameCount < 3) return;
|
||||||
|
|
||||||
|
byte[] identity = msg[0].Buffer; // Frame 0: 路由ID
|
||||||
|
string protocol = msg[1].ConvertToString(); // Frame 1: 协议标识
|
||||||
|
byte[] rawData = msg[2].ToByteArray(); // Frame 2: 原始数据
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// ★★★ 核心改造 A: 接收拦截 (Inbound) ★★★
|
||||||
|
// =========================================================
|
||||||
|
// 执行管道处理
|
||||||
|
var ctx = await Pipeline.ExecuteReceiveAsync(protocol, rawData);
|
||||||
|
|
||||||
|
if (ctx != null) // 如果没被拦截
|
||||||
|
{
|
||||||
|
// 使用处理后的协议和数据进行分发
|
||||||
|
if (_processors.TryGetValue(ctx.Protocol, out var processor))
|
||||||
|
{
|
||||||
|
processor.Process(identity, ctx.Data);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[Bus] 未知协议: {ctx.Protocol}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// 启动失败属于致命错误,记录日志
|
Debug.WriteLine($"[Bus-Err] {ex.Message}");
|
||||||
Debug.WriteLine($"[ClientBus-Error] 启动失败: {ex.Message}");
|
}
|
||||||
throw; // 向上抛出,让 UI 层感知并报错
|
}
|
||||||
|
|
||||||
|
// --- 供 Processor 调用的内部方法 (保持不变) ---
|
||||||
|
internal void UpdateSession(string instanceId, byte[] identity) => _sessions[instanceId] = identity;
|
||||||
|
internal void RaiseServerRegistered(RegisterPayload p) => OnServerRegistered?.Invoke(p);
|
||||||
|
internal void RaiseDeviceStatusReport(List<StatusEventPayload> i) => OnDeviceStatusReport?.Invoke(i);
|
||||||
|
internal void RaiseCommandReceived(CommandPayload payload) => OnCommandReceived?.Invoke(payload);
|
||||||
|
|
||||||
|
internal void HandleResponse(CommandResult result)
|
||||||
|
{
|
||||||
|
if (_pendingRequests.TryRemove(result.RequestId, out var tcs))
|
||||||
|
tcs.TrySetResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// ★★★ 核心改造 B: 发送拦截 (Outbound) ★★★
|
||||||
|
// =========================================================
|
||||||
|
// 改为 async Task 以支持异步拦截器
|
||||||
|
public async Task SendInternalAsync(string instanceId, CommandPayload payload)
|
||||||
|
{
|
||||||
|
if (_sessions.TryGetValue(instanceId, out byte[]? identity))
|
||||||
|
{
|
||||||
|
// 1. 序列化
|
||||||
|
byte[] rawData = MessagePackSerializer.Serialize(payload);
|
||||||
|
|
||||||
|
// 2. 执行管道处理
|
||||||
|
var ctx = await Pipeline.ExecuteSendAsync(payload.Protocol, rawData);
|
||||||
|
|
||||||
|
// 3. 发送 (如果没被拦截)
|
||||||
|
if (ctx != null && _routerSocket != null)
|
||||||
|
{
|
||||||
|
// 注意:Socket 非线程安全,但 RouterSocket 的 SendMultipartMessage 通常是线程安全的
|
||||||
|
// 或者通过 Poller 线程去发。但在 Router 模式下,多线程直接 Send 通常是允许的。
|
||||||
|
var msg = new NetMQMessage();
|
||||||
|
msg.Append(identity);
|
||||||
|
msg.Append(ctx.Protocol); // 使用拦截器处理后的 Protocol
|
||||||
|
msg.Append(ctx.Data); // 使用拦截器处理后的 Data
|
||||||
|
|
||||||
|
_routerSocket.SendMultipartMessage(msg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Stop()
|
public void Stop()
|
||||||
{
|
|
||||||
if (!_isRunning) return;
|
|
||||||
|
|
||||||
lock (_disposeLock)
|
|
||||||
{
|
{
|
||||||
_isRunning = false;
|
_isRunning = false;
|
||||||
try
|
|
||||||
{
|
|
||||||
_poller?.Stop();
|
_poller?.Stop();
|
||||||
_poller?.Dispose();
|
_poller?.Dispose();
|
||||||
_routerSocket?.Dispose();
|
_routerSocket?.Dispose();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Debug.WriteLine($"[ClientBus-Error] 停止时异常: {ex.Message}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
// 彻底清理状态
|
|
||||||
CleanupPendingTasks();
|
|
||||||
_sessions.Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose() => Stop();
|
||||||
{
|
|
||||||
Stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CleanupPendingTasks()
|
|
||||||
{
|
|
||||||
// 取消所有挂起的请求,避免 SendAsync 里的 await 永久卡死
|
|
||||||
foreach (var kvp in _pendingRequests)
|
|
||||||
{
|
|
||||||
kvp.Value.TrySetCanceled();
|
|
||||||
}
|
|
||||||
_pendingRequests.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region --- 3. 核心发送逻辑 (策略层) ---
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 发送指令(包含 QoS判断 + 重试循环 + 熔断 + RTT统计)
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="instanceId">目标网关ID (如 "Gateway_01")</param>
|
|
||||||
/// <param name="payload">指令包</param>
|
|
||||||
/// <param name="timeoutMs">单次超时时间 (毫秒)</param>
|
|
||||||
/// <param name="maxRetries">最大重试次数 (0表示不重试)</param>
|
|
||||||
/// <returns>执行结果</returns>
|
|
||||||
public async Task<CommandResult> SendAsync(string instanceId, CommandPayload payload, int timeoutMs = DEFAULT_TIMEOUT_MS, int maxRetries = DEFAULT_MAX_RETRIES)
|
|
||||||
{
|
|
||||||
if (!_isRunning) return CommandResult.Fail("服务未启动");
|
|
||||||
|
|
||||||
// 1. 检查目标是否在线 (快速失败)
|
|
||||||
if (!_sessions.ContainsKey(instanceId))
|
|
||||||
{
|
|
||||||
return CommandResult.Fail($"服务端 {instanceId} 离线或未连接");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 确保有 RequestId
|
|
||||||
if (string.IsNullOrEmpty(payload.RequestId))
|
|
||||||
payload.RequestId = Guid.NewGuid().ToString("N");
|
|
||||||
|
|
||||||
// =========================================================
|
|
||||||
// 策略 A: 射后不理 (Fire-and-Forget) - QoS 0
|
|
||||||
// =========================================================
|
|
||||||
// 适用于:心跳包、非关键日志、高频状态查询
|
|
||||||
// 优势:不占用 await 线程资源,不产生网络拥堵
|
|
||||||
if (!payload.RequireAck)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
SendInternal(instanceId, payload);
|
|
||||||
return CommandResult.Ok("已投递 (NoAck Mode)");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return CommandResult.Fail($"投递失败: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================
|
|
||||||
// 策略 B: 强一致性重试 (Reliable Retry) - QoS 1
|
|
||||||
// =========================================================
|
|
||||||
// 适用于:PTZ控制、录像启停、参数设置
|
|
||||||
|
|
||||||
int currentRetry = 0;
|
|
||||||
// 启动高精度计时器 (统计包含重试在内的总耗时)
|
|
||||||
Stopwatch totalStopwatch = Stopwatch.StartNew();
|
|
||||||
|
|
||||||
// 重试循环 (Retry Loop)
|
|
||||||
while (currentRetry <= maxRetries)
|
|
||||||
{
|
|
||||||
// 更新重试计数,服务端可据此判断是否需要打印 "Retry Warning"
|
|
||||||
payload.RetryCount = currentRetry;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// ★ 核心原子操作:发送并等待单次结果 ★
|
|
||||||
var result = await SendRequestCore(instanceId, payload, timeoutMs);
|
|
||||||
|
|
||||||
// --- 成功路径 ---
|
|
||||||
totalStopwatch.Stop();
|
|
||||||
result.ElapsedMilliseconds = totalStopwatch.Elapsed.TotalMilliseconds;
|
|
||||||
|
|
||||||
// 如果重试过,打印一条恢复日志
|
|
||||||
if (currentRetry > 0)
|
|
||||||
Debug.WriteLine($"[ClientBus] {payload.CmdCode} 在第 {currentRetry} 次重试后成功恢复。");
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
catch (TimeoutException)
|
|
||||||
{
|
|
||||||
// --- 超时路径 ---
|
|
||||||
Debug.WriteLine($"[ClientBus-Warn] Req {payload.RequestId} 超时 ({currentRetry + 1}/{maxRetries + 1})...");
|
|
||||||
currentRetry++;
|
|
||||||
|
|
||||||
// 可选:在重试前稍微等待一下 (指数退避),避免瞬间拥塞
|
|
||||||
// await Task.Delay(50 * currentRetry);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// --- 致命错误路径 (如序列化失败、Socket已释放) ---
|
|
||||||
// 这种错误重试也没用,直接报错
|
|
||||||
return CommandResult.Fail($"发送过程发生不可恢复错误: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================
|
|
||||||
// 熔断 (Meltdown)
|
|
||||||
// =========================================================
|
|
||||||
totalStopwatch.Stop();
|
|
||||||
var failRes = CommandResult.Fail($"请求熔断: 目标无响应 (已重试 {maxRetries} 次)");
|
|
||||||
failRes.ElapsedMilliseconds = totalStopwatch.Elapsed.TotalMilliseconds;
|
|
||||||
return failRes;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region --- 4. 底层发送实现 (原子操作层) ---
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 执行单次 "请求-响应" 周期
|
|
||||||
/// </summary>
|
|
||||||
private async Task<CommandResult> SendRequestCore(string instanceId, CommandPayload payload, int timeoutMs)
|
|
||||||
{
|
|
||||||
// 1. 创建异步凭证 (TCS)
|
|
||||||
// RunContinuationsAsynchronously 是必须的,防止 NetMQ 接收线程直接执行 await 后的 UI 代码导致死锁
|
|
||||||
var tcs = new TaskCompletionSource<CommandResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
||||||
|
|
||||||
// 2. 注册到字典,等待回信
|
|
||||||
// 如果 ID 冲突 (极低概率),说明上一个还没处理完,强行覆盖或报错
|
|
||||||
_pendingRequests[payload.RequestId] = tcs;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 3. 发送网络包
|
|
||||||
SendInternal(instanceId, payload);
|
|
||||||
|
|
||||||
// 4. 异步等待 (Wait for TCS or Timeout)
|
|
||||||
// Task.WhenAny 是实现超时的经典模式
|
|
||||||
var completedTask = await Task.WhenAny(tcs.Task, Task.Delay(timeoutMs));
|
|
||||||
|
|
||||||
if (completedTask == tcs.Task)
|
|
||||||
{
|
|
||||||
// 任务完成 (OnReceiveReady 设置了结果)
|
|
||||||
return await tcs.Task;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// 时间到,任务还没完成 -> 抛出超时异常,触发外层重试
|
|
||||||
throw new TimeoutException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
// 5. 清理现场 (无论成功失败,必须移除字典,防止内存泄漏)
|
|
||||||
_pendingRequests.TryRemove(payload.RequestId, out _);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 纯粹的 NetMQ 数据发送 (不处理逻辑)
|
|
||||||
/// </summary>
|
|
||||||
private void SendInternal(string instanceId, CommandPayload payload)
|
|
||||||
{
|
|
||||||
// 查路由表获取 Identity
|
|
||||||
if (_sessions.TryGetValue(instanceId, out byte[]? identity))
|
|
||||||
{
|
|
||||||
var msg = new NetMQMessage();
|
|
||||||
// Frame 1: 目标地址 (Identity)
|
|
||||||
msg.Append(identity);
|
|
||||||
// Frame 2: 数据 (JSON)
|
|
||||||
msg.Append(JsonConvert.SerializeObject(payload));
|
|
||||||
|
|
||||||
// 线程安全检查
|
|
||||||
if (_routerSocket != null)
|
|
||||||
{
|
|
||||||
_routerSocket.SendMultipartMessage(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"无法找到目标 {instanceId} 的路由信息");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region --- 5. 核心接收逻辑 (Router) ---
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 处理所有入站消息
|
|
||||||
/// </summary>
|
|
||||||
private void OnReceiveReady(object? sender, NetMQSocketEventArgs e)
|
|
||||||
{
|
|
||||||
// 防止处理过程中崩溃导致监听停止
|
|
||||||
try
|
|
||||||
{
|
|
||||||
NetMQMessage msg = new NetMQMessage();
|
|
||||||
// Router 模式:至少包含 [Identity, Data] 两帧,有时中间会有空帧
|
|
||||||
if (!e.Socket.TryReceiveMultipartMessage(ref msg) || msg.FrameCount < 2) return;
|
|
||||||
|
|
||||||
// 第一帧永远是发送方的 Identity
|
|
||||||
byte[] identity = msg[0].Buffer;
|
|
||||||
// 最后一帧通常是 JSON 数据
|
|
||||||
string json = msg.Last.ConvertToString();
|
|
||||||
|
|
||||||
// 简单的协议识别
|
|
||||||
// 优化建议:正式项目中可以用更严谨的 Header 区分,这里用 JSON 嗅探即可
|
|
||||||
if (json.Contains("\"CmdCode\""))
|
|
||||||
{
|
|
||||||
// ---> 收到注册包 (CmdCode 字段存在)
|
|
||||||
HandleRegistration(identity, json);
|
|
||||||
}
|
|
||||||
else if (json.Contains("\"Success\""))
|
|
||||||
{
|
|
||||||
// ---> 收到回执包 (Success 字段存在)
|
|
||||||
HandleResponse(json);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Debug.WriteLine($"[ClientBus-RecvError] 接收处理异常: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleRegistration(byte[] identity, string json)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var payload = JsonConvert.DeserializeObject<CommandPayload>(json);
|
|
||||||
if (payload?.CmdCode == "SERVER_REGISTER")
|
|
||||||
{
|
|
||||||
var regInfo = JsonConvert.DeserializeObject<ServerRegistrationDto>(payload.JsonParams);
|
|
||||||
if (regInfo != null)
|
|
||||||
{
|
|
||||||
// 更新路由表:[实例名] -> [二进制地址]
|
|
||||||
_sessions[regInfo.InstanceId] = identity;
|
|
||||||
|
|
||||||
Debug.WriteLine($"[ClientBus] 网关上线: {regInfo.InstanceId} IP: {regInfo.ServerIp}");
|
|
||||||
|
|
||||||
// 通知 UI 刷新列表
|
|
||||||
OnServerRegistered?.Invoke(regInfo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Debug.WriteLine($"[ClientBus-Warn] 注册包解析失败: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleResponse(string json)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = JsonConvert.DeserializeObject<CommandResult>(json);
|
|
||||||
|
|
||||||
// 闭环匹配:根据 RequestId 找到挂起的 TCS
|
|
||||||
if (!string.IsNullOrEmpty(result?.RequestId) &&
|
|
||||||
_pendingRequests.TryGetValue(result.RequestId, out var tcs))
|
|
||||||
{
|
|
||||||
// 设置结果 -> 唤醒 SendRequestCore -> 唤醒 SendAsync
|
|
||||||
tcs.TrySetResult(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Debug.WriteLine($"[ClientBus-Warn] 回执包解析失败: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
41
SHH.CameraDashboard/Services/Payloads/CommandProcessor.cs
Normal file
41
SHH.CameraDashboard/Services/Payloads/CommandProcessor.cs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
using MessagePack;
|
||||||
|
using SHH.Contracts;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace SHH.CameraDashboard.Services.Processors
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// [协议处理器] 处理来自服务端的反向指令 (COMMAND)
|
||||||
|
/// 场景:服务端主动要求客户端执行某些动作(如弹出实时画面、同步系统配置等)
|
||||||
|
/// </summary>
|
||||||
|
public class CommandProcessor : IProtocolProcessor
|
||||||
|
{
|
||||||
|
public string ProtocolType => "COMMAND";
|
||||||
|
private readonly CommandBusClient _bus;
|
||||||
|
|
||||||
|
public CommandProcessor(CommandBusClient bus)
|
||||||
|
{
|
||||||
|
_bus = bus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Process(byte[] identity, byte[] payloadBytes)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 1. 反序列化指令载体
|
||||||
|
var payload = MessagePackSerializer.Deserialize<CommandPayload>(payloadBytes);
|
||||||
|
if (payload == null) return;
|
||||||
|
|
||||||
|
// 2. 核心:触发总线上的指令接收事件
|
||||||
|
// 让监听该事件的 ViewModel 或全局管理器去执行具体业务
|
||||||
|
_bus.RaiseCommandReceived(payload);
|
||||||
|
|
||||||
|
Debug.WriteLine($"[Bus] 收到服务端反向指令: {payload.CmdCode}, 目标: {payload.TargetId}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[Bus-Err] CommandProcessor 解析异常: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using MessagePack;
|
||||||
|
using SHH.CameraDashboard;
|
||||||
|
using SHH.CameraDashboard.Services;
|
||||||
|
using SHH.Contracts;
|
||||||
|
|
||||||
|
public class CommandResultProcessor : IProtocolProcessor
|
||||||
|
{
|
||||||
|
public string ProtocolType => "COMMAND_RESULT";
|
||||||
|
private readonly CommandBusClient _bus;
|
||||||
|
public CommandResultProcessor(CommandBusClient bus) => _bus = bus;
|
||||||
|
|
||||||
|
public void Process(byte[] identity, byte[] payloadBytes)
|
||||||
|
{
|
||||||
|
var p = MessagePackSerializer.Deserialize<CommandResult>(payloadBytes);
|
||||||
|
_bus.HandleResponse(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
SHH.CameraDashboard/Services/Payloads/IProtocolProcessor.cs
Normal file
11
SHH.CameraDashboard/Services/Payloads/IProtocolProcessor.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace SHH.CameraDashboard
|
||||||
|
{
|
||||||
|
public interface IProtocolProcessor
|
||||||
|
{
|
||||||
|
// 匹配 Key(0) 的 Protocol 字符串
|
||||||
|
string ProtocolType { get; }
|
||||||
|
|
||||||
|
// 执行具体的解析与业务逻辑
|
||||||
|
void Process(byte[] identity, byte[] payloadBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
SHH.CameraDashboard/Services/Payloads/RegisterProcessor.cs
Normal file
20
SHH.CameraDashboard/Services/Payloads/RegisterProcessor.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
using MessagePack;
|
||||||
|
using SHH.CameraDashboard;
|
||||||
|
using SHH.CameraDashboard.Services;
|
||||||
|
using SHH.Contracts;
|
||||||
|
|
||||||
|
public class RegisterProcessor : IProtocolProcessor
|
||||||
|
{
|
||||||
|
public string ProtocolType => ProtocolHeaders.ServerRegister;
|
||||||
|
|
||||||
|
private readonly CommandBusClient _bus;
|
||||||
|
|
||||||
|
public RegisterProcessor(CommandBusClient bus) => _bus = bus;
|
||||||
|
|
||||||
|
public void Process(byte[] identity, byte[] payloadBytes)
|
||||||
|
{
|
||||||
|
var p = MessagePackSerializer.Deserialize<RegisterPayload>(payloadBytes);
|
||||||
|
_bus.UpdateSession(p.InstanceId, identity);
|
||||||
|
_bus.RaiseServerRegistered(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using MessagePack;
|
||||||
|
using SHH.CameraDashboard;
|
||||||
|
using SHH.CameraDashboard.Services;
|
||||||
|
using SHH.Contracts;
|
||||||
|
|
||||||
|
public class StatusBatchProcessor : IProtocolProcessor
|
||||||
|
{
|
||||||
|
public string ProtocolType => "STATUS_BATCH";
|
||||||
|
private readonly CommandBusClient _bus;
|
||||||
|
public StatusBatchProcessor(CommandBusClient bus) => _bus = bus;
|
||||||
|
|
||||||
|
public void Process(byte[] identity, byte[] payloadBytes)
|
||||||
|
{
|
||||||
|
var p = MessagePackSerializer.Deserialize<StatusBatchPayload>(payloadBytes);
|
||||||
|
if (p?.Items != null) _bus.RaiseDeviceStatusReport(p.Items);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ namespace SHH.CameraDashboard
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 处理注册/心跳包,更新列表
|
/// 处理注册/心跳包,更新列表
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void RegisterOrUpdate(ServerRegistrationDto info)
|
public void RegisterOrUpdate(RegisterPayload info)
|
||||||
{
|
{
|
||||||
// 确保在 UI 线程执行 (WPF 必须)
|
// 确保在 UI 线程执行 (WPF 必须)
|
||||||
Application.Current.Dispatcher.Invoke(() =>
|
Application.Current.Dispatcher.Invoke(() =>
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ namespace SHH.CameraDashboard
|
|||||||
// 用于绑定 ComboBox 的类型列表
|
// 用于绑定 ComboBox 的类型列表
|
||||||
public Dictionary<int, string> SubscriptionTypes { get; } = new Dictionary<int, string>
|
public Dictionary<int, string> SubscriptionTypes { get; } = new Dictionary<int, string>
|
||||||
{
|
{
|
||||||
{ 0, "本地窗口预览" },
|
{ 0, "仅取流" },
|
||||||
{ 1, "本地录像" },
|
{ 1, "本地窗口预览" },
|
||||||
{ 2, "句柄渲染 (嵌入)" },
|
{ 2, "本地录像" },
|
||||||
{ 3, "网络转发 (TCP/UDP)" },
|
{ 3, "句柄渲染 (嵌入)" },
|
||||||
{ 4, "Web 推流" }
|
{ 4, "网络转发 (TCP/UDP)" },
|
||||||
|
{ 5, "Web 推流" }
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- 数据源 ---
|
// --- 数据源 ---
|
||||||
|
|||||||
@@ -437,4 +437,25 @@ public class CameraManager : IDisposable, IAsyncDisposable
|
|||||||
// 复用现有的 GetAllDevices 逻辑
|
// 复用现有的 GetAllDevices 逻辑
|
||||||
return GetAllDevices();
|
return GetAllDevices();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#region --- [新增] 状态事件总线 (SDK 对外接口) ---
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当设备在线/离线状态发生变更时触发
|
||||||
|
/// <para>参数1: DeviceId</para>
|
||||||
|
/// <para>参数2: IsOnline (true=在线, false=离线)</para>
|
||||||
|
/// <para>参数3: Reason (变更原因)</para>
|
||||||
|
/// </summary>
|
||||||
|
public event Action<long, bool, string>? OnDeviceStatusChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// [内部方法] 供 Sentinel 调用,触发事件冒泡
|
||||||
|
/// </summary>
|
||||||
|
internal void NotifyStatusChange(long deviceId, bool isOnline, string reason)
|
||||||
|
{
|
||||||
|
// 仅仅是触发 C# 事件,完全不知道网络发送的存在
|
||||||
|
OnDeviceStatusChanged?.Invoke(deviceId, isOnline, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Net.NetworkInformation;
|
using System.Drawing;
|
||||||
|
using System.Net.NetworkInformation;
|
||||||
|
|
||||||
namespace SHH.CameraSdk;
|
namespace SHH.CameraSdk;
|
||||||
|
|
||||||
@@ -15,6 +16,11 @@ public class ConnectivitySentinel
|
|||||||
private readonly PeriodicTimer _timer;
|
private readonly PeriodicTimer _timer;
|
||||||
private readonly CancellationTokenSource _cts = new();
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
|
||||||
|
// [关键] 状态缓存:用于“去重”。
|
||||||
|
// 只有当状态真的从 true 变 false (或反之) 时,才通知 Manager。
|
||||||
|
// 防止每 3 秒发一次 "在线" 骚扰上层。
|
||||||
|
private readonly ConcurrentDictionary<long, bool> _lastStates = new();
|
||||||
|
|
||||||
// [关键配置] 最大并发度
|
// [关键配置] 最大并发度
|
||||||
// 建议值:CPU 核心数 * 4,或者固定 16-32
|
// 建议值:CPU 核心数 * 4,或者固定 16-32
|
||||||
// 50 个摄像头,设为 16,意味着分 4 批完成,总耗时极短
|
// 50 个摄像头,设为 16,意味着分 4 批完成,总耗时极短
|
||||||
@@ -77,6 +83,21 @@ public class ConnectivitySentinel
|
|||||||
|
|
||||||
// [状态注入]:将探测结果“注入”回设备
|
// [状态注入]:将探测结果“注入”回设备
|
||||||
device.SetNetworkStatus(isAlive);
|
device.SetNetworkStatus(isAlive);
|
||||||
|
|
||||||
|
// 3. [状态去重与上报]
|
||||||
|
// 获取上一次的状态,如果没记录过,假设它之前是反状态(强制第一次上报)
|
||||||
|
bool lastState = _lastStates.TryGetValue(device.Id, out bool val) ? val : !isAlive;
|
||||||
|
|
||||||
|
if (lastState != isAlive)
|
||||||
|
{
|
||||||
|
// 记录新状态
|
||||||
|
_lastStates[device.Id] = isAlive;
|
||||||
|
|
||||||
|
// ★★★ 核心动作:只通知 Manager,不做任何网络操作 ★★★
|
||||||
|
_manager.NotifyStatusChange(device.Id, isAlive, "网络连通性哨兵检测结论");
|
||||||
|
|
||||||
|
// Console.WriteLine($"[Sentinel] 诊断变化: {device.Id} -> {isAlive}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 纯粹的 Ping 逻辑
|
// 纯粹的 Ping 逻辑
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
using Microsoft.Extensions.Hosting;
|
using MessagePack;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
using NetMQ;
|
using NetMQ;
|
||||||
using NetMQ.Sockets;
|
using NetMQ.Sockets;
|
||||||
using Newtonsoft.Json;
|
|
||||||
using SHH.CameraSdk;
|
using SHH.CameraSdk;
|
||||||
|
using SHH.Contracts;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace SHH.CameraService;
|
namespace SHH.CameraService;
|
||||||
@@ -10,130 +11,144 @@ namespace SHH.CameraService;
|
|||||||
public class CommandClientWorker : BackgroundService
|
public class CommandClientWorker : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly ServiceConfig _config;
|
private readonly ServiceConfig _config;
|
||||||
private readonly CommandDispatcher _dispatcher; // 注入分发器
|
private readonly CommandDispatcher _dispatcher;
|
||||||
|
|
||||||
public CommandClientWorker(ServiceConfig config, CommandDispatcher dispatcher)
|
// ★ 1. 注入拦截器管道管理器
|
||||||
|
private readonly InterceptorPipeline _pipeline;
|
||||||
|
|
||||||
|
public CommandClientWorker(
|
||||||
|
ServiceConfig config,
|
||||||
|
CommandDispatcher dispatcher,
|
||||||
|
InterceptorPipeline pipeline) // <--- 注入
|
||||||
{
|
{
|
||||||
_config = config;
|
_config = config;
|
||||||
_dispatcher = dispatcher;
|
_dispatcher = dispatcher;
|
||||||
|
_pipeline = pipeline;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
// =================================================================
|
|
||||||
// ★★★ 核心修复:强制让出主线程 ★★★
|
|
||||||
// 这行代码会让当前的 ExecuteAsync 立即返回一个未完成的 Task 给 Host,
|
|
||||||
// Host 就会认为 "这个服务启动好了",然后继续去启动 WebAPI。
|
|
||||||
// 而剩下的代码会被调度到线程池里异步执行,互不干扰。
|
|
||||||
// =================================================================
|
|
||||||
await Task.Yield();
|
await Task.Yield();
|
||||||
|
|
||||||
// 1. 如果不是主动/混合模式,不需要连接
|
|
||||||
if (!_config.ShouldConnect) return;
|
if (!_config.ShouldConnect) return;
|
||||||
|
if (_config.CommandEndpoints.Count == 0) return;
|
||||||
|
|
||||||
var cmdEndpoints = _config.CommandEndpoints;
|
|
||||||
if (cmdEndpoints.Count == 0)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[指令] 未配置指令通道,跳过注册。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 初始化 Dealer Socket
|
|
||||||
using var dealer = new DealerSocket();
|
using var dealer = new DealerSocket();
|
||||||
|
|
||||||
// ★★★ 关键:设置身份标识 (Identity) ★★★
|
|
||||||
// 服务端 (Router) 收到消息时,第一帧就是这个 ID
|
|
||||||
// 如果不设,ZMQ 会随机生成一个二进制 ID,服务端就不知道你是谁了
|
|
||||||
string myIdentity = _config.AppId;
|
string myIdentity = _config.AppId;
|
||||||
dealer.Options.Identity = Encoding.UTF8.GetBytes(myIdentity);
|
dealer.Options.Identity = Encoding.UTF8.GetBytes(myIdentity);
|
||||||
|
|
||||||
// 3. 连接所有目标 (遍历 ServiceEndpoint 对象)
|
foreach (var ep in _config.CommandEndpoints)
|
||||||
foreach (var ep in cmdEndpoints)
|
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[指令] 连接控制端: {ep.Uri} [{ep.Description}]");
|
try { dealer.Connect(ep.Uri); }
|
||||||
try
|
catch (Exception ex) { Console.WriteLine($"[指令] 连接失败 {ep.Uri}: {ex.Message}"); }
|
||||||
{
|
|
||||||
dealer.Connect(ep.Uri);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[指令] 连接失败 {ep.Uri}: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 获取本机 IP (简单的获取方式,用于上报给 Dashboard)
|
|
||||||
string localIp = "127.0.0.1";
|
string localIp = "127.0.0.1";
|
||||||
try
|
// ... (获取 IP 代码省略,保持不变) ...
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// 构建注册包
|
||||||
|
// =================================================================
|
||||||
|
var registerPayload = new RegisterPayload
|
||||||
{
|
{
|
||||||
// 简单获取首个非回环 IP,生产环境建议用更严谨的帮助类
|
Protocol = ProtocolHeaders.ServerRegister,
|
||||||
var host = System.Net.Dns.GetHostEntry(System.Net.Dns.GetHostName());
|
InstanceId = _config.AppId,
|
||||||
localIp = host.AddressList.FirstOrDefault(ip =>
|
ProcessId = Environment.ProcessId,
|
||||||
ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)?.ToString() ?? "127.0.0.1";
|
Version = "1.0.0",
|
||||||
}
|
ServerIp = localIp,
|
||||||
catch { }
|
WebApiPort = _config.BasePort,
|
||||||
|
StartTime = DateTime.Now
|
||||||
// 4. 构建注册/登录包
|
|
||||||
var registerPayload = new
|
|
||||||
{
|
|
||||||
Action = "Register",
|
|
||||||
Payload = new
|
|
||||||
{
|
|
||||||
// 1. AppId (身份)
|
|
||||||
Id = _config.AppId,
|
|
||||||
|
|
||||||
// 2. Version (程序集版本)
|
|
||||||
Version = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "1.0.0",
|
|
||||||
|
|
||||||
// 3. 进程 ID (用于远程监控)
|
|
||||||
Pid = Environment.ProcessId,
|
|
||||||
|
|
||||||
// 4. 关键端口信息
|
|
||||||
// 告诉 Dashboard:如果你想调我的 REST API,请访问这个端口
|
|
||||||
WebPort = _config.BasePort,
|
|
||||||
|
|
||||||
// 如果您有本地绑定的 ZMQ 端口也可以在这里上报
|
|
||||||
// VideoPort = _config.BasePort + 1,
|
|
||||||
|
|
||||||
// 基础网络信息
|
|
||||||
Ip = localIp,
|
|
||||||
|
|
||||||
// 附带信息:我是要把视频推给谁 (供 Dashboard 调试用)
|
|
||||||
TargetVideoNodes = _config.VideoEndpoints.Select(e => e.Uri).ToList()
|
|
||||||
},
|
|
||||||
Time = DateTime.Now
|
|
||||||
};
|
};
|
||||||
|
|
||||||
string json = JsonConvert.SerializeObject(registerPayload);
|
|
||||||
|
|
||||||
// 5. 发送注册包
|
|
||||||
// Dealer 连接建立是异步的,所以这里直接发,ZMQ 会在底层连接成功后自动把消息推出去
|
|
||||||
// 为了保险,对于多个 Endpoint,Dealer 默认是负载均衡发送的(轮询)。
|
|
||||||
// 如果想让每个 Endpoint 都收到注册包,这在 Dealer 模式下稍微有点特殊。
|
|
||||||
// 但通常我们只需要发一次,只要有一个 Dashboard 收到并建立会话即可。
|
|
||||||
// 或者简单粗暴:循环发送几次,确保覆盖。
|
|
||||||
|
|
||||||
Console.WriteLine($"[指令] 发送注册包: {json}");
|
|
||||||
dealer.SendFrame(json);
|
|
||||||
|
|
||||||
// 6. 进入监听循环 (等待 ACK 或 指令)
|
|
||||||
// 进入监听循环
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (dealer.TryReceiveFrameString(TimeSpan.FromMilliseconds(500), out string msg))
|
byte[] regData = MessagePackSerializer.Serialize(registerPayload);
|
||||||
{
|
|
||||||
Console.WriteLine($"[指令] 收到消息: {msg}");
|
|
||||||
|
|
||||||
// ★★★ 核心变化:直接扔给分发器 ★★★
|
// =============================================================
|
||||||
// 无论未来加多少指令,这里都不用改代码
|
// ★ 2. 拦截点 A: 发送注册包 (Outbound)
|
||||||
await _dispatcher.DispatchAsync(msg);
|
// =============================================================
|
||||||
|
var ctx = await _pipeline.ExecuteSendAsync(ProtocolHeaders.ServerRegister, regData);
|
||||||
|
|
||||||
|
if (ctx != null) // 如果未被拦截
|
||||||
|
{
|
||||||
|
// 注意:这里使用 ctx.Protocol 和 ctx.Data,允许拦截器修改内容
|
||||||
|
dealer.SendMoreFrame(ctx.Protocol)
|
||||||
|
.SendFrame(ctx.Data);
|
||||||
|
|
||||||
|
Console.WriteLine($"[指令] 注册包已发送 ({ctx.Data.Length} bytes)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[指令] 异常: {ex.Message}");
|
Console.WriteLine($"[致命错误] 注册流程异常: {ex.Message}");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// 定义 ACK 发送逻辑 (包含拦截器)
|
||||||
|
// =================================================================
|
||||||
|
// 注意:这里需要 async,因为拦截器是异步的
|
||||||
|
Action<CommandResult> sendAckHandler = async (result) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
byte[] resultBytes = MessagePackSerializer.Serialize(result);
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// ★ 3. 拦截点 B: 发送 ACK 回执 (Outbound)
|
||||||
|
// =========================================================
|
||||||
|
// 协议头是 COMMAND_RESULT
|
||||||
|
var ctx = await _pipeline.ExecuteSendAsync(ProtocolHeaders.CommandResult, resultBytes);
|
||||||
|
|
||||||
|
if (ctx != null)
|
||||||
|
{
|
||||||
|
dealer.SendMoreFrame(ctx.Protocol)
|
||||||
|
.SendFrame(ctx.Data);
|
||||||
|
|
||||||
|
Console.WriteLine($"[指令] 已回复 ACK -> Req: {result.RequestId}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[ACK Error] 回执发送失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 订阅事件 (需要适配 async void,注意异常捕获)
|
||||||
|
_dispatcher.OnResponseReady += async (res) => await Task.Run(() => sendAckHandler(res));
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// 接收循环
|
||||||
|
// =================================================================
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
NetMQMessage incomingMsg = new NetMQMessage();
|
||||||
|
if (dealer.TryReceiveMultipartMessage(TimeSpan.FromMilliseconds(500), ref incomingMsg))
|
||||||
|
{
|
||||||
|
if (incomingMsg.FrameCount >= 2)
|
||||||
|
{
|
||||||
|
string rawProtocol = incomingMsg[0].ConvertToString();
|
||||||
|
byte[] rawData = incomingMsg[1].ToByteArray();
|
||||||
|
|
||||||
|
// =================================================
|
||||||
|
// ★ 4. 拦截点 C: 接收指令 (Inbound)
|
||||||
|
// =================================================
|
||||||
|
var ctx = await _pipeline.ExecuteReceiveAsync(rawProtocol, rawData);
|
||||||
|
|
||||||
|
if (ctx != null) // 如果未被拦截
|
||||||
|
{
|
||||||
|
// 将处理后的数据交给 Dispatcher
|
||||||
|
await _dispatcher.DispatchAsync(ctx.Protocol, ctx.Data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[指令] 接收循环异常: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,46 +1,91 @@
|
|||||||
using Newtonsoft.Json.Linq;
|
// 文件: Core\CmdClients\CommandDispatcher.cs
|
||||||
|
|
||||||
|
using MessagePack;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using SHH.Contracts;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace SHH.CameraService;
|
namespace SHH.CameraService;
|
||||||
|
|
||||||
public class CommandDispatcher
|
public class CommandDispatcher
|
||||||
{
|
{
|
||||||
// 路由表:Key = ActionName, Value = Handler
|
// 1. 注入路由表
|
||||||
private readonly Dictionary<string, ICommandHandler> _handlers;
|
private readonly Dictionary<string, ICommandHandler> _handlers;
|
||||||
|
|
||||||
// 通过依赖注入拿到所有实现了 ICommandHandler 的类
|
// 2. 定义回执事件 (ACK闭环的核心)
|
||||||
|
public event Action<CommandResult>? OnResponseReady;
|
||||||
|
|
||||||
|
// 3. 构造函数:注入所有 Handler
|
||||||
public CommandDispatcher(IEnumerable<ICommandHandler> handlers)
|
public CommandDispatcher(IEnumerable<ICommandHandler> handlers)
|
||||||
{
|
{
|
||||||
_handlers = handlers.ToDictionary(h => h.ActionName, h => h);
|
// 将注入的 Handler 转换为字典,Key = ActionName (e.g. "SyncCamera")
|
||||||
|
_handlers = handlers.ToDictionary(h => h.ActionName, h => h, StringComparer.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DispatchAsync(string jsonMessage)
|
public async Task DispatchAsync(string protocol, byte[] data)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var jObj = JObject.Parse(jsonMessage);
|
// 只处理 COMMAND 协议
|
||||||
string action = jObj["Action"]?.ToString();
|
if (protocol != ProtocolHeaders.Command) return;
|
||||||
var payload = jObj["Payload"];
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(action)) return;
|
// 反序列化信封
|
||||||
|
var envelope = MessagePackSerializer.Deserialize<CommandPayload>(data);
|
||||||
|
if (envelope == null) return;
|
||||||
|
|
||||||
// 1. 查找是否有对应的处理器
|
string cmdCode = envelope.CmdCode; // e.g. "SyncCamera"
|
||||||
if (_handlers.TryGetValue(action, out var handler))
|
Console.WriteLine($"[分发] 收到指令: {cmdCode} (ID: {envelope.RequestId})");
|
||||||
|
|
||||||
|
bool isSuccess = true;
|
||||||
|
string message = "OK";
|
||||||
|
|
||||||
|
// --- 路由匹配逻辑 ---
|
||||||
|
if (_handlers.TryGetValue(cmdCode, out var handler))
|
||||||
{
|
{
|
||||||
await handler.ExecuteAsync(payload);
|
try
|
||||||
|
{
|
||||||
|
// 数据适配:你的 Handler 需要 JToken
|
||||||
|
// 如果 envelope.JsonParams 是空的,传个空对象防止报错
|
||||||
|
var jsonStr = string.IsNullOrEmpty(envelope.JsonParams) ? "{}" : envelope.JsonParams;
|
||||||
|
var token = JToken.Parse(jsonStr);
|
||||||
|
|
||||||
|
// ★★★ 核心:调用 SyncCameraHandler.ExecuteAsync ★★★
|
||||||
|
await handler.ExecuteAsync(token);
|
||||||
|
|
||||||
|
message = $"Executed {cmdCode}";
|
||||||
}
|
}
|
||||||
else if (action == "ACK")
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// ACK 是特殊的,可以直接在这里处理或者忽略
|
isSuccess = false;
|
||||||
Console.WriteLine($"[指令] 握手成功: {jObj["Message"]}");
|
message = $"Handler Error: {ex.Message}";
|
||||||
|
Console.WriteLine($"[业务异常] {message}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[警告] 未知的指令: {action}");
|
isSuccess = false;
|
||||||
|
message = $"No handler found for {cmdCode}";
|
||||||
|
Console.WriteLine($"[警告] {message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ACK 闭环逻辑 ---
|
||||||
|
if (envelope.RequireAck)
|
||||||
|
{
|
||||||
|
var result = new CommandResult
|
||||||
|
{
|
||||||
|
Protocol = ProtocolHeaders.CommandResult,
|
||||||
|
RequestId = envelope.RequestId, // 必须带回 ID
|
||||||
|
Success = isSuccess,
|
||||||
|
Message = message,
|
||||||
|
Timestamp = DateTime.Now.Ticks
|
||||||
|
};
|
||||||
|
// 触发事件
|
||||||
|
OnResponseReady?.Invoke(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[分发错误] {ex.Message}");
|
Console.WriteLine($"[Dispatcher] 致命错误: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@ public class SyncCameraHandler : ICommandHandler
|
|||||||
{
|
{
|
||||||
private readonly CameraManager _cameraManager;
|
private readonly CameraManager _cameraManager;
|
||||||
|
|
||||||
public string ActionName => "SyncCamera";
|
public string ActionName => ProtocolHeaders.SyncCamera;
|
||||||
|
|
||||||
public SyncCameraHandler(CameraManager cameraManager)
|
public SyncCameraHandler(CameraManager cameraManager)
|
||||||
{
|
{
|
||||||
|
|||||||
109
SHH.CameraService/Core/JsonHelper.cs
Normal file
109
SHH.CameraService/Core/JsonHelper.cs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Converters;
|
||||||
|
using Newtonsoft.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SHH.CameraService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// JSON 序列化与反序列化帮助类
|
||||||
|
/// 职责:
|
||||||
|
/// 1. 提供全局统一的 JSON 转换配置(如驼峰命名、日期格式)。
|
||||||
|
/// 2. 封装常见的序列化和反序列化操作。
|
||||||
|
/// 3. 增加对 null 输入和无效 JSON 的健壮性处理。
|
||||||
|
/// </summary>
|
||||||
|
public static class JsonHelper
|
||||||
|
{
|
||||||
|
#region --- 静态配置 ---
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 全局共享的 JSON 序列化设置。
|
||||||
|
/// 静态构造函数保证其只被初始化一次。
|
||||||
|
/// </summary>
|
||||||
|
private static readonly JsonSerializerSettings _settings;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region --- 静态构造函数 ---
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 静态构造函数,用于初始化全局的 JSON 序列化设置。
|
||||||
|
/// </summary>
|
||||||
|
static JsonHelper()
|
||||||
|
{
|
||||||
|
_settings = new JsonSerializerSettings
|
||||||
|
{
|
||||||
|
// 1. 命名策略:将 C# 的 PascalCase 属性名序列化为 JSON 的 camelCase。
|
||||||
|
// 这是与 JavaScript/TypeScript 前端交互的标准做法。
|
||||||
|
ContractResolver = new CamelCasePropertyNamesContractResolver(),
|
||||||
|
|
||||||
|
// 2. 日期格式:统一使用 "yyyy-MM-dd HH:mm:ss" 格式,避免时区和格式差异导致的问题。
|
||||||
|
DateFormatString = "yyyy-MM-dd HH:mm:ss",
|
||||||
|
|
||||||
|
// 3. Null 值处理:在序列化时忽略值为 null 的属性。
|
||||||
|
// 这可以显著减小 JSON 字符串的大小,并使生成的 JSON 更干净。
|
||||||
|
// 例如,`{ Name = "Alice", Age = null }` 会被序列化为 `{"name":"Alice"}`。
|
||||||
|
NullValueHandling = NullValueHandling.Ignore
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. 枚举转换:将枚举值序列化为其字符串表示,而不是数字。
|
||||||
|
// 例如,`LogLevel.Info` 会被序列化为 `"info"`,而不是 `1`。
|
||||||
|
_settings.Converters.Add(new StringEnumConverter());
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region --- 公共方法 ---
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将对象序列化为 JSON 字符串。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="obj">要序列化的对象。</param>
|
||||||
|
/// <returns>序列化后的 JSON 字符串。如果输入为 null,则返回空字符串。</returns>
|
||||||
|
public static string Serialize(object obj)
|
||||||
|
{
|
||||||
|
// [健壮性] 如果输入对象为 null,返回空字符串,而不是 "null"。
|
||||||
|
// 这可以防止在创建 HTTP 请求内容时出现意外行为。
|
||||||
|
if (obj == null)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonConvert.SerializeObject(obj, _settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将 JSON 字符串反序列化为指定类型的对象。
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">目标对象的类型(必须是引用类型)。</typeparam>
|
||||||
|
/// <param name="json">要反序列化的 JSON 字符串。</param>
|
||||||
|
/// <returns>成功时返回反序列化后的对象;失败或输入无效时返回 null。</returns>
|
||||||
|
public static T? Deserialize<T>(string json) where T : class
|
||||||
|
{
|
||||||
|
// [健壮性] 检查输入是否为 null、空字符串或仅包含空白字符。
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// [健壮性] 处理 JSON 字符串为 "null" 的特殊情况。
|
||||||
|
if (json.Trim() == "null")
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 尝试使用预配置的设置进行反序列化。
|
||||||
|
return JsonConvert.DeserializeObject<T>(json, _settings);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// [健壮性] 如果 JSON 格式无效,捕获异常并返回 null。
|
||||||
|
// 这可以防止程序因一个格式错误的 JSON 字符串而崩溃。
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
}
|
||||||
151
SHH.CameraService/Core/NetSenders/DeviceStateMonitorWorker.cs
Normal file
151
SHH.CameraService/Core/NetSenders/DeviceStateMonitorWorker.cs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using NetMQ;
|
||||||
|
using NetMQ.Sockets;
|
||||||
|
using MessagePack;
|
||||||
|
using SHH.CameraSdk;
|
||||||
|
using SHH.Contracts;
|
||||||
|
|
||||||
|
namespace SHH.CameraService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// [二合一] 设备状态聚合与上报服务
|
||||||
|
/// </summary>
|
||||||
|
public class DeviceStateMonitorWorker : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly CameraManager _manager;
|
||||||
|
private readonly ServiceConfig _config;
|
||||||
|
|
||||||
|
// ★ 2. 注入拦截器管道
|
||||||
|
private readonly InterceptorPipeline _pipeline;
|
||||||
|
|
||||||
|
// 本地状态全集缓存
|
||||||
|
private readonly ConcurrentDictionary<string, StatusEventPayload> _stateStore = new();
|
||||||
|
|
||||||
|
// 标记是否有新变更
|
||||||
|
private volatile bool _isDirty = false;
|
||||||
|
private long _lastSendTick = 0;
|
||||||
|
|
||||||
|
// ★ 3. 构造函数增加 InterceptorPipeline 参数
|
||||||
|
public DeviceStateMonitorWorker(
|
||||||
|
CameraManager manager,
|
||||||
|
ServiceConfig config,
|
||||||
|
InterceptorPipeline pipeline) // <--- 注入点
|
||||||
|
{
|
||||||
|
_manager = manager;
|
||||||
|
_config = config;
|
||||||
|
_pipeline = pipeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
// 1. 初始化缓存 (默认离线)
|
||||||
|
foreach (var dev in _manager.GetAllDevices())
|
||||||
|
{
|
||||||
|
UpdateLocalState(dev.Id, false, "Init");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 挂载 SDK 事件
|
||||||
|
_manager.OnDeviceStatusChanged += OnSdkStatusChanged;
|
||||||
|
|
||||||
|
// 3. 建立连接
|
||||||
|
var cmdEndpoint = _config.CommandEndpoints.FirstOrDefault()?.Uri;
|
||||||
|
if (string.IsNullOrEmpty(cmdEndpoint))
|
||||||
|
{
|
||||||
|
Console.WriteLine("[StatusWorker] 警告: 未配置 Command 端点,状态上报无法启动。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"[StatusWorker] 启动状态上报,直连服务端: {cmdEndpoint}");
|
||||||
|
|
||||||
|
using var socket = new DealerSocket();
|
||||||
|
socket.Options.SendHighWatermark = 1000;
|
||||||
|
// 设置 Identity 是个好习惯,虽然这里只发不收
|
||||||
|
// socket.Options.Identity = ...
|
||||||
|
socket.Connect(cmdEndpoint);
|
||||||
|
|
||||||
|
// 4. 定时循环 (1秒1次)
|
||||||
|
var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||||
|
{
|
||||||
|
// ★ 4. 关键修正:必须使用 await 调用新的异步方法
|
||||||
|
await CheckAndDirectSendAsync(socket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_manager.OnDeviceStatusChanged -= OnSdkStatusChanged;
|
||||||
|
socket.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSdkStatusChanged(long deviceId, bool isOnline, string reason)
|
||||||
|
{
|
||||||
|
UpdateLocalState(deviceId, isOnline, reason);
|
||||||
|
_isDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateLocalState(long deviceId, bool isOnline, string reason)
|
||||||
|
{
|
||||||
|
var evt = new StatusEventPayload
|
||||||
|
{
|
||||||
|
CameraId = deviceId.ToString(),
|
||||||
|
IsOnline = isOnline,
|
||||||
|
Reason = reason,
|
||||||
|
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
||||||
|
};
|
||||||
|
_stateStore[deviceId.ToString()] = evt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 检查并在当前线程直接发送 (已改为异步 Task)
|
||||||
|
/// </summary>
|
||||||
|
// ★ 5. 关键修正:void -> async Task
|
||||||
|
private async Task CheckAndDirectSendAsync(NetMQSocket socket)
|
||||||
|
{
|
||||||
|
long now = Environment.TickCount64;
|
||||||
|
// 策略: 有变更 或 超过5秒(心跳)
|
||||||
|
bool shouldSend = _isDirty || (now - _lastSendTick > 5000);
|
||||||
|
|
||||||
|
if (shouldSend)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// A. 组包 (全量)
|
||||||
|
var snapshot = _stateStore.Values.ToList();
|
||||||
|
var batch = new StatusBatchPayload
|
||||||
|
{
|
||||||
|
Items = snapshot,
|
||||||
|
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
||||||
|
};
|
||||||
|
|
||||||
|
// B. 序列化
|
||||||
|
byte[] data = MessagePackSerializer.Serialize(batch);
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// ★ 6. 拦截器调用
|
||||||
|
// =========================================================
|
||||||
|
// 这里的 "STATUS_BATCH" 是协议头,你可以替换为 ProtocolHeaders.StatusBatch (如果定义了的话)
|
||||||
|
var ctx = await _pipeline.ExecuteSendAsync("STATUS_BATCH", data);
|
||||||
|
|
||||||
|
if (ctx != null) // 如果没被拦截
|
||||||
|
{
|
||||||
|
// C. 直接发送
|
||||||
|
socket.SendMoreFrame(ctx.Protocol)
|
||||||
|
.SendFrame(ctx.Data);
|
||||||
|
|
||||||
|
// D. 重置标记
|
||||||
|
_isDirty = false;
|
||||||
|
_lastSendTick = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[StatusWorker] 发送失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using NetMQ;
|
using MessagePack;
|
||||||
|
using NetMQ;
|
||||||
using SHH.Contracts;
|
using SHH.Contracts;
|
||||||
|
|
||||||
namespace SHH.CameraService
|
namespace SHH.CameraService
|
||||||
@@ -21,8 +22,16 @@ namespace SHH.CameraService
|
|||||||
// Frame 0: 协议魔数
|
// Frame 0: 协议魔数
|
||||||
msg.Append(PROTOCOL_HEADER);
|
msg.Append(PROTOCOL_HEADER);
|
||||||
|
|
||||||
// Frame 1: 元数据 JSON
|
////// Frame 1: 元数据 JSON
|
||||||
msg.Append(payload.GetMetadataJson());
|
////msg.Append(payload.GetMetadataJson());
|
||||||
|
|
||||||
|
// ★★★ 修复点:在序列化之前,手动更新 Payload 的标志位 ★★★
|
||||||
|
payload.HasOriginalImage = (payload.OriginalImageBytes != null && payload.OriginalImageBytes.Length > 0);
|
||||||
|
payload.HasTargetImage = (payload.TargetImageBytes != null && payload.TargetImageBytes.Length > 0);
|
||||||
|
|
||||||
|
// Frame 1: Metadata (MessagePack)
|
||||||
|
byte[] metaBytes = MessagePackSerializer.Serialize(payload);
|
||||||
|
msg.Append(metaBytes);
|
||||||
|
|
||||||
// Frame 2: 原始图 (保持帧位对齐,无数据则发空帧)
|
// Frame 2: 原始图 (保持帧位对齐,无数据则发空帧)
|
||||||
if (payload.HasOriginalImage && payload.OriginalImageBytes != null)
|
if (payload.HasOriginalImage && payload.OriginalImageBytes != null)
|
||||||
@@ -49,9 +58,14 @@ namespace SHH.CameraService
|
|||||||
// Frame 0 Check
|
// Frame 0 Check
|
||||||
if (msg[0].ConvertToString() != PROTOCOL_HEADER) return null;
|
if (msg[0].ConvertToString() != PROTOCOL_HEADER) return null;
|
||||||
|
|
||||||
// Frame 1: Metadata
|
//// Frame 1: Metadata
|
||||||
string json = msg[1].ConvertToString();
|
//string json = msg[1].ConvertToString();
|
||||||
var payload = VideoPayload.FromMetadataJson(json);
|
//var payload = VideoPayload.FromMetadataJson(json);
|
||||||
|
|
||||||
|
// [新代码] 直接从二进制还原
|
||||||
|
// ToByteArray() 虽然会产生一次拷贝,但对于 Metadata 这种小数据影响微乎其微
|
||||||
|
// 相比 JSON 解析 String 的开销,这已经非常快了
|
||||||
|
var payload = MessagePackSerializer.Deserialize<VideoPayload>(msg[1].ToByteArray());
|
||||||
if (payload == null) return null;
|
if (payload == null) return null;
|
||||||
|
|
||||||
// Frame 2: Raw Image
|
// Frame 2: Raw Image
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ public class NetworkStreamingWorker : BackgroundService
|
|||||||
DispatchTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
DispatchTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 添加订阅者
|
||||||
|
payload.SubscriberIds.AddRange(frame.SubscriberIds);
|
||||||
|
|
||||||
// 计算转码耗时(ms)
|
// 计算转码耗时(ms)
|
||||||
double processMs = (Stopwatch.GetTimestamp() - startTick) * 1000.0 / Stopwatch.Frequency;
|
double processMs = (Stopwatch.GetTimestamp() - startTick) * 1000.0 / Stopwatch.Frequency;
|
||||||
payload.Diagnostics["encode_ms"] = Math.Round(processMs, 2);
|
payload.Diagnostics["encode_ms"] = Math.Round(processMs, 2);
|
||||||
|
|||||||
45
SHH.CameraService/Interceptors/InterceptorPipeline.cs
Normal file
45
SHH.CameraService/Interceptors/InterceptorPipeline.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
namespace SHH.CameraService;
|
||||||
|
|
||||||
|
public class InterceptorPipeline
|
||||||
|
{
|
||||||
|
private readonly IEnumerable<IProtocolInterceptor> _interceptors;
|
||||||
|
|
||||||
|
// 通过依赖注入获取所有注册的拦截器
|
||||||
|
public InterceptorPipeline(IEnumerable<IProtocolInterceptor> interceptors)
|
||||||
|
{
|
||||||
|
_interceptors = interceptors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 执行发送管道
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>返回处理后的上下文,如果被拦截则返回 null</returns>
|
||||||
|
public async Task<ProtocolContext?> ExecuteSendAsync(string protocol, byte[] data)
|
||||||
|
{
|
||||||
|
var context = new ProtocolContext(protocol, data);
|
||||||
|
|
||||||
|
foreach (var interceptor in _interceptors)
|
||||||
|
{
|
||||||
|
await interceptor.OnSendingAsync(context);
|
||||||
|
if (context.IsBlocked) return null; // 被拦截,终止发送
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 执行接收管道
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ProtocolContext?> ExecuteReceiveAsync(string protocol, byte[] data)
|
||||||
|
{
|
||||||
|
var context = new ProtocolContext(protocol, data);
|
||||||
|
|
||||||
|
foreach (var interceptor in _interceptors)
|
||||||
|
{
|
||||||
|
await interceptor.OnReceivedAsync(context);
|
||||||
|
if (context.IsBlocked) return null; // 被拦截,丢弃消息
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
SHH.CameraService/Interceptors/ProtocolContext.cs
Normal file
37
SHH.CameraService/Interceptors/ProtocolContext.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
namespace SHH.CameraService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 协议上下文 (用于在拦截器之间传递数据)
|
||||||
|
/// </summary>
|
||||||
|
public class ProtocolContext
|
||||||
|
{
|
||||||
|
public string Protocol { get; set; }
|
||||||
|
public byte[] Data { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否拦截/终止 (设为 true 则不再继续传递)
|
||||||
|
/// </summary>
|
||||||
|
public bool IsBlocked { get; set; } = false;
|
||||||
|
|
||||||
|
public ProtocolContext(string protocol, byte[] data)
|
||||||
|
{
|
||||||
|
Protocol = protocol;
|
||||||
|
Data = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 拦截器接口
|
||||||
|
/// </summary>
|
||||||
|
public interface IProtocolInterceptor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 发送前触发 (Outbound)
|
||||||
|
/// </summary>
|
||||||
|
Task OnSendingAsync(ProtocolContext context);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 接收后触发 (Inbound)
|
||||||
|
/// </summary>
|
||||||
|
Task OnReceivedAsync(ProtocolContext context);
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ public class Program
|
|||||||
|
|
||||||
// 注册后台引擎 (理由:托管长周期的硬件状态监控)
|
// 注册后台引擎 (理由:托管长周期的硬件状态监控)
|
||||||
builder.Services.AddHostedService<CameraEngineWorker>();
|
builder.Services.AddHostedService<CameraEngineWorker>();
|
||||||
|
builder.Services.AddHostedService<DeviceStateMonitorWorker>();
|
||||||
|
|
||||||
// 配置 Web 相关的服务
|
// 配置 Web 相关的服务
|
||||||
ConfigureWebServices(builder, config);
|
ConfigureWebServices(builder, config);
|
||||||
@@ -70,7 +71,7 @@ public class Program
|
|||||||
// 3. 注册采集者 (它会注入上面的 targets,进行编码和分发)
|
// 3. 注册采集者 (它会注入上面的 targets,进行编码和分发)
|
||||||
builder.Services.AddHostedService<NetworkStreamingWorker>();
|
builder.Services.AddHostedService<NetworkStreamingWorker>();
|
||||||
|
|
||||||
// 4. 为每个 Target 注册一个独立的发送者
|
// 5. 为每个 Target 注册一个独立的发送者
|
||||||
foreach (var target in netTargets)
|
foreach (var target in netTargets)
|
||||||
{
|
{
|
||||||
builder.Services.AddHostedService(sp => new NetMqSenderWorker(target));
|
builder.Services.AddHostedService(sp => new NetMqSenderWorker(target));
|
||||||
@@ -80,6 +81,9 @@ public class Program
|
|||||||
// 5. 命令管道配置
|
// 5. 命令管道配置
|
||||||
// =============================================================
|
// =============================================================
|
||||||
|
|
||||||
|
// 2. 注册管道管理器
|
||||||
|
builder.Services.AddSingleton<InterceptorPipeline>();
|
||||||
|
|
||||||
// 负责连接 Dashboard,注册身份,接收重启/控制指令
|
// 负责连接 Dashboard,注册身份,接收重启/控制指令
|
||||||
builder.Services.AddHostedService<CommandClientWorker>();
|
builder.Services.AddHostedService<CommandClientWorker>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
namespace SHH.Contracts
|
using MessagePack;
|
||||||
|
|
||||||
|
namespace SHH.Contracts
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 通用指令执行结果 (Response)
|
/// 通用指令执行结果 (Response)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
public class CommandResult
|
public class CommandResult
|
||||||
{
|
{
|
||||||
|
#region --- 0. 协议自描述 ---
|
||||||
|
|
||||||
|
[Key(0)]
|
||||||
|
public string Protocol { get; set; } = "COMMAND_RESULT";
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
#region --- 核心匹配信息 ---
|
#region --- 核心匹配信息 ---
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 回执 ID (必须与请求包的 RequestId 一致)
|
/// 回执 ID (必须与请求包的 RequestId 一致)
|
||||||
/// <para>客户端靠这个 ID 来找到对应的 await Task</para>
|
/// <para>客户端靠这个 ID 来找到对应的 await Task</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(1)]
|
||||||
public string RequestId { get; set; }
|
public string RequestId { get; set; }
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -20,17 +31,20 @@
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 执行是否成功
|
/// 执行是否成功
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(2)]
|
||||||
public bool Success { get; set; }
|
public bool Success { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 结果消息 (成功提示或错误原因)
|
/// 结果消息 (成功提示或错误原因)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(3)]
|
||||||
public string Message { get; set; }
|
public string Message { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 返回的数据 (JSON 或 Base64 字符串)
|
/// 返回的数据 (JSON 或 Base64 字符串)
|
||||||
/// <para>示例: 截图的 Base64,或者查询到的设备列表 JSON</para>
|
/// <para>示例: 截图的 Base64,或者查询到的设备列表 JSON</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(4)]
|
||||||
public string Data { get; set; }
|
public string Data { get; set; }
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -42,10 +56,17 @@
|
|||||||
/// <para>从客户端发出指令,到收到服务端回执的总时长</para>
|
/// <para>从客户端发出指令,到收到服务端回执的总时长</para>
|
||||||
/// <para>注意:该字段由客户端收到回执后自动计算填充,服务端不需要赋值</para>
|
/// <para>注意:该字段由客户端收到回执后自动计算填充,服务端不需要赋值</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(5)]
|
||||||
public double ElapsedMilliseconds { get; set; }
|
public double ElapsedMilliseconds { get; set; }
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 时间戳
|
||||||
|
/// </summary>
|
||||||
|
[Key(6)]
|
||||||
|
public long Timestamp { get; set;}
|
||||||
|
|
||||||
#region --- 快捷构造方法 ---
|
#region --- 快捷构造方法 ---
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using MessagePack;
|
||||||
|
using System;
|
||||||
|
|
||||||
namespace SHH.Contracts
|
namespace SHH.Contracts
|
||||||
{
|
{
|
||||||
@@ -6,26 +7,41 @@ namespace SHH.Contracts
|
|||||||
/// 通用指令请求载体 (Request)
|
/// 通用指令请求载体 (Request)
|
||||||
/// <para>用于 NetMQ 的 Request-Reply 或 Router-Dealer 模式</para>
|
/// <para>用于 NetMQ 的 Request-Reply 或 Router-Dealer 模式</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
public class CommandPayload
|
public class CommandPayload
|
||||||
{
|
{
|
||||||
|
#region --- 0. 协议自描述 ---
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 协议类型标识
|
||||||
|
/// <para>建议值: "COMMAND" 或 "指令包"</para>
|
||||||
|
/// </summary>
|
||||||
|
[Key(0)]
|
||||||
|
public string Protocol { get; set; } = "COMMAND";
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
#region --- 核心路由信息 ---
|
#region --- 核心路由信息 ---
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 指令代码 (路由键)
|
/// 指令代码 (路由键)
|
||||||
/// <para>示例: "PTZ", "RECORD_START", "SERVER_REGISTER"</para>
|
/// <para>示例: "PTZ", "RECORD_START", "SERVER_REGISTER"</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(1)]
|
||||||
public string CmdCode { get; set; }
|
public string CmdCode { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 目标对象 ID
|
/// 目标对象 ID
|
||||||
/// <para>示例: 摄像头ID "101",或者系统级指令填 "SYSTEM"</para>
|
/// <para>示例: 摄像头ID "101",或者系统级指令填 "SYSTEM"</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(2)]
|
||||||
public string TargetId { get; set; }
|
public string TargetId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 业务参数 (JSON 字符串)
|
/// 业务参数 (JSON 字符串)
|
||||||
/// <para>根据 CmdCode 的不同,反序列化为不同的 DTO (如 PtzControlDto)</para>
|
/// <para>根据 CmdCode 的不同,反序列化为不同的 DTO (如 PtzControlDto)</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(3)]
|
||||||
public string JsonParams { get; set; }
|
public string JsonParams { get; set; }
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -36,11 +52,13 @@ namespace SHH.Contracts
|
|||||||
/// 请求追踪 ID (UUID)
|
/// 请求追踪 ID (UUID)
|
||||||
/// <para>核心字段:用于实现异步等待 (await)。回执包必须携带此 ID。</para>
|
/// <para>核心字段:用于实现异步等待 (await)。回执包必须携带此 ID。</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(4)]
|
||||||
public string RequestId { get; set; } = Guid.NewGuid().ToString("N");
|
public string RequestId { get; set; } = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 发送时间戳
|
/// 发送时间戳
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(5)]
|
||||||
public DateTime Timestamp { get; set; } = DateTime.Now;
|
public DateTime Timestamp { get; set; } = DateTime.Now;
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -52,6 +70,7 @@ namespace SHH.Contracts
|
|||||||
/// <para>true: 发送端会 await 等待结果 (默认)</para>
|
/// <para>true: 发送端会 await 等待结果 (默认)</para>
|
||||||
/// <para>false: 发后即忘 (Fire-and-Forget),服务端收到后不回发任何消息,减少带宽</para>
|
/// <para>false: 发后即忘 (Fire-and-Forget),服务端收到后不回发任何消息,减少带宽</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(6)]
|
||||||
public bool RequireAck { get; set; } = true;
|
public bool RequireAck { get; set; } = true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -60,12 +79,14 @@ namespace SHH.Contracts
|
|||||||
/// <para>1, 2...: 第N次重试</para>
|
/// <para>1, 2...: 第N次重试</para>
|
||||||
/// <para>服务端据此判断是否需要查重 (幂等性处理)</para>
|
/// <para>服务端据此判断是否需要查重 (幂等性处理)</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(7)]
|
||||||
public int RetryCount { get; set; } = 0;
|
public int RetryCount { get; set; } = 0;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 消息过期时间 (Unix时间戳)
|
/// 消息过期时间 (Unix时间戳)
|
||||||
/// <para>如果接收端收到时已经超过此时间,直接丢弃,不处理也不回复</para>
|
/// <para>如果接收端收到时已经超过此时间,直接丢弃,不处理也不回复</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(8)]
|
||||||
public long ExpireTime { get; set; }
|
public long ExpireTime { get; set; }
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
49
SHH.Contracts/Commands/DeviceStatusEvent.cs
Normal file
49
SHH.Contracts/Commands/DeviceStatusEvent.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using MessagePack;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace SHH.Contracts
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// [控制面] 状态全量快照包
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public class StatusBatchPayload
|
||||||
|
{
|
||||||
|
// [新增] 协议类型标识 (人工可读)
|
||||||
|
// 建议值: "STATUS_BATCH" 或 "设备状态全量包"
|
||||||
|
[Key(0)]
|
||||||
|
public string Protocol { get; set; } = "STATUS_BATCH";
|
||||||
|
|
||||||
|
[Key(1)]
|
||||||
|
public List<StatusEventPayload> Items { get; set; }
|
||||||
|
= new List<StatusEventPayload>();
|
||||||
|
|
||||||
|
[Key(2)]
|
||||||
|
public long Timestamp { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// [控制面] 设备状态变更通知包
|
||||||
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
|
public class StatusEventPayload
|
||||||
|
{
|
||||||
|
[Key(0)]
|
||||||
|
public string CameraId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// true: 上线/活跃, false: 离线/超时
|
||||||
|
/// </summary>
|
||||||
|
[Key(1)]
|
||||||
|
public bool IsOnline { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 变更原因 (e.g. "Ping Success", "Frame Timeout")
|
||||||
|
/// </summary>
|
||||||
|
[Key(2)]
|
||||||
|
public string Reason { get; set; }
|
||||||
|
|
||||||
|
[Key(3)]
|
||||||
|
public long Timestamp { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
13
SHH.Contracts/Commands/ProtocolHeaders.cs
Normal file
13
SHH.Contracts/Commands/ProtocolHeaders.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace SHH.Contracts
|
||||||
|
{
|
||||||
|
public static class ProtocolHeaders
|
||||||
|
{
|
||||||
|
// 核心协议头定义
|
||||||
|
public const string ServerRegister = "SERVER_REGISTER";
|
||||||
|
public const string StatusBatch = "STATUS_BATCH";
|
||||||
|
public const string Command = "COMMAND";
|
||||||
|
public const string CommandResult = "COMMAND_RESULT";
|
||||||
|
|
||||||
|
public const string SyncCamera = "Sync_Camera";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using MessagePack;
|
||||||
|
using System;
|
||||||
|
|
||||||
namespace SHH.Contracts
|
namespace SHH.Contracts
|
||||||
{
|
{
|
||||||
@@ -6,24 +7,38 @@ namespace SHH.Contracts
|
|||||||
/// 服务端身份注册信息 (DTO)
|
/// 服务端身份注册信息 (DTO)
|
||||||
/// <para>用于服务端主动连上客户端后,上报自身的端口和身份信息</para>
|
/// <para>用于服务端主动连上客户端后,上报自身的端口和身份信息</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ServerRegistrationDto
|
[MessagePackObject]
|
||||||
|
public class RegisterPayload
|
||||||
{
|
{
|
||||||
|
#region --- 0. 协议自描述 ---
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 协议类型标识 (人工可读)
|
||||||
|
/// </summary>
|
||||||
|
[Key(0)]
|
||||||
|
public string Protocol { get; set; } = ProtocolHeaders.ServerRegister;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
#region --- 1. 身份标识 ---
|
#region --- 1. 身份标识 ---
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 进程 ID (用于区分同一台机器上的多个实例)
|
/// 进程 ID (用于区分同一台机器上的多个实例)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(1)]
|
||||||
public int ProcessId { get; set; }
|
public int ProcessId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 实例唯一标识符
|
/// 实例唯一标识符
|
||||||
/// <para>启动时通过命令行传入,例如 "Gateway_Factory_A"</para>
|
/// <para>启动时通过命令行传入,例如 "Gateway_Factory_A"</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(2)]
|
||||||
public string InstanceId { get; set; }
|
public string InstanceId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 服务端版本号
|
/// 服务端版本号
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(3)]
|
||||||
public string Version { get; set; } = "1.0.0";
|
public string Version { get; set; } = "1.0.0";
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -34,22 +49,26 @@ namespace SHH.Contracts
|
|||||||
/// 服务端所在的局域网 IP
|
/// 服务端所在的局域网 IP
|
||||||
/// <para>客户端无法直接连接此IP(因为可能是内网),但运维人员需要知道</para>
|
/// <para>客户端无法直接连接此IP(因为可能是内网),但运维人员需要知道</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(4)]
|
||||||
public string ServerIp { get; set; }
|
public string ServerIp { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// WebAPI 监听端口 (HTTP)
|
/// WebAPI 监听端口 (HTTP)
|
||||||
/// <para>用于运维人员打开 Swagger 进行调试</para>
|
/// <para>用于运维人员打开 Swagger 进行调试</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(5)]
|
||||||
public int WebApiPort { get; set; }
|
public int WebApiPort { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 视频流端口 (ZeroMQ Publisher/Push)
|
/// 视频流端口 (ZeroMQ Publisher/Push)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(6)]
|
||||||
public int VideoPort { get; set; }
|
public int VideoPort { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 指令流端口 (ZeroMQ Response)
|
/// 指令流端口 (ZeroMQ Response)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(7)]
|
||||||
public int CmdPort { get; set; }
|
public int CmdPort { get; set; }
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -59,11 +78,13 @@ namespace SHH.Contracts
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 启动时间
|
/// 启动时间
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(8)]
|
||||||
public DateTime StartTime { get; set; }
|
public DateTime StartTime { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 描述信息 (可选)
|
/// 描述信息 (可选)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(9)]
|
||||||
public string Description { get; set; }
|
public string Description { get; set; }
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System;
|
using MessagePack;
|
||||||
using System.Collections.Generic;
|
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
using System.Collections.Generic;
|
||||||
// 注意:如果不想依赖 Newtonsoft,也可以用 System.Text.Json,但 Newtonsoft 在 Std 2.0 中兼容性更好
|
// 注意:如果不想依赖 Newtonsoft,也可以用 System.Text.Json,但 Newtonsoft 在 Std 2.0 中兼容性更好
|
||||||
|
|
||||||
namespace SHH.Contracts
|
namespace SHH.Contracts
|
||||||
@@ -8,6 +8,7 @@ namespace SHH.Contracts
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 视频数据传输契约(纯净版 POCO)
|
/// 视频数据传输契约(纯净版 POCO)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[MessagePackObject]
|
||||||
public class VideoPayload
|
public class VideoPayload
|
||||||
{
|
{
|
||||||
public VideoPayload()
|
public VideoPayload()
|
||||||
@@ -18,35 +19,49 @@ namespace SHH.Contracts
|
|||||||
|
|
||||||
#region --- 1. 元数据 (Metadata) ---
|
#region --- 1. 元数据 (Metadata) ---
|
||||||
|
|
||||||
|
[Key(0)]
|
||||||
public string CameraId { get; set; }
|
public string CameraId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 采集时间戳 (Unix 毫秒)
|
/// 采集时间戳 (Unix 毫秒)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(1)]
|
||||||
public long CaptureTimestamp { get; set; }
|
public long CaptureTimestamp { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 分发时间戳 (Unix 毫秒)
|
/// 分发时间戳 (Unix 毫秒)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(2)]
|
||||||
public long DispatchTimestamp { get; set; }
|
public long DispatchTimestamp { get; set; }
|
||||||
|
|
||||||
|
[Key(3)]
|
||||||
public int OriginalWidth { get; set; }
|
public int OriginalWidth { get; set; }
|
||||||
|
|
||||||
|
[Key(4)]
|
||||||
public int OriginalHeight { get; set; }
|
public int OriginalHeight { get; set; }
|
||||||
|
|
||||||
|
[Key(5)]
|
||||||
public int TargetWidth { get; set; }
|
public int TargetWidth { get; set; }
|
||||||
|
|
||||||
|
[Key(6)]
|
||||||
public int TargetHeight { get; set; }
|
public int TargetHeight { get; set; }
|
||||||
|
|
||||||
public List<string> SubscriberIds { get; }
|
[Key(7)]
|
||||||
|
public List<string> SubscriberIds { get; set; }
|
||||||
|
|
||||||
public Dictionary<string, object> Diagnostics { get; }
|
[Key(8)]
|
||||||
|
public Dictionary<string, object> Diagnostics { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 指示标志:是否存在原始图
|
/// 指示标志:是否存在原始图
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(9)]
|
||||||
public bool HasOriginalImage { get; set; }
|
public bool HasOriginalImage { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 指示标志:是否存在处理图
|
/// 指示标志:是否存在处理图
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Key(10)]
|
||||||
public bool HasTargetImage { get; set; }
|
public bool HasTargetImage { get; set; }
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -55,9 +70,11 @@ namespace SHH.Contracts
|
|||||||
|
|
||||||
// 标记 JsonIgnore,防止被错误序列化
|
// 标记 JsonIgnore,防止被错误序列化
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
|
[IgnoreMember]
|
||||||
public byte[] OriginalImageBytes { get; set; }
|
public byte[] OriginalImageBytes { get; set; }
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
|
[IgnoreMember]
|
||||||
public byte[] TargetImageBytes { get; set; }
|
public byte[] TargetImageBytes { get; set; }
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
|
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
Reference in New Issue
Block a user