在 AiVideo 中能看到图像

增加了在线状态同步逻辑
This commit is contained in:
2026-01-09 12:30:36 +08:00
parent 3d47c8f009
commit 3351ae739e
31 changed files with 1090 additions and 477 deletions

View File

@@ -1,4 +1,4 @@
using SHH.CameraDashboard.Services;
using MessagePack;
using SHH.Contracts;
using SHH.ProcessLaunchers;
using System.Collections.ObjectModel;
@@ -30,9 +30,13 @@ namespace SHH.CameraDashboard
StreamReceiverService.Instance.Start(6002);
// 启动指令服务 (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 = $"" +
$"--pid {myPid} " +
$"--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 \"192.168.1.100,6002,video,;\" " +
$"--uris \"127.0.0.1,6004,command,PC;\" " +
$"--uris \"127.0.0.1,6002,video,;\" " +
$"--mode 1 " +
$"--ports \"5000,100\"";
@@ -71,7 +76,7 @@ namespace SHH.CameraDashboard
Id = "CameraService", // 内部标识
DisplayName = "视频接入服务", // UI显示名称
// 请确保路径正确,建议用相对路径 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, // ★★★ 核心:注入参数 ★★★
StartupOrder = 1, // 优先级
RestartDelayMs = 2000, // 崩溃后2秒重启
@@ -90,6 +95,76 @@ namespace SHH.CameraDashboard
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>

View File

@@ -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;
}
}
}

View File

@@ -1,4 +1,5 @@
using NetMQ;
using MessagePack;
using NetMQ;
using NetMQ.Sockets;
using Newtonsoft.Json;
using SHH.Contracts; // ★★★ 必须引用契约库 ★★★
@@ -77,9 +78,16 @@ public class StreamReceiverService : IDisposable
// 3. 协议头校验 (Frame 0)
if (msg[0].ConvertToString() != "SHH_V1") continue;
//// 4. 反序列化元数据 (Frame 1)
//string json = msg[1].ConvertToString();
//var payload = JsonConvert.DeserializeObject<VideoPayload>(json);
// 4. 反序列化元数据 (Frame 1)
string json = msg[1].ConvertToString();
var payload = JsonConvert.DeserializeObject<VideoPayload>(json);
// 直接获取二进制数据,不需要转 String (省去了 UTF8 解码开销)
byte[] metaBytes = msg[1].ToByteArray();
// 极速反序列化
var payload = MessagePackSerializer.Deserialize<VideoPayload>(metaBytes);
if (payload == null) continue;

View File

@@ -1,390 +1,158 @@
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Threading.Tasks;
using MessagePack;
using NetMQ;
using NetMQ.Sockets;
using Newtonsoft.Json;
using SHH.CameraDashboard.Services.Processors;
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
{
#region --- 1. ---
private RouterSocket? _routerSocket;
private NetMQPoller? _poller;
private volatile bool _isRunning;
private readonly object _disposeLock = new object();
// 默认超时设置
private const int DEFAULT_TIMEOUT_MS = 2000;
private const int DEFAULT_MAX_RETRIES = 2;
// 单例模式
public static CommandBusClient Instance { get; } = new CommandBusClient();
// 处理器字典
private readonly Dictionary<string, IProtocolProcessor> _processors = new();
// ★★★ 核心:线程安全的任务字典 <RequestId, TCS> ★★★
// Key: 请求ID (身份证号)
// Value: 异步任务凭证 (用于 await 唤醒)
private readonly ConcurrentDictionary<string, TaskCompletionSource<CommandResult>> _pendingRequests
= new ConcurrentDictionary<string, TaskCompletionSource<CommandResult>>();
// ★★★ 核心:路由表 ★★★
// Key: 实例ID (例如 "Gateway_01")
// Value: NetMQ 路由 Identity (二进制地址,这是 Router 发消息必须的“信封地址”)
private readonly ConcurrentDictionary<string, byte[]> _sessions
= new ConcurrentDictionary<string, byte[]>();
/// <summary>
/// 当有服务端连上来并完成注册时触发
/// </summary>
public event Action<ServerRegistrationDto>? OnServerRegistered;
// ★★★ 新增:拦截器管道 ★★★
public InterceptorPipeline Pipeline { get; } = new InterceptorPipeline();
#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)
{
if (_isRunning) return;
try
{
lock (_disposeLock)
{
_routerSocket = new RouterSocket();
// 绑定端口,等待服务端(Active Mode)主动来连接
// 使用 tcp://*:{port} 绑定本机所有网卡
_routerSocket.Bind($"tcp://*:{port}");
// 注册接收事件 (基于 NetMQPoller 的异步非阻塞模式)
_routerSocket.ReceiveReady += OnReceiveReady;
_poller = new NetMQPoller { _routerSocket };
_poller.RunAsync(); // 在后台线程启动轮询
_isRunning = true;
Debug.WriteLine($"[ClientBus] 指令中心已启动,监听端口: {port}");
}
}
catch (Exception ex)
{
// 启动失败属于致命错误,记录日志
Debug.WriteLine($"[ClientBus-Error] 启动失败: {ex.Message}");
throw; // 向上抛出,让 UI 层感知并报错
}
}
public void Stop()
{
if (!_isRunning) return;
lock (_disposeLock)
{
_isRunning = false;
try
{
_poller?.Stop();
_poller?.Dispose();
_routerSocket?.Dispose();
}
catch (Exception ex)
{
Debug.WriteLine($"[ClientBus-Error] 停止时异常: {ex.Message}");
}
finally
{
// 彻底清理状态
CleanupPendingTasks();
_sessions.Clear();
}
_routerSocket = new RouterSocket();
_routerSocket.Bind($"tcp://*:{port}");
_routerSocket.ReceiveReady += OnReceiveReady;
// --- 注册处理器 ---
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;
}
}
public void Dispose()
// 注意NetMQ 的事件处理器本质上是同步的 (void)。
// 为了调用异步拦截器,我们需要在这里使用 async void (仅限顶层事件处理)
private async void OnReceiveReady(object? sender, NetMQSocketEventArgs e)
{
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;
// 1. 尝试接收多帧消息
if (!e.Socket.TryReceiveMultipartMessage(ref msg)) return;
// 第一帧永远是发送方的 Identity
byte[] identity = msg[0].Buffer;
// 最后一帧通常是 JSON 数据
string json = msg.Last.ConvertToString();
// 2. 帧校验 (Router 收到 Dealer 消息:[Identity] [Protocol] [Data])
// 此时 msg 应该有 3 帧
if (msg.FrameCount < 3) return;
// 简单的协议识别
// 优化建议:正式项目中可以用更严谨的 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}");
}
}
byte[] identity = msg[0].Buffer; // Frame 0: 路由ID
string protocol = msg[1].ConvertToString(); // Frame 1: 协议标识
byte[] rawData = msg[2].ToByteArray(); // Frame 2: 原始数据
private void HandleRegistration(byte[] identity, string json)
{
try
{
var payload = JsonConvert.DeserializeObject<CommandPayload>(json);
if (payload?.CmdCode == "SERVER_REGISTER")
// =========================================================
// ★★★ 核心改造 A: 接收拦截 (Inbound) ★★★
// =========================================================
// 执行管道处理
var ctx = await Pipeline.ExecuteReceiveAsync(protocol, rawData);
if (ctx != null) // 如果没被拦截
{
var regInfo = JsonConvert.DeserializeObject<ServerRegistrationDto>(payload.JsonParams);
if (regInfo != null)
// 使用处理后的协议和数据进行分发
if (_processors.TryGetValue(ctx.Protocol, out var processor))
{
// 更新路由表:[实例名] -> [二进制地址]
_sessions[regInfo.InstanceId] = identity;
Debug.WriteLine($"[ClientBus] 网关上线: {regInfo.InstanceId} IP: {regInfo.ServerIp}");
// 通知 UI 刷新列表
OnServerRegistered?.Invoke(regInfo);
processor.Process(identity, ctx.Data);
}
else
{
Debug.WriteLine($"[Bus] 未知协议: {ctx.Protocol}");
}
}
}
catch (Exception ex)
{
Debug.WriteLine($"[ClientBus-Warn] 注册包解析失败: {ex.Message}");
Debug.WriteLine($"[Bus-Err] {ex.Message}");
}
}
private void HandleResponse(string json)
{
try
{
var result = JsonConvert.DeserializeObject<CommandResult>(json);
// --- 供 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);
// 闭环匹配:根据 RequestId 找到挂起的 TCS
if (!string.IsNullOrEmpty(result?.RequestId) &&
_pendingRequests.TryGetValue(result.RequestId, out var tcs))
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)
{
// 设置结果 -> 唤醒 SendRequestCore -> 唤醒 SendAsync
tcs.TrySetResult(result);
// 注意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);
}
}
catch (Exception ex)
{
Debug.WriteLine($"[ClientBus-Warn] 回执包解析失败: {ex.Message}");
}
}
#endregion
public void Stop()
{
_isRunning = false;
_poller?.Stop();
_poller?.Dispose();
_routerSocket?.Dispose();
}
public void Dispose() => Stop();
}
}

View 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}");
}
}
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,11 @@
namespace SHH.CameraDashboard
{
public interface IProtocolProcessor
{
// 匹配 Key(0) 的 Protocol 字符串
string ProtocolType { get; }
// 执行具体的解析与业务逻辑
void Process(byte[] identity, byte[] payloadBytes);
}
}

View 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);
}
}

View File

@@ -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);
}
}

View File

@@ -17,7 +17,7 @@ namespace SHH.CameraDashboard
/// <summary>
/// 处理注册/心跳包,更新列表
/// </summary>
public void RegisterOrUpdate(ServerRegistrationDto info)
public void RegisterOrUpdate(RegisterPayload info)
{
// 确保在 UI 线程执行 (WPF 必须)
Application.Current.Dispatcher.Invoke(() =>

View File

@@ -15,11 +15,12 @@ namespace SHH.CameraDashboard
// 用于绑定 ComboBox 的类型列表
public Dictionary<int, string> SubscriptionTypes { get; } = new Dictionary<int, string>
{
{ 0, "本地窗口预览" },
{ 1, "本地录像" },
{ 2, "句柄渲染 (嵌入)" },
{ 3, "网络转发 (TCP/UDP)" },
{ 4, "Web 推流" }
{ 0, "仅取流" },
{ 1, "本地窗口预览" },
{ 2, "本地录像" },
{ 3, "句柄渲染 (嵌入)" },
{ 4, "网络转发 (TCP/UDP)" },
{ 5, "Web 推流" }
};
// --- 数据源 ---

View File

@@ -437,4 +437,25 @@ public class CameraManager : IDisposable, IAsyncDisposable
// 复用现有的 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
}

View File

@@ -1,4 +1,5 @@
using System.Net.NetworkInformation;
using System.Drawing;
using System.Net.NetworkInformation;
namespace SHH.CameraSdk;
@@ -15,6 +16,11 @@ public class ConnectivitySentinel
private readonly PeriodicTimer _timer;
private readonly CancellationTokenSource _cts = new();
// [关键] 状态缓存:用于“去重”。
// 只有当状态真的从 true 变 false (或反之) 时,才通知 Manager。
// 防止每 3 秒发一次 "在线" 骚扰上层。
private readonly ConcurrentDictionary<long, bool> _lastStates = new();
// [关键配置] 最大并发度
// 建议值CPU 核心数 * 4或者固定 16-32
// 50 个摄像头,设为 16意味着分 4 批完成,总耗时极短
@@ -77,6 +83,21 @@ public class ConnectivitySentinel
// [状态注入]:将探测结果“注入”回设备
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 逻辑

View File

@@ -1,8 +1,9 @@
using Microsoft.Extensions.Hosting;
using MessagePack;
using Microsoft.Extensions.Hosting;
using NetMQ;
using NetMQ.Sockets;
using Newtonsoft.Json;
using SHH.CameraSdk;
using SHH.Contracts;
using System.Text;
namespace SHH.CameraService;
@@ -10,130 +11,144 @@ namespace SHH.CameraService;
public class CommandClientWorker : BackgroundService
{
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;
_dispatcher = dispatcher;
_pipeline = pipeline;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// =================================================================
// ★★★ 核心修复:强制让出主线程 ★★★
// 这行代码会让当前的 ExecuteAsync 立即返回一个未完成的 Task 给 Host
// Host 就会认为 "这个服务启动好了",然后继续去启动 WebAPI。
// 而剩下的代码会被调度到线程池里异步执行,互不干扰。
// =================================================================
await Task.Yield();
// 1. 如果不是主动/混合模式,不需要连接
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();
// ★★★ 关键:设置身份标识 (Identity) ★★★
// 服务端 (Router) 收到消息时,第一帧就是这个 ID
// 如果不设ZMQ 会随机生成一个二进制 ID服务端就不知道你是谁了
string myIdentity = _config.AppId;
dealer.Options.Identity = Encoding.UTF8.GetBytes(myIdentity);
// 3. 连接所有目标 (遍历 ServiceEndpoint 对象)
foreach (var ep in cmdEndpoints)
foreach (var ep in _config.CommandEndpoints)
{
Console.WriteLine($"[指令] 连接控制端: {ep.Uri} [{ep.Description}]");
try
{
dealer.Connect(ep.Uri);
}
catch (Exception ex)
{
Console.WriteLine($"[指令] 连接失败 {ep.Uri}: {ex.Message}");
}
try { dealer.Connect(ep.Uri); }
catch (Exception ex) { Console.WriteLine($"[指令] 连接失败 {ep.Uri}: {ex.Message}"); }
}
// 1. 获取本机 IP (简单的获取方式,用于上报给 Dashboard)
string localIp = "127.0.0.1";
try
// ... (获取 IP 代码省略,保持不变) ...
// =================================================================
// 构建注册包
// =================================================================
var registerPayload = new RegisterPayload
{
// 简单获取首个非回环 IP生产环境建议用更严谨的帮助类
var host = System.Net.Dns.GetHostEntry(System.Net.Dns.GetHostName());
localIp = host.AddressList.FirstOrDefault(ip =>
ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)?.ToString() ?? "127.0.0.1";
}
catch { }
// 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
Protocol = ProtocolHeaders.ServerRegister,
InstanceId = _config.AppId,
ProcessId = Environment.ProcessId,
Version = "1.0.0",
ServerIp = localIp,
WebApiPort = _config.BasePort,
StartTime = DateTime.Now
};
string json = JsonConvert.SerializeObject(registerPayload);
try
{
byte[] regData = MessagePackSerializer.Serialize(registerPayload);
// 5. 发送注册包
// Dealer 连接建立是异步的所以这里直接发ZMQ 会在底层连接成功后自动把消息推出去
// 为了保险,对于多个 EndpointDealer 默认是负载均衡发送的(轮询)。
// 如果想让每个 Endpoint 都收到注册包,这在 Dealer 模式下稍微有点特殊。
// 但通常我们只需要发一次,只要有一个 Dashboard 收到并建立会话即可。
// 或者简单粗暴:循环发送几次,确保覆盖。
// =============================================================
// ★ 2. 拦截点 A: 发送注册包 (Outbound)
// =============================================================
var ctx = await _pipeline.ExecuteSendAsync(ProtocolHeaders.ServerRegister, regData);
Console.WriteLine($"[指令] 发送注册包: {json}");
dealer.SendFrame(json);
if (ctx != null) // 如果未被拦截
{
// 注意:这里使用 ctx.Protocol 和 ctx.Data允许拦截器修改内容
dealer.SendMoreFrame(ctx.Protocol)
.SendFrame(ctx.Data);
// 6. 进入监听循环 (等待 ACK 或 指令)
// 进入监听循环
while (!stoppingToken.IsCancellationRequested)
Console.WriteLine($"[指令] 注册包已发送 ({ctx.Data.Length} bytes)");
}
}
catch (Exception ex)
{
Console.WriteLine($"[致命错误] 注册流程异常: {ex.Message}");
return;
}
// =================================================================
// 定义 ACK 发送逻辑 (包含拦截器)
// =================================================================
// 注意:这里需要 async因为拦截器是异步的
Action<CommandResult> sendAckHandler = async (result) =>
{
try
{
if (dealer.TryReceiveFrameString(TimeSpan.FromMilliseconds(500), out string msg))
{
Console.WriteLine($"[指令] 收到消息: {msg}");
byte[] resultBytes = MessagePackSerializer.Serialize(result);
// ★★★ 核心变化:直接扔给分发器 ★★★
// 无论未来加多少指令,这里都不用改代码
await _dispatcher.DispatchAsync(msg);
// =========================================================
// ★ 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($"[指令] 异常: {ex.Message}");
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}");
}
}
}

View File

@@ -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;
public class CommandDispatcher
{
// 路由表Key = ActionName, Value = Handler
// 1. 注入路由表
private readonly Dictionary<string, ICommandHandler> _handlers;
// 通过依赖注入拿到所有实现了 ICommandHandler 的类
// 2. 定义回执事件 (ACK闭环的核心)
public event Action<CommandResult>? OnResponseReady;
// 3. 构造函数:注入所有 Handler
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
{
var jObj = JObject.Parse(jsonMessage);
string action = jObj["Action"]?.ToString();
var payload = jObj["Payload"];
// 只处理 COMMAND 协议
if (protocol != ProtocolHeaders.Command) return;
if (string.IsNullOrEmpty(action)) return;
// 反序列化信封
var envelope = MessagePackSerializer.Deserialize<CommandPayload>(data);
if (envelope == null) return;
// 1. 查找是否有对应的处理器
if (_handlers.TryGetValue(action, out var handler))
string cmdCode = envelope.CmdCode; // e.g. "SyncCamera"
Console.WriteLine($"[分发] 收到指令: {cmdCode} (ID: {envelope.RequestId})");
bool isSuccess = true;
string message = "OK";
// --- 路由匹配逻辑 ---
if (_handlers.TryGetValue(cmdCode, out var handler))
{
await handler.ExecuteAsync(payload);
}
else if (action == "ACK")
{
// ACK 是特殊的,可以直接在这里处理或者忽略
Console.WriteLine($"[指令] 握手成功: {jObj["Message"]}");
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}";
}
catch (Exception ex)
{
isSuccess = false;
message = $"Handler Error: {ex.Message}";
Console.WriteLine($"[业务异常] {message}");
}
}
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)
{
Console.WriteLine($"[分发错误] {ex.Message}");
Console.WriteLine($"[Dispatcher] 致命错误: {ex.Message}");
}
}
}

View File

@@ -8,7 +8,7 @@ public class SyncCameraHandler : ICommandHandler
{
private readonly CameraManager _cameraManager;
public string ActionName => "SyncCamera";
public string ActionName => ProtocolHeaders.SyncCamera;
public SyncCameraHandler(CameraManager cameraManager)
{

View 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
}
}

View 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}");
}
}
}
}
}

View File

@@ -1,4 +1,5 @@
using NetMQ;
using MessagePack;
using NetMQ;
using SHH.Contracts;
namespace SHH.CameraService
@@ -21,8 +22,16 @@ namespace SHH.CameraService
// Frame 0: 协议魔数
msg.Append(PROTOCOL_HEADER);
// Frame 1: 元数据 JSON
msg.Append(payload.GetMetadataJson());
////// Frame 1: 元数据 JSON
////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: 原始图 (保持帧位对齐,无数据则发空帧)
if (payload.HasOriginalImage && payload.OriginalImageBytes != null)
@@ -49,9 +58,14 @@ namespace SHH.CameraService
// Frame 0 Check
if (msg[0].ConvertToString() != PROTOCOL_HEADER) return null;
// Frame 1: Metadata
string json = msg[1].ConvertToString();
var payload = VideoPayload.FromMetadataJson(json);
//// Frame 1: Metadata
//string json = msg[1].ConvertToString();
//var payload = VideoPayload.FromMetadataJson(json);
// [新代码] 直接从二进制还原
// ToByteArray() 虽然会产生一次拷贝,但对于 Metadata 这种小数据影响微乎其微
// 相比 JSON 解析 String 的开销,这已经非常快了
var payload = MessagePackSerializer.Deserialize<VideoPayload>(msg[1].ToByteArray());
if (payload == null) return null;
// Frame 2: Raw Image

View File

@@ -92,6 +92,9 @@ public class NetworkStreamingWorker : BackgroundService
DispatchTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
};
// 添加订阅者
payload.SubscriberIds.AddRange(frame.SubscriberIds);
// 计算转码耗时(ms)
double processMs = (Stopwatch.GetTimestamp() - startTick) * 1000.0 / Stopwatch.Frequency;
payload.Diagnostics["encode_ms"] = Math.Round(processMs, 2);

View 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;
}
}

View 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);
}

View File

@@ -41,6 +41,7 @@ public class Program
// 注册后台引擎 (理由:托管长周期的硬件状态监控)
builder.Services.AddHostedService<CameraEngineWorker>();
builder.Services.AddHostedService<DeviceStateMonitorWorker>();
// 配置 Web 相关的服务
ConfigureWebServices(builder, config);
@@ -70,7 +71,7 @@ public class Program
// 3. 注册采集者 (它会注入上面的 targets进行编码和分发)
builder.Services.AddHostedService<NetworkStreamingWorker>();
// 4. 为每个 Target 注册一个独立的发送者
// 5. 为每个 Target 注册一个独立的发送者
foreach (var target in netTargets)
{
builder.Services.AddHostedService(sp => new NetMqSenderWorker(target));
@@ -80,6 +81,9 @@ public class Program
// 5. 命令管道配置
// =============================================================
// 2. 注册管道管理器
builder.Services.AddSingleton<InterceptorPipeline>();
// 负责连接 Dashboard注册身份接收重启/控制指令
builder.Services.AddHostedService<CommandClientWorker>();

View File

@@ -1,16 +1,27 @@
namespace SHH.Contracts
using MessagePack;
namespace SHH.Contracts
{
/// <summary>
/// 通用指令执行结果 (Response)
/// </summary>
[MessagePackObject]
public class CommandResult
{
#region --- 0. ---
[Key(0)]
public string Protocol { get; set; } = "COMMAND_RESULT";
#endregion
#region --- ---
/// <summary>
/// 回执 ID (必须与请求包的 RequestId 一致)
/// <para>客户端靠这个 ID 来找到对应的 await Task</para>
/// </summary>
[Key(1)]
public string RequestId { get; set; }
#endregion
@@ -20,17 +31,20 @@
/// <summary>
/// 执行是否成功
/// </summary>
[Key(2)]
public bool Success { get; set; }
/// <summary>
/// 结果消息 (成功提示或错误原因)
/// </summary>
[Key(3)]
public string Message { get; set; }
/// <summary>
/// 返回的数据 (JSON 或 Base64 字符串)
/// <para>示例: 截图的 Base64或者查询到的设备列表 JSON</para>
/// </summary>
[Key(4)]
public string Data { get; set; }
#endregion
@@ -42,10 +56,17 @@
/// <para>从客户端发出指令,到收到服务端回执的总时长</para>
/// <para>注意:该字段由客户端收到回执后自动计算填充,服务端不需要赋值</para>
/// </summary>
[Key(5)]
public double ElapsedMilliseconds { get; set; }
#endregion
/// <summary>
/// 时间戳
/// </summary>
[Key(6)]
public long Timestamp { get; set;}
#region --- ---
/// <summary>

View File

@@ -1,4 +1,5 @@
using System;
using MessagePack;
using System;
namespace SHH.Contracts
{
@@ -6,26 +7,41 @@ namespace SHH.Contracts
/// 通用指令请求载体 (Request)
/// <para>用于 NetMQ 的 Request-Reply 或 Router-Dealer 模式</para>
/// </summary>
[MessagePackObject]
public class CommandPayload
{
#region --- 0. ---
/// <summary>
/// 协议类型标识
/// <para>建议值: "COMMAND" 或 "指令包"</para>
/// </summary>
[Key(0)]
public string Protocol { get; set; } = "COMMAND";
#endregion
#region --- ---
/// <summary>
/// 指令代码 (路由键)
/// <para>示例: "PTZ", "RECORD_START", "SERVER_REGISTER"</para>
/// </summary>
[Key(1)]
public string CmdCode { get; set; }
/// <summary>
/// 目标对象 ID
/// <para>示例: 摄像头ID "101",或者系统级指令填 "SYSTEM"</para>
/// </summary>
[Key(2)]
public string TargetId { get; set; }
/// <summary>
/// 业务参数 (JSON 字符串)
/// <para>根据 CmdCode 的不同,反序列化为不同的 DTO (如 PtzControlDto)</para>
/// </summary>
[Key(3)]
public string JsonParams { get; set; }
#endregion
@@ -36,11 +52,13 @@ namespace SHH.Contracts
/// 请求追踪 ID (UUID)
/// <para>核心字段:用于实现异步等待 (await)。回执包必须携带此 ID。</para>
/// </summary>
[Key(4)]
public string RequestId { get; set; } = Guid.NewGuid().ToString("N");
/// <summary>
/// 发送时间戳
/// </summary>
[Key(5)]
public DateTime Timestamp { get; set; } = DateTime.Now;
#endregion
@@ -52,6 +70,7 @@ namespace SHH.Contracts
/// <para>true: 发送端会 await 等待结果 (默认)</para>
/// <para>false: 发后即忘 (Fire-and-Forget),服务端收到后不回发任何消息,减少带宽</para>
/// </summary>
[Key(6)]
public bool RequireAck { get; set; } = true;
/// <summary>
@@ -60,12 +79,14 @@ namespace SHH.Contracts
/// <para>1, 2...: 第N次重试</para>
/// <para>服务端据此判断是否需要查重 (幂等性处理)</para>
/// </summary>
[Key(7)]
public int RetryCount { get; set; } = 0;
/// <summary>
/// 消息过期时间 (Unix时间戳)
/// <para>如果接收端收到时已经超过此时间,直接丢弃,不处理也不回复</para>
/// </summary>
[Key(8)]
public long ExpireTime { get; set; }
#endregion

View 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; }
}
}

View 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";
}
}

View File

@@ -1,4 +1,5 @@
using System;
using MessagePack;
using System;
namespace SHH.Contracts
{
@@ -6,24 +7,38 @@ namespace SHH.Contracts
/// 服务端身份注册信息 (DTO)
/// <para>用于服务端主动连上客户端后,上报自身的端口和身份信息</para>
/// </summary>
public class ServerRegistrationDto
[MessagePackObject]
public class RegisterPayload
{
#region --- 0. ---
/// <summary>
/// 协议类型标识 (人工可读)
/// </summary>
[Key(0)]
public string Protocol { get; set; } = ProtocolHeaders.ServerRegister;
#endregion
#region --- 1. ---
/// <summary>
/// 进程 ID (用于区分同一台机器上的多个实例)
/// </summary>
[Key(1)]
public int ProcessId { get; set; }
/// <summary>
/// 实例唯一标识符
/// <para>启动时通过命令行传入,例如 "Gateway_Factory_A"</para>
/// </summary>
[Key(2)]
public string InstanceId { get; set; }
/// <summary>
/// 服务端版本号
/// </summary>
[Key(3)]
public string Version { get; set; } = "1.0.0";
#endregion
@@ -34,22 +49,26 @@ namespace SHH.Contracts
/// 服务端所在的局域网 IP
/// <para>客户端无法直接连接此IP(因为可能是内网),但运维人员需要知道</para>
/// </summary>
[Key(4)]
public string ServerIp { get; set; }
/// <summary>
/// WebAPI 监听端口 (HTTP)
/// <para>用于运维人员打开 Swagger 进行调试</para>
/// </summary>
[Key(5)]
public int WebApiPort { get; set; }
/// <summary>
/// 视频流端口 (ZeroMQ Publisher/Push)
/// </summary>
[Key(6)]
public int VideoPort { get; set; }
/// <summary>
/// 指令流端口 (ZeroMQ Response)
/// </summary>
[Key(7)]
public int CmdPort { get; set; }
#endregion
@@ -59,11 +78,13 @@ namespace SHH.Contracts
/// <summary>
/// 启动时间
/// </summary>
[Key(8)]
public DateTime StartTime { get; set; }
/// <summary>
/// 描述信息 (可选)
/// </summary>
[Key(9)]
public string Description { get; set; }
#endregion

View File

@@ -1,6 +1,6 @@
using System;
using System.Collections.Generic;
using MessagePack;
using Newtonsoft.Json;
using System.Collections.Generic;
// 注意:如果不想依赖 Newtonsoft也可以用 System.Text.Json但 Newtonsoft 在 Std 2.0 中兼容性更好
namespace SHH.Contracts
@@ -8,6 +8,7 @@ namespace SHH.Contracts
/// <summary>
/// 视频数据传输契约(纯净版 POCO
/// </summary>
[MessagePackObject]
public class VideoPayload
{
public VideoPayload()
@@ -18,35 +19,49 @@ namespace SHH.Contracts
#region --- 1. (Metadata) ---
[Key(0)]
public string CameraId { get; set; }
/// <summary>
/// 采集时间戳 (Unix 毫秒)
/// </summary>
[Key(1)]
public long CaptureTimestamp { get; set; }
/// <summary>
/// 分发时间戳 (Unix 毫秒)
/// </summary>
[Key(2)]
public long DispatchTimestamp { get; set; }
[Key(3)]
public int OriginalWidth { get; set; }
[Key(4)]
public int OriginalHeight { get; set; }
[Key(5)]
public int TargetWidth { get; set; }
[Key(6)]
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>
[Key(9)]
public bool HasOriginalImage { get; set; }
/// <summary>
/// 指示标志:是否存在处理图
/// </summary>
[Key(10)]
public bool HasTargetImage { get; set; }
#endregion
@@ -55,9 +70,11 @@ namespace SHH.Contracts
// 标记 JsonIgnore防止被错误序列化
[JsonIgnore]
[IgnoreMember]
public byte[] OriginalImageBytes { get; set; }
[JsonIgnore]
[IgnoreMember]
public byte[] TargetImageBytes { get; set; }
#endregion

View File

@@ -5,6 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
</ItemGroup>