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

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

View File

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

View File

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

View File

@@ -281,6 +281,8 @@ public class CameraManager : IDisposable, IAsyncDisposable
newConfig.Username != oldConfig.Username ||
newConfig.Password != oldConfig.Password ||
newConfig.ChannelIndex != oldConfig.ChannelIndex ||
newConfig.RtspPath != oldConfig.RtspPath ||
newConfig.RenderHandle != oldConfig.RenderHandle ||
newConfig.Brand != oldConfig.Brand;
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;
namespace SHH.CameraSdk;
@@ -9,23 +11,32 @@ namespace SHH.CameraSdk;
/// 1. 低耦合:不依赖具体驱动,只依赖接口
/// 2. 高性能:使用 Parallel.ForEachAsync 实现受控并行
/// 3. 智能策略播放中不Ping空闲时才Ping
/// 4. 稳定性:基于“持续断联时间”判定离线,防止网络瞬抖
/// </summary>
public class ConnectivitySentinel
{
private readonly CameraManager _manager; // [cite: 329]
private readonly CameraManager _manager; //
private readonly PeriodicTimer _timer;
private readonly CancellationTokenSource _cts = new();
// [关键] 状态缓存:用于“去重”
// 只有当状态真的从 true 变 false (或反之) 时,才通知 Manager。
// 防止每 3 秒发一次 "在线" 骚扰上层。
// [关键] 状态缓存:用于“去重”上报
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;
// [配置] 判定离线的持续时间阈值 (秒)
// 只有连续 Ping 不通超过 30秒才认定为断线
private const int OFFLINE_DURATION_THRESHOLD = 30;
// [配置] 单次 Ping 的超时时间 (毫秒)
// 设为 1000ms保证一轮检查快速结束不依赖 Ping 的默认 5秒 超时
private const int PING_TIMEOUT = 1000;
public ConnectivitySentinel(CameraManager manager)
{
_manager = manager;
@@ -44,11 +55,9 @@ public class ConnectivitySentinel
while (await _timer.WaitForNextTickAsync(_cts.Token))
{
// 1. 获取当前所有设备的快照
// CameraManager.GetAllDevices() 返回的是 BaseVideoSource它实现了 IDeviceConnectivity
var devices = _manager.GetAllDevices().Cast<IDeviceConnectivity>();
// 2. [核心回答] 受控并行执行
// .NET 6+ 提供的超级 API专门解决“一下子 50 个”的问题
await Parallel.ForEachAsync(devices, new ParallelOptions
{
MaxDegreeOfParallelism = MAX_PARALLELISM,
@@ -66,37 +75,84 @@ public class ConnectivitySentinel
private async Task CheckSingleDeviceAsync(IDeviceConnectivity device)
{
bool isAlive = false;
// 1. 获取“瞬时”连通性 (Raw Status)
bool isResponsive = false;
// [智能策略]:如果设备正在取流,直接检查帧心跳(省流模式)
// [智能策略]:如果设备正在取流,优先检查帧心跳
if (device.Status == VideoSourceStatus.Playing || device.Status == VideoSourceStatus.Streaming)
{
long now = Environment.TickCount64;
// 5秒内有帧就算在线
isAlive = (now - device.LastFrameTick) < 5000;
// 5秒内有帧就算瞬时在线
isResponsive = (now - device.LastFrameTick) < 5000;
// [双重保障] 如果帧心跳断了,立即 Ping 确认,防止只是解码卡死而非断网
if (!isResponsive)
{
isResponsive = await PingAsync(device.IpAddress);
}
}
else
{
// [主动探测]:空闲或离线时,发射 ICMP Ping
isAlive = await PingAsync(device.IpAddress);
isResponsive = await PingAsync(device.IpAddress);
}
// [状态注入]:将探测结果“注入”回设备
device.SetNetworkStatus(isAlive);
// 2. [核心逻辑] 基于持续时间的稳定性判定 (Stable Status)
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. [状态去重与上报]
// 获取上一次的状态,如果没记录过,假设它之前是反状态(强制第一次上报)
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
{
using var ping = new Ping();
// 超时设为 800ms,快速失败,避免拖慢整体批次
var reply = await ping.SendPingAsync(ip, 800);
// [修改] 超时设为 1000ms (1秒)
// 理由:我们要快速探测,不要等待 5秒。
// 即使 Ping 因为网络延迟用了 4秒 才返回Ping 类也会在 1秒 时抛出超时,
// 这会被视为一次“瞬时失败”,然后由外层的 30秒 时间窗口来容错。
var reply = await ping.SendPingAsync(ip, PING_TIMEOUT);
return reply.Status == IPStatus.Success;
}
catch

View File

@@ -55,8 +55,11 @@ public class FileStorageService : IStorageService
await _configLock.WaitAsync();
try
{
var json = JsonSerializer.Serialize(configs, _jsonOptions);
await File.WriteAllTextAsync(_devicesPath, json);
if (SdkGlobal.SaveCameraConfigEnable)
{
var json = JsonSerializer.Serialize(configs, _jsonOptions);
await File.WriteAllTextAsync(_devicesPath, json);
}
}
catch (Exception ex)
{
@@ -72,6 +75,9 @@ public class FileStorageService : IStorageService
await _configLock.WaitAsync();
try
{
if (!SdkGlobal.SaveCameraConfigEnable)
return new List<VideoSourceConfig>();
var json = await File.ReadAllTextAsync(_devicesPath);
if (string.IsNullOrWhiteSpace(json)) return new List<VideoSourceConfig>();

View File

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