NetMQ 协议,支持摄像头增、删、改
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -147,10 +147,37 @@ namespace SHH.CameraDashboard
|
|||||||
|
|
||||||
public void Stop()
|
public void Stop()
|
||||||
{
|
{
|
||||||
_isRunning = false;
|
// 增加锁,防止重复释放
|
||||||
_poller?.Stop();
|
lock (_disposeLock)
|
||||||
_poller?.Dispose();
|
{
|
||||||
_routerSocket?.Dispose();
|
if (!_isRunning) return;
|
||||||
|
_isRunning = false;
|
||||||
|
|
||||||
|
Console.WriteLine("[Bus] 正在释放 NetMQ 资源...");
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ public class VideoSourceConfig
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
|
||||||
#region --- 2. 厂商扩展配置 (Vendor-Specific Extensions) ---
|
#region --- 2. 厂商扩展配置 (Vendor-Specific Extensions) ---
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -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. 图像处理参数
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
13
SHH.CameraSdk/Core/SdkGlobal.cs
Normal file
13
SHH.CameraSdk/Core/SdkGlobal.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace SHH.CameraSdk
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// SDk 全局
|
||||||
|
/// </summary>
|
||||||
|
public class SdkGlobal
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否保存摄像头配置
|
||||||
|
/// </summary>
|
||||||
|
public static bool SaveCameraConfigEnable { get; set; } = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -55,8 +55,11 @@ public class FileStorageService : IStorageService
|
|||||||
await _configLock.WaitAsync();
|
await _configLock.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var json = JsonSerializer.Serialize(configs, _jsonOptions);
|
if (SdkGlobal.SaveCameraConfigEnable)
|
||||||
await File.WriteAllTextAsync(_devicesPath, json);
|
{
|
||||||
|
var json = JsonSerializer.Serialize(configs, _jsonOptions);
|
||||||
|
await File.WriteAllTextAsync(_devicesPath, json);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -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>();
|
||||||
|
|
||||||
|
|||||||
@@ -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 包含了 "谁需要这一帧" 的信息
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
84
SHH.CameraService/Core/CmdClients/RemoveCameraHandler.cs
Normal file
84
SHH.CameraService/Core/CmdClients/RemoveCameraHandler.cs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
{
|
|
||||||
Id = dto.Id,
|
|
||||||
Name = dto.Name,
|
|
||||||
IpAddress = dto.IpAddress,
|
|
||||||
Port = dto.Port,
|
|
||||||
Username = dto.Username,
|
|
||||||
Password = dto.Password,
|
|
||||||
ChannelIndex = dto.ChannelIndex,
|
|
||||||
StreamType = dto.StreamType,
|
|
||||||
Brand = (DeviceBrand)dto.Brand,
|
|
||||||
RenderHandle = (IntPtr)dto.RenderHandle,
|
|
||||||
MainboardIp = dto.MainboardIp,
|
|
||||||
MainboardPort = dto.MainboardPort,
|
|
||||||
// 必须给个默认值,防止空引用
|
|
||||||
VendorArguments = new Dictionary<string, string>(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 如果设备不存在才添加,如果已存在,后续逻辑会直接获取
|
|
||||||
if (_cameraManager.GetDevice(videoConfig.Id) == null)
|
|
||||||
{
|
|
||||||
_cameraManager.AddDevice(videoConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 核心:直接获取设备实例
|
|
||||||
var device = _cameraManager.GetDevice(dto.Id);
|
var device = _cameraManager.GetDevice(dto.Id);
|
||||||
if (device == null)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[SyncError] 设备 {dto.Id} 创建失败,无法执行自动订阅。");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 拿到你的“宝贝”控制器 (FrameController)
|
if (device != null)
|
||||||
var controller = device.Controller;
|
|
||||||
if (controller == null)
|
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[SyncError] 设备 {dto.Id} 不支持流控调度 (Controller is null)。");
|
// =========================================================
|
||||||
return;
|
// 场景 A: 设备已存在 -> 执行智能更新 (Smart Update)
|
||||||
}
|
// =========================================================
|
||||||
|
Console.WriteLine($"[Sync] 更新设备配置: {dto.Id} ({dto.Name})");
|
||||||
|
|
||||||
// 5. 暴力注册订阅需求 (Loop AutoSubscriptions)
|
// 将全量配置映射为部分更新 DTO
|
||||||
if (dto.AutoSubscriptions != null && dto.AutoSubscriptions.Count > 0)
|
var updateDto = new DeviceUpdateDto
|
||||||
{
|
|
||||||
foreach (var subItem in dto.AutoSubscriptions)
|
|
||||||
{
|
{
|
||||||
// 生成 AppId (照抄你给的逻辑)
|
// --- 冷更新参数 (变更会触发重启) ---
|
||||||
string finalAppId = string.IsNullOrWhiteSpace(subItem.AppId)
|
IpAddress = dto.IpAddress,
|
||||||
? $"SUB_{Guid.NewGuid().ToString("N").Substring(0, 8).ToUpper()}"
|
Port = dto.Port,
|
||||||
: subItem.AppId;
|
Username = dto.Username,
|
||||||
|
Password = dto.Password,
|
||||||
|
ChannelIndex = dto.ChannelIndex,
|
||||||
|
Brand = dto.Brand,
|
||||||
|
RtspPath = dto.RtspPath,
|
||||||
|
RenderHandle = dto.RenderHandle, // long 类型直接赋值
|
||||||
|
|
||||||
Console.WriteLine($"[自动化] 正在注册流控: {finalAppId}, 目标: {subItem.TargetFps} FPS");
|
// --- 热更新参数 (变更立即生效) ---
|
||||||
|
Name = dto.Name,
|
||||||
|
Location = dto.Location,
|
||||||
|
StreamType = dto.StreamType,
|
||||||
|
|
||||||
// 构造 FrameRequirement 对象 (完全匹配你 FrameController 的入参)
|
MainboardIp = dto.MainboardIp,
|
||||||
// 这里的属性赋值对应你代码里 req.Type, req.SavePath 等逻辑
|
MainboardPort = dto.MainboardPort,
|
||||||
var requirement = new FrameRequirement
|
|
||||||
|
// --- 图像处理参数 (热更新) ---
|
||||||
|
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,
|
||||||
|
Name = dto.Name,
|
||||||
|
Brand = (DeviceBrand)dto.Brand, // int -> Enum 强转
|
||||||
|
IpAddress = dto.IpAddress,
|
||||||
|
Port = dto.Port,
|
||||||
|
Username = dto.Username,
|
||||||
|
Password = dto.Password,
|
||||||
|
ChannelIndex = dto.ChannelIndex,
|
||||||
|
StreamType = dto.StreamType,
|
||||||
|
RtspPath = dto.RtspPath,
|
||||||
|
MainboardIp = dto.MainboardIp,
|
||||||
|
MainboardPort = dto.MainboardPort,
|
||||||
|
RenderHandle = (IntPtr)dto.RenderHandle, // long -> IntPtr 转换
|
||||||
|
ConnectionTimeoutMs = 5000 // 默认超时
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加到管理器池
|
||||||
|
_cameraManager.AddDevice(newConfig);
|
||||||
|
|
||||||
|
// 重新获取引用以进行后续操作
|
||||||
|
device = _cameraManager.GetDevice(dto.Id);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★★★ 核心修复:统一处理“运行意图” ★★★
|
||||||
|
if (device != null)
|
||||||
|
{
|
||||||
|
// 将 DTO 的立即执行标志直接同步给设备的运行意图
|
||||||
|
device.IsRunning = dto.ImmediateExecution;
|
||||||
|
|
||||||
|
if (dto.ImmediateExecution)
|
||||||
|
{
|
||||||
|
// 情况 1: 收到“启动”指令
|
||||||
|
if (!device.IsOnline) // 只有没在线时才点火
|
||||||
{
|
{
|
||||||
AppId = finalAppId,
|
Console.WriteLine($"[Sync] 指令:立即启动设备 {dto.Id}");
|
||||||
TargetFps = subItem.TargetFps, // 8帧 或 1帧
|
_ = device.StartAsync();
|
||||||
Type = (SubscriptionType)subItem.Type, // 业务类型 (LocalWindow, NetworkTrans...)
|
}
|
||||||
Memo = subItem.Memo ?? "Auto Sync",
|
}
|
||||||
|
else
|
||||||
// 其它字段给默认空值,防止 Controller 内部逻辑报错
|
{
|
||||||
Handle = "",
|
// 情况 2: 收到“停止”指令 (即 ImmediateExecution = false)
|
||||||
SavePath = ""
|
if (device.IsOnline) // 只有在线时才熄火
|
||||||
};
|
{
|
||||||
|
Console.WriteLine($"[Sync] 指令:立即停止设备 {dto.Id}");
|
||||||
// ★★★ 见证奇迹的时刻:直接调用 Register ★★★
|
_ = device.StopAsync();
|
||||||
controller.Register(requirement);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//// 6. 启动设备
|
// =========================================================
|
||||||
//// 你的积分算法会在 device 内部的推流循环中被 MakeDecision 调用
|
// 3. 处理自动订阅策略 (Auto Subscriptions)
|
||||||
if (dto.ImmediateExecution)
|
// =========================================================
|
||||||
await device.StartAsync();
|
// 无论新增还是更新,都确保订阅策略是最新的
|
||||||
|
if (device != null && dto.AutoSubscriptions != null)
|
||||||
|
{
|
||||||
|
var controller = device.Controller;
|
||||||
|
if (controller != null)
|
||||||
|
{
|
||||||
|
foreach (var sub in dto.AutoSubscriptions)
|
||||||
|
{
|
||||||
|
// 如果没有 AppId,生成一个临时的(通常 Dashboard 会下发固定的 AppId)
|
||||||
|
string appId = string.IsNullOrWhiteSpace(sub.AppId)
|
||||||
|
? $"AUTO_{Guid.NewGuid().ToString("N")[..8]}"
|
||||||
|
: sub.AppId;
|
||||||
|
|
||||||
Console.WriteLine($"[SyncSuccess] 设备 {dto.Id} 同步完成,策略已下发。");
|
// 构造流控需求
|
||||||
|
var req = new FrameRequirement
|
||||||
|
{
|
||||||
|
AppId = appId,
|
||||||
|
TargetFps = sub.TargetFps,
|
||||||
|
Type = (SubscriptionType)sub.Type, // int -> Enum
|
||||||
|
Memo = sub.Memo ?? "Sync Auto",
|
||||||
|
|
||||||
|
// 自动订阅通常不包含具体的 Handle 或 SavePath,除非协议里带了
|
||||||
|
// 如果需要支持网络转发,这里可以扩展映射 sub.TargetIp 等
|
||||||
|
Handle = "",
|
||||||
|
SavePath = ""
|
||||||
|
};
|
||||||
|
|
||||||
|
// 注册到帧控制器
|
||||||
|
controller.Register(req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
// ★★★ 修正点:必须使用 PublisherSocket 来配合接收端的 SubscriberSocket ★★★
|
|
||||||
// 虽然是 Connect 模式,Publisher 依然可以 Connect
|
|
||||||
using var clientSocket = new PublisherSocket();
|
|
||||||
|
|
||||||
// 设置高水位 (HWM)
|
|
||||||
// 对于 Publisher,如果队列满了,默认行为就是丢弃旧数据,这非常符合视频流需求
|
|
||||||
clientSocket.Options.SendHighWatermark = 1000;
|
|
||||||
|
|
||||||
// 主动连接
|
|
||||||
clientSocket.Connect(_target.Config.Endpoint);
|
|
||||||
|
|
||||||
Console.WriteLine("[NetMqSender] 连接成功,开始从通道搬运数据...");
|
|
||||||
|
|
||||||
await foreach (var payload in _target.Channel.Reader.ReadAllAsync(stoppingToken))
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var msg = payload.ToNetMqMessage();
|
Console.WriteLine($"[NetMqSender] 连接至: {_target.Config.Endpoint}");
|
||||||
|
|
||||||
// 发送消息
|
using var clientSocket = new PublisherSocket();
|
||||||
// PublisherSocket 的 TrySend 如果没人订阅或者队列满了,通常不会阻塞,而是直接丢弃或返回
|
clientSocket.Options.SendHighWatermark = 1000;
|
||||||
// 注意:PUB 模式下,第一帧 ("SHH_V1") 会被当作订阅的主题 (Topic)。
|
// 关键:增加 TCP 保活,防止防火墙静默断开长连接
|
||||||
// 你的接收端订阅了 "" (空字符串),所以能收到以任何字符串开头的数据。
|
clientSocket.Options.TcpKeepalive = true;
|
||||||
bool sent = clientSocket.TrySendMultipartMessage(msg);
|
clientSocket.Options.TcpKeepaliveIdle = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
if (!sent)
|
clientSocket.Connect(_target.Config.Endpoint);
|
||||||
|
|
||||||
|
int frameCount = 0;
|
||||||
|
|
||||||
|
// 使用更稳健的读取方式
|
||||||
|
await foreach (var payload in _target.Channel.Reader.ReadAllAsync(stoppingToken))
|
||||||
{
|
{
|
||||||
// 这种情况通常意味着网络断了且 HWM 队列也满了
|
try
|
||||||
Console.WriteLine($"[NetMqSender] 警告: 发送队列已满,正在丢帧...");
|
{
|
||||||
msg.Clear(); // 手动清理(可选)
|
// 1. 构造消息 (内部执行了 MessagePack 序列化)
|
||||||
|
var msg = payload.ToNetMqMessage();
|
||||||
|
|
||||||
|
// 2. 发送
|
||||||
|
bool sent = clientSocket.TrySendMultipartMessage(msg);
|
||||||
|
|
||||||
|
if (!sent)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[NetMqSender] 发送缓冲区满,丢弃帧: {payload.CameraId}");
|
||||||
|
// ★ 如果没有发送成功,建议显式清理消息帧,防止内存滞留
|
||||||
|
msg.Clear();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
frameCount++;
|
||||||
|
if (frameCount % 100 == 0)
|
||||||
|
Console.WriteLine($"[NetMqSender] 已搬运 100 帧至缓冲区.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[NetMqSender] 内部循环异常: {ex.Message}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[NetMqSender] 异常: {ex.Message}");
|
// ★★★ 核心改进:捕获异常并等待重试 ★★★
|
||||||
|
// 防止因为一次内存溢出或网络波动导致整个 BackgroundService 永久停止
|
||||||
|
Console.WriteLine($"[NetMqSender] 发生致命异常,5秒后尝试重建连接: {ex.Message}");
|
||||||
|
await Task.Delay(5000, stoppingToken);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// 确保每次循环退出(无论是异常还是正常)都清理环境
|
||||||
|
NetMQConfig.Cleanup(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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. 构建与管道配置
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user