NetMQ 协议,支持摄像头增、删、改

This commit is contained in:
2026-01-12 18:27:58 +08:00
parent 031d4f3416
commit 3f8e42e560
20 changed files with 604 additions and 332 deletions

View File

@@ -21,6 +21,29 @@ namespace SHH.CameraDashboard
#endregion #endregion
#region OnExit
/// <summary>
/// 退出时执行
/// </summary>
/// <param name="e"></param>
protected override void OnExit(ExitEventArgs e)
{
// 1. 显式停止通讯总线
CommandBusClient.Instance.Stop();
// 2. 如果你有其他的单例服务需要清理(比如视频解码库),也放在这里
base.OnExit(e);
// 3. 终极保底:如果程序在清理逻辑执行后 3 秒还没消失,强制杀掉进程
// 防止某些第三方 DLL如海康 SDK的线程卡死
Task.Delay(3000).ContinueWith(_ =>
{
System.Diagnostics.Process.GetCurrentProcess().Kill();
});
}
#endregion
protected override async void OnStartup(StartupEventArgs e) protected override async void OnStartup(StartupEventArgs e)
{ {
@@ -99,142 +122,70 @@ namespace SHH.CameraDashboard
{ {
Console.WriteLine($"[自动化] 新服务上线: {client.InstanceId}"); Console.WriteLine($"[自动化] 新服务上线: {client.InstanceId}");
Task.Run(async () => //Task.Run(async () =>
{ //{
await Task.Delay(500); // await Task.Delay(500);
// 1. 构建业务配置对象 // // 1. 构建业务配置对象
var cameraConfig = new CameraConfigDto // var cameraConfig = new CameraConfigDto
{ // {
Id = 17798, // Id = 17798,
Name = "206摄像头", // Name = "206摄像头",
Location = "404办公室", // Location = "404办公室",
IpAddress = "172.16.41.88", // IpAddress = "172.16.41.88",
Username = "admin", // Username = "admin",
Password = "abcd1234", // Password = "abcd1234",
Port = 8000, // Port = 8000,
ChannelIndex = 1, // ChannelIndex = 1,
StreamType = 0, // StreamType = 0,
Brand = DeviceBrand.HikVision.GetHashCode(), // 对应 DeviceBrand 枚举 // Brand = DeviceBrand.HikVision.GetHashCode(), // 对应 DeviceBrand 枚举
RenderHandle = 0, // 初始化为0 // RenderHandle = 0, // 初始化为0
MainboardIp = "", // 留空 // MainboardIp = "", // 留空
MainboardPort = 0, // MainboardPort = 0,
RtspPath = "" // RtspPath = ""
}; // };
// ★ 新增:一并带上订阅要求 ★ // // ★ 新增:一并带上订阅要求 ★
cameraConfig.AutoSubscriptions = new List<CameraConfigSubscribeDto> // cameraConfig.AutoSubscriptions = new List<CameraConfigSubscribeDto>
{ // {
// 第一条:显示帧,要求 8 帧 // // 第一条:显示帧,要求 8 帧
new CameraConfigSubscribeDto { // new CameraConfigSubscribeDto {
AppId = "UI_Display", // AppId = "UI_Display",
Type = 0, // Type = 0,
TargetFps = 8, // TargetFps = 8,
Memo = "显示帧" // Memo = "显示帧"
}, // },
// 第二条:分析帧,要求 1 帧 // // 第二条:分析帧,要求 1 帧
new CameraConfigSubscribeDto { // new CameraConfigSubscribeDto {
AppId = "AI_Analysis", // AppId = "AI_Analysis",
Type = 0, // Type = 0,
Memo = "分析帧", // Memo = "分析帧",
TargetFps = 1 // TargetFps = 1
} // }
}; // };
// 2. 构造指令包 // // 2. 构造指令包
var command = new CommandPayload // var command = new CommandPayload
{ // {
Protocol = ProtocolHeaders.Command, // Protocol = ProtocolHeaders.Command,
CmdCode = ProtocolHeaders.SyncCamera, // CmdCode = ProtocolHeaders.SyncCamera,
TargetId = client.InstanceId, // TargetId = client.InstanceId,
RequestId = Guid.NewGuid().ToString("N"), // RequestId = Guid.NewGuid().ToString("N"),
// ★ 修正 1: 使用 JsonParams 属性名,并将对象序列化为 JSON 字符串 ★ // // ★ 修正 1: 使用 JsonParams 属性名,并将对象序列化为 JSON 字符串 ★
// 因为你的 DTO 定义 JsonParams 是 string 类型 // // 因为你的 DTO 定义 JsonParams 是 string 类型
JsonParams = JsonHelper.Serialize(cameraConfig), // JsonParams = JsonHelper.Serialize(cameraConfig),
// ★ 修正 2: Timestamp 直接赋值 DateTime 对象 ★ // // ★ 修正 2: Timestamp 直接赋值 DateTime 对象 ★
// 因为你的 DTO 定义 Timestamp 是 DateTime 类型 // // 因为你的 DTO 定义 Timestamp 是 DateTime 类型
Timestamp = DateTime.Now, // Timestamp = DateTime.Now,
RequireAck = true // RequireAck = true
}; // };
// 3. 发送 // // 3. 发送
await CommandBusClient.Instance.SendInternalAsync(client.InstanceId, command); // await CommandBusClient.Instance.SendInternalAsync(client.InstanceId, command);
}); //});
}
/// <summary>
/// 在程序启动时订阅事件
/// </summary>
/// <param name="obj"></param>
private void SetupAutomaticConfiguration(ConnectedClient obj)
{
// 监听注册事件:每当有 Service (CommandClientWorker) 连上来注册成功
CommandServer.Instance.OnClientRegistered += (client) =>
{
Console.WriteLine($"[自动化] 检测到新服务上线: {client.ServiceId} ({client.Ip})");
// 放到线程池去执行,避免阻塞 UI 或网络接收线程
Task.Run(async () =>
{
// 1. 稍微延时一点点 (500ms),给 Service 一点喘息时间准备接收指令
await Task.Delay(500);
// 2. 构造您指定的“206摄像头”配置
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
}
};
// 3. 封装协议包
var commandPacket = new
{
Action = "SyncCamera", // 告诉 Service 执行什么动作
Payload = cameraConfig, // 数据载荷
Time = DateTime.Now
};
// 4. 定向发送
// client.ServiceId 就是那个 "CameraApp_01"
CommandServer.Instance.SendCommand(client.ServiceId, commandPacket);
Console.WriteLine($"[自动化] 已向 {client.ServiceId} 下发配置: 206摄像头");
});
};
} }
/// <summary> /// <summary>

View File

@@ -147,10 +147,37 @@ namespace SHH.CameraDashboard
public void Stop() public void Stop()
{ {
// 增加锁,防止重复释放
lock (_disposeLock)
{
if (!_isRunning) return;
_isRunning = false; _isRunning = false;
_poller?.Stop();
_poller?.Dispose(); Console.WriteLine("[Bus] 正在释放 NetMQ 资源...");
_routerSocket?.Dispose();
// 1. 停止 Poller
if (_poller != null)
{
_poller.Stop();
_poller.Dispose();
_poller = null;
}
// 2. 释放 Socket
if (_routerSocket != null)
{
_routerSocket.Close();
_routerSocket.Dispose();
_routerSocket = null;
}
// 3. ★★★ 解决残留的关键:强制清理静态环境 ★★★
// 参数为 true 会等待后台 I/O 线程完成(可能卡住),
// 参数为 false 则强制放弃未完成的 I/O 直接关闭。
NetMQConfig.Cleanup(false);
Console.WriteLine("[Bus] NetMQ 资源已安全释放。");
}
} }
public void Dispose() => Stop(); public void Dispose() => Stop();

View File

@@ -62,6 +62,7 @@ public class VideoSourceConfig
#endregion #endregion
#region --- 2. (Vendor-Specific Extensions) --- #region --- 2. (Vendor-Specific Extensions) ---
/// <summary> /// <summary>

View File

@@ -232,7 +232,7 @@ public class CamerasController : ControllerBase
ChannelIndex = dto.ChannelIndex, ChannelIndex = dto.ChannelIndex,
Brand = dto.Brand, Brand = dto.Brand,
RtspPath = dto.RtspPath, RtspPath = dto.RtspPath,
RenderHandle = dto.RenderHandle,
// ========================================== // ==========================================
// 2. 热更新参数 (运行时属性) // 2. 热更新参数 (运行时属性)
@@ -243,10 +243,7 @@ public class CamerasController : ControllerBase
MainboardIp = dto.MainboardIp, MainboardIp = dto.MainboardIp,
MainboardPort = dto.MainboardPort, MainboardPort = dto.MainboardPort,
RenderHandle = dto.RenderHandle,
// 注意:通常句柄是通过 bind-handle 接口单独绑定的,
// 但如果 ConfigDto 里包含了上次保存的句柄,也可以映射
// RenderHandle = dto.RenderHandle,
// ========================================== // ==========================================
// 3. 图像处理参数 // 3. 图像处理参数

View File

@@ -44,15 +44,9 @@ public class DeviceUpdateDto
public string RtspPath { get; set; } public string RtspPath { get; set; }
= string.Empty; = string.Empty;
/// <summary>关联的主板IP (用于联动控制)</summary> /// <summary>渲染句柄 (IntPtr 的 Long 形式)</summary>
[RegularExpression(@"^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)?$", [Range(0, long.MaxValue, ErrorMessage = "渲染句柄必须是非负整数")]
ErrorMessage = "IPv4地址")] public long RenderHandle { get; set; }
public string MainboardIp { get; set; }
= string.Empty;
/// <summary>关联的主板端口</summary>
[Range(1, 65535, ErrorMessage = "主板端口号必须在 1-65535 范围内")]
public int MainboardPort { get; set; }
// ============================================================================== // ==============================================================================
// 2. 热更新参数 (Hot Update) // 2. 热更新参数 (Hot Update)
@@ -71,9 +65,15 @@ public class DeviceUpdateDto
[Range(0, 1, ErrorMessage = "码流类型只能是 0(主码流) 或 1(子码流)")] [Range(0, 1, ErrorMessage = "码流类型只能是 0(主码流) 或 1(子码流)")]
public int? StreamType { get; set; } public int? StreamType { get; set; }
/// <summary>渲染句柄 (IntPtr 的 Long 形式)</summary> /// <summary>关联的主板IP (用于联动控制)</summary>
[Range(0, long.MaxValue, ErrorMessage = "渲染句柄必须是非负整数")] [RegularExpression(@"^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)?$",
public long RenderHandle { get; set; } ErrorMessage = "IPv4地址")]
public string MainboardIp { get; set; }
= string.Empty;
/// <summary>关联的主板端口</summary>
[Range(1, 65535, ErrorMessage = "主板端口号必须在 1-65535 范围内")]
public int MainboardPort { get; set; }
// ============================================================================== // ==============================================================================
// 3. 图像处理参数 (Image Processing - Hot Update) // 3. 图像处理参数 (Image Processing - Hot Update)

View File

@@ -281,6 +281,8 @@ public class CameraManager : IDisposable, IAsyncDisposable
newConfig.Username != oldConfig.Username || newConfig.Username != oldConfig.Username ||
newConfig.Password != oldConfig.Password || newConfig.Password != oldConfig.Password ||
newConfig.ChannelIndex != oldConfig.ChannelIndex || newConfig.ChannelIndex != oldConfig.ChannelIndex ||
newConfig.RtspPath != oldConfig.RtspPath ||
newConfig.RenderHandle != oldConfig.RenderHandle ||
newConfig.Brand != oldConfig.Brand; newConfig.Brand != oldConfig.Brand;
if (needColdRestart) if (needColdRestart)

View File

@@ -0,0 +1,13 @@
namespace SHH.CameraSdk
{
/// <summary>
/// SDk 全局
/// </summary>
public class SdkGlobal
{
/// <summary>
/// 是否保存摄像头配置
/// </summary>
public static bool SaveCameraConfigEnable { get; set; } = false;
}
}

View File

@@ -1,4 +1,6 @@
using System.Drawing; using System.Collections.Concurrent;
using System.Diagnostics;
using System.Drawing;
using System.Net.NetworkInformation; using System.Net.NetworkInformation;
namespace SHH.CameraSdk; namespace SHH.CameraSdk;
@@ -9,23 +11,32 @@ namespace SHH.CameraSdk;
/// 1. 低耦合:不依赖具体驱动,只依赖接口 /// 1. 低耦合:不依赖具体驱动,只依赖接口
/// 2. 高性能:使用 Parallel.ForEachAsync 实现受控并行 /// 2. 高性能:使用 Parallel.ForEachAsync 实现受控并行
/// 3. 智能策略播放中不Ping空闲时才Ping /// 3. 智能策略播放中不Ping空闲时才Ping
/// 4. 稳定性:基于“持续断联时间”判定离线,防止网络瞬抖
/// </summary> /// </summary>
public class ConnectivitySentinel public class ConnectivitySentinel
{ {
private readonly CameraManager _manager; // [cite: 329] private readonly CameraManager _manager; //
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(); private readonly ConcurrentDictionary<long, bool> _lastStates = new();
// [新增] 故障计时器:记录设备“首次探测失败”的时间点
// Key: DeviceId, Value: 首次失败时间
private readonly ConcurrentDictionary<long, DateTime> _failureStartTimes = new();
// [关键配置] 最大并发度 // [关键配置] 最大并发度
// 建议值CPU 核心数 * 4或者固定 16-32
// 50 个摄像头,设为 16意味着分 4 批完成,总耗时极短
private const int MAX_PARALLELISM = 16; private const int MAX_PARALLELISM = 16;
// [配置] 判定离线的持续时间阈值 (秒)
// 只有连续 Ping 不通超过 30秒才认定为断线
private const int OFFLINE_DURATION_THRESHOLD = 30;
// [配置] 单次 Ping 的超时时间 (毫秒)
// 设为 1000ms保证一轮检查快速结束不依赖 Ping 的默认 5秒 超时
private const int PING_TIMEOUT = 1000;
public ConnectivitySentinel(CameraManager manager) public ConnectivitySentinel(CameraManager manager)
{ {
_manager = manager; _manager = manager;
@@ -44,11 +55,9 @@ public class ConnectivitySentinel
while (await _timer.WaitForNextTickAsync(_cts.Token)) while (await _timer.WaitForNextTickAsync(_cts.Token))
{ {
// 1. 获取当前所有设备的快照 // 1. 获取当前所有设备的快照
// CameraManager.GetAllDevices() 返回的是 BaseVideoSource它实现了 IDeviceConnectivity
var devices = _manager.GetAllDevices().Cast<IDeviceConnectivity>(); var devices = _manager.GetAllDevices().Cast<IDeviceConnectivity>();
// 2. [核心回答] 受控并行执行 // 2. [核心回答] 受控并行执行
// .NET 6+ 提供的超级 API专门解决“一下子 50 个”的问题
await Parallel.ForEachAsync(devices, new ParallelOptions await Parallel.ForEachAsync(devices, new ParallelOptions
{ {
MaxDegreeOfParallelism = MAX_PARALLELISM, MaxDegreeOfParallelism = MAX_PARALLELISM,
@@ -66,37 +75,84 @@ public class ConnectivitySentinel
private async Task CheckSingleDeviceAsync(IDeviceConnectivity device) private async Task CheckSingleDeviceAsync(IDeviceConnectivity device)
{ {
bool isAlive = false; // 1. 获取“瞬时”连通性 (Raw Status)
bool isResponsive = false;
// [智能策略]:如果设备正在取流,直接检查帧心跳(省流模式) // [智能策略]:如果设备正在取流,优先检查帧心跳
if (device.Status == VideoSourceStatus.Playing || device.Status == VideoSourceStatus.Streaming) if (device.Status == VideoSourceStatus.Playing || device.Status == VideoSourceStatus.Streaming)
{ {
long now = Environment.TickCount64; long now = Environment.TickCount64;
// 5秒内有帧就算在线 // 5秒内有帧就算瞬时在线
isAlive = (now - device.LastFrameTick) < 5000; isResponsive = (now - device.LastFrameTick) < 5000;
// [双重保障] 如果帧心跳断了,立即 Ping 确认,防止只是解码卡死而非断网
if (!isResponsive)
{
isResponsive = await PingAsync(device.IpAddress);
}
} }
else else
{ {
// [主动探测]:空闲或离线时,发射 ICMP Ping // [主动探测]:空闲或离线时,发射 ICMP Ping
isAlive = await PingAsync(device.IpAddress); isResponsive = await PingAsync(device.IpAddress);
} }
// [状态注入]:将探测结果“注入”回设备 // 2. [核心逻辑] 基于持续时间的稳定性判定 (Stable Status)
device.SetNetworkStatus(isAlive); bool isLogicallyOnline;
if (isResponsive)
{
// --- 情况 A: 瞬时探测通了 ---
// 只要通一次,立即清除故障计时,认为设备在线
_failureStartTimes.TryRemove(device.Id, out _);
isLogicallyOnline = true;
}
else
{
// --- 情况 B: 瞬时探测失败 ---
// 记录或获取“首次失败时间”
var nowTime = DateTime.Now;
var firstFailureTime = _failureStartTimes.GetOrAdd(device.Id, nowTime);
// 计算已经持续失败了多久
var failureDuration = (nowTime - firstFailureTime).TotalSeconds;
if (failureDuration >= OFFLINE_DURATION_THRESHOLD)
{
// 只有持续失败超过 30秒才“真的”判定为离线
isLogicallyOnline = false;
}
else
{
// 还没到 30秒处于“抖动观察期”
// 策略:维持上一次的已知状态(如果之前是在线,就假装还在线;之前是离线,就继续离线)
// 这样可以防止网络微小抖动导致的 Status 频繁跳变
isLogicallyOnline = _lastStates.TryGetValue(device.Id, out bool last) ? last : true;
// 调试日志 (可选)
// Console.WriteLine($"[Sentinel] 设备 {device.Id} 瞬时异常,观察中: {failureDuration:F1}s / {OFFLINE_DURATION_THRESHOLD}s");
}
}
// [状态注入]:将经过时间滤波后的“稳定状态”注入回设备
device.SetNetworkStatus(isLogicallyOnline);
// 3. [状态去重与上报] // 3. [状态去重与上报]
// 获取上一次的状态,如果没记录过,假设它之前是反状态(强制第一次上报) // 获取上一次上报的状态,默认为反状态以触发首次上报
bool lastState = _lastStates.TryGetValue(device.Id, out bool val) ? val : !isAlive; bool lastReported = _lastStates.TryGetValue(device.Id, out bool val) ? val : !isLogicallyOnline;
if (lastState != isAlive) if (lastReported != isLogicallyOnline)
{ {
// 记录新状态 // 记录新状态
_lastStates[device.Id] = isAlive; _lastStates[device.Id] = isLogicallyOnline;
// ★★★ 核心动作:只通知 Manager不做任何网络操作 ★★★ // 构造原因描述
_manager.NotifyStatusChange(device.Id, isAlive, "网络连通性哨兵检测结论"); string reason = isLogicallyOnline
? "网络探测恢复"
: $"持续断连超过{OFFLINE_DURATION_THRESHOLD}秒";
// Console.WriteLine($"[Sentinel] 诊断变化: {device.Id} -> {isAlive}"); // ★★★ 核心动作:通知 Manager ★★★
_manager.NotifyStatusChange(device.Id, isLogicallyOnline, reason);
} }
} }
@@ -106,8 +162,11 @@ public class ConnectivitySentinel
try try
{ {
using var ping = new Ping(); using var ping = new Ping();
// 超时设为 800ms,快速失败,避免拖慢整体批次 // [修改] 超时设为 1000ms (1秒)
var reply = await ping.SendPingAsync(ip, 800); // 理由:我们要快速探测,不要等待 5秒。
// 即使 Ping 因为网络延迟用了 4秒 才返回Ping 类也会在 1秒 时抛出超时,
// 这会被视为一次“瞬时失败”,然后由外层的 30秒 时间窗口来容错。
var reply = await ping.SendPingAsync(ip, PING_TIMEOUT);
return reply.Status == IPStatus.Success; return reply.Status == IPStatus.Success;
} }
catch catch

View File

@@ -54,10 +54,13 @@ public class FileStorageService : IStorageService
{ {
await _configLock.WaitAsync(); await _configLock.WaitAsync();
try try
{
if (SdkGlobal.SaveCameraConfigEnable)
{ {
var json = JsonSerializer.Serialize(configs, _jsonOptions); var json = JsonSerializer.Serialize(configs, _jsonOptions);
await File.WriteAllTextAsync(_devicesPath, json); await File.WriteAllTextAsync(_devicesPath, json);
} }
}
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"[Storage] ❌ 保存配置失败: {ex.Message}"); Console.WriteLine($"[Storage] ❌ 保存配置失败: {ex.Message}");
@@ -72,6 +75,9 @@ public class FileStorageService : IStorageService
await _configLock.WaitAsync(); await _configLock.WaitAsync();
try try
{ {
if (!SdkGlobal.SaveCameraConfigEnable)
return new List<VideoSourceConfig>();
var json = await File.ReadAllTextAsync(_devicesPath); var json = await File.ReadAllTextAsync(_devicesPath);
if (string.IsNullOrWhiteSpace(json)) return new List<VideoSourceConfig>(); if (string.IsNullOrWhiteSpace(json)) return new List<VideoSourceConfig>();

View File

@@ -386,7 +386,8 @@ public class HikVideoSource : BaseVideoSource,
// ========================================================================= // =========================================================================
// 【修正】删除这里的 GlobalStreamDispatcher.Dispatch // 【修正】删除这里的 GlobalStreamDispatcher.Dispatch
// 严禁在这里分发,因为这时的图是“生的”,还没经过 Pipeline 处理。 // 严禁在这里分发,因为这时的图是“生的”,还没经过 Pipeline 处理。
// =========================================================================GlobalStreamDispatcher.Dispatch(Id, smartFrame); // =========================================================================
//GlobalStreamDispatcher.Dispatch(Id, smartFrame);
// 4. [分发] 将决策结果传递给处理中心 // 4. [分发] 将决策结果传递给处理中心
// decision.TargetAppIds 包含了 "谁需要这一帧" 的信息 // decision.TargetAppIds 包含了 "谁需要这一帧" 的信息

View File

@@ -1,10 +1,11 @@
using MessagePack; using System.Text;
using MessagePack;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using NetMQ; using NetMQ;
using NetMQ.Monitoring; // ★ 1. 必须引用 Monitoring 命名空间
using NetMQ.Sockets; using NetMQ.Sockets;
using SHH.CameraSdk; using SHH.CameraSdk;
using SHH.Contracts; using SHH.Contracts;
using System.Text;
namespace SHH.CameraService; namespace SHH.CameraService;
@@ -16,6 +17,10 @@ public class CommandClientWorker : BackgroundService
// 管理多个 Socket // 管理多个 Socket
private readonly List<DealerSocket> _sockets = new(); private readonly List<DealerSocket> _sockets = new();
// ★ 2. 新增:保存 Monitor 列表,防止被 GC 回收
private readonly List<NetMQMonitor> _monitors = new();
private NetMQPoller? _poller; private NetMQPoller? _poller;
public CommandClientWorker( public CommandClientWorker(
@@ -34,71 +39,53 @@ public class CommandClientWorker : BackgroundService
if (!_config.ShouldConnect || _config.CommandEndpoints.Count == 0) return; if (!_config.ShouldConnect || _config.CommandEndpoints.Count == 0) return;
// 1. 建立连接 (但不立即启动 Poller)
_poller = new NetMQPoller(); _poller = new NetMQPoller();
// -------------------------------------------------------------
// 核心修改区:建立连接并挂载监控器
// -------------------------------------------------------------
foreach (var ep in _config.CommandEndpoints) foreach (var ep in _config.CommandEndpoints)
{ {
try try
{ {
var socket = new DealerSocket(); var socket = new DealerSocket();
// 建议加上 Socket 索引或 UUID 以防服务端认为 Identity 冲突
// 或者保持原样,取决于服务端逻辑。通常同一个 AppId 连不同 Server 是没问题的。
socket.Options.Identity = Encoding.UTF8.GetBytes(_config.AppId); socket.Options.Identity = Encoding.UTF8.GetBytes(_config.AppId);
socket.Connect(ep.Uri);
var monitorUrl = $"inproc://monitor_{Guid.NewGuid():N}";
var monitor = new NetMQMonitor(socket, monitorUrl, SocketEvents.Connected);
monitor.Connected += async (s, args) =>
{
Console.WriteLine($"[指令] 网络连接建立: {ep.Uri} -> 正在补发注册包...");
await SendRegisterAsync(socket);
};
// ★★★ 修正点:使用 AttachToPoller 代替 Add ★★★
// 错误写法: _poller.Add(monitor);
monitor.AttachToPoller(_poller);
// 依然需要保存引用,防止被 GC 回收
_monitors.Add(monitor);
socket.Connect(ep.Uri);
socket.ReceiveReady += OnSocketReceiveReady; socket.ReceiveReady += OnSocketReceiveReady;
_sockets.Add(socket); _sockets.Add(socket);
_poller.Add(socket); _poller.Add(socket);
Console.WriteLine($"[指令] 建立通道: {ep.Uri}"); Console.WriteLine($"[指令] 通道初始化完成: {ep.Uri} (带自动重连监控)");
}
catch (Exception ex)
{
Console.WriteLine($"[指令] 连接初始化异常: {ex.Message}");
} }
catch (Exception ex) { Console.WriteLine($"[指令] 连接异常: {ex.Message}"); }
} }
if (_sockets.Count == 0) return; if (_sockets.Count == 0) return;
// ================================================================= // =================================================================
// 2. 发送注册包 (在 Poller 启动前发送,绝对线程安全) // 6. 绑定 ACK 逻辑 (保持不变)
// ================================================================= // =================================================================
var registerPayload = new RegisterPayload
{
Protocol = ProtocolHeaders.ServerRegister,
InstanceId = _config.AppId,
ProcessId = Environment.ProcessId,
Version = "1.0.0",
ServerIp = "127.0.0.1",
WebApiPort = _config.BasePort,
StartTime = DateTime.Now
};
try
{
byte[] regData = MessagePackSerializer.Serialize(registerPayload);
var ctx = await _pipeline.ExecuteSendAsync(ProtocolHeaders.ServerRegister, regData);
if (ctx != null)
{
foreach (var socket in _sockets)
{
// 此时 Poller 还没跑,主线程发送是安全的
socket.SendMoreFrame(ctx.Protocol).SendFrame(ctx.Data);
}
Console.WriteLine($"[指令] 注册包已广播至 {_sockets.Count} 个目标");
}
}
catch (Exception ex)
{
Console.WriteLine($"[指令] 注册失败: {ex.Message}");
}
// =================================================================
// 3. 绑定 ACK 逻辑
// =================================================================
// 关键修正:直接使用 async void不要包裹在 Task.Run 中!
// 因为 OnResponseReady 是由 Dispatcher 触发的,而 Dispatcher 是由 Poller 线程触发的。
// 所以这里就在 Poller 线程内,可以直接操作 Socket。
_dispatcher.OnResponseReady += async (result) => _dispatcher.OnResponseReady += async (result) =>
{ {
try try
@@ -122,8 +109,11 @@ public class CommandClientWorker : BackgroundService
}; };
// ================================================================= // =================================================================
// 4. 启动 Poller (开始监听接收) // 7. 启动 Poller
// ================================================================= // =================================================================
// 注意:我们不需要手动发第一次注册包了,
// 因为 Poller 启动后,底层 TCP 会建立连接,从而触发 monitor.Connected 事件,
// 事件里会自动发送注册包。这就是“自动档”的好处。
_poller.RunAsync(); _poller.RunAsync();
// 阻塞直到取消 // 阻塞直到取消
@@ -135,12 +125,49 @@ public class CommandClientWorker : BackgroundService
// 清理 // 清理
_poller.Stop(); _poller.Stop();
_poller.Dispose(); _poller.Dispose();
foreach (var m in _monitors) m.Dispose(); // 释放监控器
foreach (var s in _sockets) s.Dispose(); foreach (var s in _sockets) s.Dispose();
} }
// =================================================================
// ★ 8. 抽离出的注册包发送逻辑 (供 Monitor 调用)
// =================================================================
private async Task SendRegisterAsync(DealerSocket targetSocket)
{
try
{
var registerPayload = new RegisterPayload
{
Protocol = ProtocolHeaders.ServerRegister,
InstanceId = _config.AppId,
ProcessId = Environment.ProcessId,
Version = "1.0.0",
ServerIp = "127.0.0.1", // 建议优化获取本机真实IP
WebApiPort = _config.BasePort,
StartTime = DateTime.Now
};
byte[] regData = MessagePackSerializer.Serialize(registerPayload);
// 执行拦截器
var ctx = await _pipeline.ExecuteSendAsync(ProtocolHeaders.ServerRegister, regData);
if (ctx != null)
{
// 直接向触发事件的那个 Socket 发送
// DealerSocket 允许在连接未完全就绪时 Send它会缓存直到网络通畅
targetSocket.SendMoreFrame(ctx.Protocol).SendFrame(ctx.Data);
// Console.WriteLine($"[指令] 身份注册包已推入队列: {targetSocket.Options.Identity}");
}
}
catch (Exception ex)
{
Console.WriteLine($"[指令] 注册包发送失败: {ex.Message}");
}
}
private async void OnSocketReceiveReady(object? sender, NetMQSocketEventArgs e) private async void OnSocketReceiveReady(object? sender, NetMQSocketEventArgs e)
{ {
// 这里的代码运行在 Poller 线程
NetMQMessage incomingMsg = new NetMQMessage(); NetMQMessage incomingMsg = new NetMQMessage();
if (e.Socket.TryReceiveMultipartMessage(ref incomingMsg)) if (e.Socket.TryReceiveMultipartMessage(ref incomingMsg))
{ {
@@ -154,8 +181,6 @@ public class CommandClientWorker : BackgroundService
var ctx = await _pipeline.ExecuteReceiveAsync(rawProtocol, rawData); var ctx = await _pipeline.ExecuteReceiveAsync(rawProtocol, rawData);
if (ctx != null) if (ctx != null)
{ {
// DispatchAsync 会同步触发 OnResponseReady
// 从而在同一个线程内完成 ACK 发送,线程安全且高效。
await _dispatcher.DispatchAsync(ctx.Protocol, ctx.Data); await _dispatcher.DispatchAsync(ctx.Protocol, ctx.Data);
} }
} }

View File

@@ -0,0 +1,84 @@
using Newtonsoft.Json.Linq;
using SHH.CameraSdk;
using SHH.Contracts;
namespace SHH.CameraService
{
/// <summary>
/// 移除设备指令处理器
/// </summary>
public class RemoveCameraHandler : ICommandHandler
{
private readonly CameraManager _cameraManager;
/// <summary>
/// 指令名称
/// </summary>
public string ActionName => ProtocolHeaders.Remove_Camera;
/// <summary>
/// 构造函数
/// </summary>
/// <param name="cameraManager"></param>
public RemoveCameraHandler(CameraManager cameraManager)
{
_cameraManager = cameraManager;
}
/// <summary>
/// 处理指令
/// </summary>
/// <param name="payload"></param>
public async Task ExecuteAsync(JToken payload)
{
long deviceId = 0;
try
{
// 1. 增强型 ID 解析
if (payload.Type == JTokenType.Object)
{
// 兼容大小写不敏感的解析
var idToken = payload["Id"] ?? payload["id"];
if (idToken != null) deviceId = idToken.Value<long>();
}
else if (payload.Type == JTokenType.Integer || payload.Type == JTokenType.String)
{
// 兼容字符串形式的 ID
long.TryParse(payload.ToString(), out deviceId);
}
if (deviceId <= 0)
{
Console.WriteLine($"[{ActionName}] 收到无效指令: ID解析失败 ({payload})");
return;
}
// 2. 预检查
var device = _cameraManager.GetDevice(deviceId);
if (device == null)
{
Console.WriteLine($"[{ActionName}] 设备 {deviceId} 已经不在管理池中,无需操作。");
return;
}
// 3. 安全移除
// 这里建议增加审计日志,记录谁触发了删除(如果协议里有用户信息的话)
device.AddAuditLog("收到远程指令:彻底移除设备");
Console.WriteLine($"[{ActionName}] 正在安全移除设备: {deviceId} ({device.Config.Name})");
// CameraManager 内部会StopAsync -> DisposeAsync -> TryRemove -> SaveChanges
await _cameraManager.RemoveDeviceAsync(deviceId);
Console.WriteLine($"[{ActionName}] 设备 {deviceId} 已彻底清理并从持久化库中移除。");
// 4. (可选) 此处可以调用 CommandDispatcher 发送 Success ACK
}
catch (Exception ex)
{
// 捕获异常,防止影响全局 Socket 轮询
Console.WriteLine($"[{ActionName}] 移除设备 {deviceId} 过程中发生致命错误: {ex.Message}");
}
}
}
}

View File

@@ -1,103 +1,177 @@
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using SHH.CameraSdk; // 引用包含 FrameController 和 FrameRequirement 的命名空间 using SHH.CameraSdk;
using SHH.Contracts; using SHH.Contracts;
namespace SHH.CameraService; namespace SHH.CameraService;
/// <summary>
/// 同步设备配置处理器
/// </summary>
public class SyncCameraHandler : ICommandHandler public class SyncCameraHandler : ICommandHandler
{ {
private readonly CameraManager _cameraManager; private readonly CameraManager _cameraManager;
public string ActionName => ProtocolHeaders.SyncCamera; /// <summary>
/// 命令名称
/// </summary>
public string ActionName => ProtocolHeaders.Sync_Camera;
/// <summary>
/// 构造函数
/// </summary>
/// <param name="cameraManager"></param>
public SyncCameraHandler(CameraManager cameraManager) public SyncCameraHandler(CameraManager cameraManager)
{ {
_cameraManager = cameraManager; _cameraManager = cameraManager;
} }
/// <summary>
/// 执行处理
/// </summary>
/// <param name="payload"></param>
/// <returns></returns>
public async Task ExecuteAsync(JToken payload) public async Task ExecuteAsync(JToken payload)
{ {
// 1. 解析配置 // 1. 反序列化配置 DTO
var dto = payload.ToObject<CameraConfigDto>(); var dto = payload.ToObject<CameraConfigDto>();
if (dto == null) return; if (dto == null) return;
// 2. 添加设备到管理器 (这一步是必须的,不然没有 Device 就没有 Controller) // 2. 尝试获取现有设备
var videoConfig = new VideoSourceConfig var device = _cameraManager.GetDevice(dto.Id);
if (device != null)
{
// =========================================================
// 场景 A: 设备已存在 -> 执行智能更新 (Smart Update)
// =========================================================
Console.WriteLine($"[Sync] 更新设备配置: {dto.Id} ({dto.Name})");
// 将全量配置映射为部分更新 DTO
var updateDto = new DeviceUpdateDto
{
// --- 冷更新参数 (变更会触发重启) ---
IpAddress = dto.IpAddress,
Port = dto.Port,
Username = dto.Username,
Password = dto.Password,
ChannelIndex = dto.ChannelIndex,
Brand = dto.Brand,
RtspPath = dto.RtspPath,
RenderHandle = dto.RenderHandle, // long 类型直接赋值
// --- 热更新参数 (变更立即生效) ---
Name = dto.Name,
Location = dto.Location,
StreamType = dto.StreamType,
MainboardIp = dto.MainboardIp,
MainboardPort = dto.MainboardPort,
// --- 图像处理参数 (热更新) ---
AllowCompress = dto.AllowCompress,
AllowExpand = dto.AllowExpand,
TargetResolution = dto.TargetResolution,
EnhanceImage = dto.EnhanceImage,
UseGrayscale = dto.UseGrayscale
};
// 调用 Manager 的核心更新逻辑 (它会自动判断是 Stop->Start 还是直接应用)
await _cameraManager.UpdateDeviceConfigAsync(dto.Id, updateDto);
}
else
{
// =========================================================
// 场景 B: 设备不存在 -> 执行新增 (Add New)
// =========================================================
Console.WriteLine($"[Sync] 新增设备: {dto.Id} ({dto.Name})");
// 构造全新的设备配置
var newConfig = new VideoSourceConfig
{ {
Id = dto.Id, Id = dto.Id,
Name = dto.Name, Name = dto.Name,
Brand = (DeviceBrand)dto.Brand, // int -> Enum 强转
IpAddress = dto.IpAddress, IpAddress = dto.IpAddress,
Port = dto.Port, Port = dto.Port,
Username = dto.Username, Username = dto.Username,
Password = dto.Password, Password = dto.Password,
ChannelIndex = dto.ChannelIndex, ChannelIndex = dto.ChannelIndex,
StreamType = dto.StreamType, StreamType = dto.StreamType,
Brand = (DeviceBrand)dto.Brand, RtspPath = dto.RtspPath,
RenderHandle = (IntPtr)dto.RenderHandle,
MainboardIp = dto.MainboardIp, MainboardIp = dto.MainboardIp,
MainboardPort = dto.MainboardPort, MainboardPort = dto.MainboardPort,
// 必须给个默认值,防止空引用 RenderHandle = (IntPtr)dto.RenderHandle, // long -> IntPtr 转换
VendorArguments = new Dictionary<string, string>(), ConnectionTimeoutMs = 5000 // 默认超时
}; };
// 如果设备不存在才添加,如果已存在,后续逻辑会直接获取 // 添加到管理器池
if (_cameraManager.GetDevice(videoConfig.Id) == null) _cameraManager.AddDevice(newConfig);
{
_cameraManager.AddDevice(videoConfig); // 重新获取引用以进行后续操作
device = _cameraManager.GetDevice(dto.Id);
} }
// 3. 核心:直接获取设备实例 // ★★★ 核心修复:统一处理“运行意图” ★★★
var device = _cameraManager.GetDevice(dto.Id); if (device != null)
if (device == null)
{ {
Console.WriteLine($"[SyncError] 设备 {dto.Id} 创建失败,无法执行自动订阅。"); // 将 DTO 的立即执行标志直接同步给设备的运行意图
return; device.IsRunning = dto.ImmediateExecution;
if (dto.ImmediateExecution)
{
// 情况 1: 收到“启动”指令
if (!device.IsOnline) // 只有没在线时才点火
{
Console.WriteLine($"[Sync] 指令:立即启动设备 {dto.Id}");
_ = device.StartAsync();
}
}
else
{
// 情况 2: 收到“停止”指令 (即 ImmediateExecution = false)
if (device.IsOnline) // 只有在线时才熄火
{
Console.WriteLine($"[Sync] 指令:立即停止设备 {dto.Id}");
_ = device.StopAsync();
}
}
} }
// 4. 拿到你的“宝贝”控制器 (FrameController) // =========================================================
// 3. 处理自动订阅策略 (Auto Subscriptions)
// =========================================================
// 无论新增还是更新,都确保订阅策略是最新的
if (device != null && dto.AutoSubscriptions != null)
{
var controller = device.Controller; var controller = device.Controller;
if (controller == null) if (controller != null)
{ {
Console.WriteLine($"[SyncError] 设备 {dto.Id} 不支持流控调度 (Controller is null)。"); foreach (var sub in dto.AutoSubscriptions)
return;
}
// 5. 暴力注册订阅需求 (Loop AutoSubscriptions)
if (dto.AutoSubscriptions != null && dto.AutoSubscriptions.Count > 0)
{ {
foreach (var subItem in dto.AutoSubscriptions) // 如果没有 AppId生成一个临时的通常 Dashboard 会下发固定的 AppId
string appId = string.IsNullOrWhiteSpace(sub.AppId)
? $"AUTO_{Guid.NewGuid().ToString("N")[..8]}"
: sub.AppId;
// 构造流控需求
var req = new FrameRequirement
{ {
// 生成 AppId (照抄你给的逻辑) AppId = appId,
string finalAppId = string.IsNullOrWhiteSpace(subItem.AppId) TargetFps = sub.TargetFps,
? $"SUB_{Guid.NewGuid().ToString("N").Substring(0, 8).ToUpper()}" Type = (SubscriptionType)sub.Type, // int -> Enum
: subItem.AppId; Memo = sub.Memo ?? "Sync Auto",
Console.WriteLine($"[自动化] 正在注册流控: {finalAppId}, 目标: {subItem.TargetFps} FPS"); // 自动订阅通常不包含具体的 Handle 或 SavePath除非协议里带了
// 如果需要支持网络转发,这里可以扩展映射 sub.TargetIp 等
// 构造 FrameRequirement 对象 (完全匹配你 FrameController 的入参)
// 这里的属性赋值对应你代码里 req.Type, req.SavePath 等逻辑
var requirement = new FrameRequirement
{
AppId = finalAppId,
TargetFps = subItem.TargetFps, // 8帧 或 1帧
Type = (SubscriptionType)subItem.Type, // 业务类型 (LocalWindow, NetworkTrans...)
Memo = subItem.Memo ?? "Auto Sync",
// 其它字段给默认空值,防止 Controller 内部逻辑报错
Handle = "", Handle = "",
SavePath = "" SavePath = ""
}; };
// ★★★ 见证奇迹的时刻:直接调用 Register ★★★ // 注册到帧控制器
controller.Register(requirement); controller.Register(req);
}
} }
} }
//// 6. 启动设备
//// 你的积分算法会在 device 内部的推流循环中被 MakeDecision 调用
if (dto.ImmediateExecution)
await device.StartAsync();
Console.WriteLine($"[SyncSuccess] 设备 {dto.Id} 同步完成,策略已下发。");
} }
} }

View File

@@ -53,7 +53,7 @@ namespace SHH.CameraService
/// </summary> /// </summary>
public static VideoPayload ToVideoPayload(this NetMQMessage msg) public static VideoPayload ToVideoPayload(this NetMQMessage msg)
{ {
if (msg == null || msg.FrameCount < 4) return null; if (msg == null || msg.FrameCount < 2) return null;
// Frame 0 Check // Frame 0 Check
if (msg[0].ConvertToString() != PROTOCOL_HEADER) return null; if (msg[0].ConvertToString() != PROTOCOL_HEADER) return null;

View File

@@ -19,43 +19,65 @@ public class NetMqSenderWorker : BackgroundService
} }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
Console.WriteLine($"[NetMqSender] 正在连接至服务端: {_target.Config.Endpoint} ..."); // 增加重启保护
while (!stoppingToken.IsCancellationRequested)
{
try
{
Console.WriteLine($"[NetMqSender] 连接至: {_target.Config.Endpoint}");
// ★★★ 修正点:必须使用 PublisherSocket 来配合接收端的 SubscriberSocket ★★★
// 虽然是 Connect 模式Publisher 依然可以 Connect
using var clientSocket = new PublisherSocket(); using var clientSocket = new PublisherSocket();
// 设置高水位 (HWM)
// 对于 Publisher如果队列满了默认行为就是丢弃旧数据这非常符合视频流需求
clientSocket.Options.SendHighWatermark = 1000; clientSocket.Options.SendHighWatermark = 1000;
// 关键:增加 TCP 保活,防止防火墙静默断开长连接
clientSocket.Options.TcpKeepalive = true;
clientSocket.Options.TcpKeepaliveIdle = TimeSpan.FromSeconds(5);
// 主动连接
clientSocket.Connect(_target.Config.Endpoint); clientSocket.Connect(_target.Config.Endpoint);
Console.WriteLine("[NetMqSender] 连接成功,开始从通道搬运数据..."); int frameCount = 0;
// 使用更稳健的读取方式
await foreach (var payload in _target.Channel.Reader.ReadAllAsync(stoppingToken)) await foreach (var payload in _target.Channel.Reader.ReadAllAsync(stoppingToken))
{ {
try try
{ {
// 1. 构造消息 (内部执行了 MessagePack 序列化)
var msg = payload.ToNetMqMessage(); var msg = payload.ToNetMqMessage();
// 发送消息 // 2. 发送
// PublisherSocket 的 TrySend 如果没人订阅或者队列满了,通常不会阻塞,而是直接丢弃或返回
// 注意PUB 模式下,第一帧 ("SHH_V1") 会被当作订阅的主题 (Topic)。
// 你的接收端订阅了 "" (空字符串),所以能收到以任何字符串开头的数据。
bool sent = clientSocket.TrySendMultipartMessage(msg); bool sent = clientSocket.TrySendMultipartMessage(msg);
if (!sent) if (!sent)
{ {
// 这种情况通常意味着网络断了且 HWM 队列也满了 Console.WriteLine($"[NetMqSender] 发送缓冲区满,丢弃帧: {payload.CameraId}");
Console.WriteLine($"[NetMqSender] 警告: 发送队列已满,正在丢帧..."); // ★ 如果没有发送成功,建议显式清理消息帧,防止内存滞留
msg.Clear(); // 手动清理(可选) msg.Clear();
}
else
{
frameCount++;
if (frameCount % 100 == 0)
Console.WriteLine($"[NetMqSender] 已搬运 100 帧至缓冲区.");
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"[NetMqSender] 异常: {ex.Message}"); Console.WriteLine($"[NetMqSender] 内部循环异常: {ex.Message}");
}
}
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
// ★★★ 核心改进:捕获异常并等待重试 ★★★
// 防止因为一次内存溢出或网络波动导致整个 BackgroundService 永久停止
Console.WriteLine($"[NetMqSender] 发生致命异常5秒后尝试重建连接: {ex.Message}");
await Task.Delay(5000, stoppingToken);
}
finally
{
// 确保每次循环退出(无论是异常还是正常)都清理环境
NetMQConfig.Cleanup(false);
} }
} }
} }

View File

@@ -106,8 +106,12 @@ public class NetworkStreamingWorker : BackgroundService
// 实现了"物理隔离":一个管道满了(云端卡顿),不影响另一个管道(大屏流畅)。 // 实现了"物理隔离":一个管道满了(云端卡顿),不影响另一个管道(大屏流畅)。
foreach (var target in _targets) foreach (var target in _targets)
{ {
// WriteLog 是非阻塞的。满了就丢弃,返回 false。 bool ok = target.Channel.WriteLog(payload);
target.Channel.WriteLog(payload); if (!ok)
{
// 如果这里打印,说明管道由于某种原因被关闭了(通常是程序正在退出)
Console.WriteLine($"[DEBUG] 管道写入失败,目标: {target.Config.Name}");
}
} }
} }
catch (Exception ex) catch (Exception ex)

View File

@@ -26,10 +26,11 @@ namespace SHH.CameraService
/// <summary> /// <summary>
/// [生产者] 写入一个封装好的数据包 (非阻塞) /// [生产者] 写入一个封装好的数据包 (非阻塞)
/// </summary> /// </summary>
public void WriteLog(VideoPayload payload) public bool WriteLog(VideoPayload payload) // 改为返回 bool
{ {
// TryWrite 永远不会等待,满了就丢旧的写入新的,返回 true // TryWrite 在 DropOldest 模式下虽然几乎总是返回 true
_channel.Writer.TryWrite(payload); // 但如果 Channel 被 Complete (关闭) 了,它会返回 false。
return _channel.Writer.TryWrite(payload);
} }
/// <summary> /// <summary>

View File

@@ -92,6 +92,7 @@ public class Program
// 2. 注册具体的指令处理器 (每写一个新的 Handler就在这里注册一下或者用反射批量注册) // 2. 注册具体的指令处理器 (每写一个新的 Handler就在这里注册一下或者用反射批量注册)
builder.Services.AddSingleton<ICommandHandler, SyncCameraHandler>(); builder.Services.AddSingleton<ICommandHandler, SyncCameraHandler>();
builder.Services.AddSingleton<ICommandHandler, RemoveCameraHandler>();
// ============================================================= // =============================================================
// 6. 构建与管道配置 // 6. 构建与管道配置

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic; using Newtonsoft.Json;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace SHH.Contracts namespace SHH.Contracts
@@ -137,6 +138,7 @@ namespace SHH.Contracts
/// <summary> /// <summary>
/// 是否立即执行 /// 是否立即执行
/// </summary> /// </summary>
[JsonProperty("ImmediateExecution")] // 确保 JSON 里的这个 key 能精准对应到这个属性
public bool ImmediateExecution { get; set; } public bool ImmediateExecution { get; set; }
} }

View File

@@ -8,6 +8,8 @@
public const string Command = "COMMAND"; public const string Command = "COMMAND";
public const string CommandResult = "COMMAND_RESULT"; public const string CommandResult = "COMMAND_RESULT";
public const string SyncCamera = "Sync_Camera"; public const string Sync_Camera = "Sync_Camera";
public const string Remove_Camera = "Remove_Camera";
} }
} }