Files
Ayay/SHH.CameraSdk/Core/Manager/CameraManager.cs
2026-02-08 16:19:48 +08:00

425 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Ayay.SerilogLogs;
using Serilog;
namespace SHH.CameraSdk;
/// <summary>
/// [管理层] 视频源总控管理器 (V3.5 持久化集成版)
/// 核心职责:统一管理所有相机设备的生命周期、状态监控与资源清理,对接协调器实现自动自愈
/// </summary>
public class CameraManager : IDisposable, IAsyncDisposable
{
#region --- 1. (Fields & States) ---
private ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core);
/// <summary> 全局设备实例池线程安全Key = 设备唯一标识 </summary>
private readonly ConcurrentDictionary<long, BaseVideoSource> _cameraPool = new();
/// <summary> 后台协调器实例:负责心跳检测、断线重连、僵尸流恢复 </summary>
private readonly CameraCoordinator _coordinator = new();
/// <summary> 全局取消令牌源:用于销毁时瞬间关停所有异步扫描任务 </summary>
private readonly CancellationTokenSource _globalCts = new();
/// <summary> 销毁状态标记:防止重复销毁或销毁过程中执行操作 </summary>
private volatile bool _isDisposed;
/// <summary>
/// 协调器引擎运行状态标记
/// 使用 volatile 关键字确保多线程环境下的内存可见性
/// </summary>
private volatile bool _isEngineStarted = false;
#endregion
#region --- (Constructor) ---
// [修改] 注入 IStorageService
public CameraManager()
{
}
#endregion
#region --- 2. (Device Management) ---
/// <summary>
/// 向管理池添加新相机设备
/// </summary>
/// <param name="config">相机设备配置信息</param>
public void AddDevice(VideoSourceConfig config)
{
// [安全防护] 销毁过程中禁止添加新设备
if (_isDisposed) return;
// 使用工厂方法创建
var device = CreateDeviceInstance(config);
if (!_cameraPool.TryAdd(config.Id, device))
{
// 如果添加失败ID冲突由于 device 还没被使用,直接释放掉
device.DisposeAsync().AsTask().Wait();
_sysLog.Warning($"[Core] 设备 ID:{config.Id} 已存在");
_sysLog.Debug($"[Core] 设备 ID:{config.Id} => 明细:" + "{@cfg}.", config);
throw new InvalidOperationException($"设备 ID {config.Id} 已存在");
}
_coordinator.Register(device);
// 动态激活逻辑:引擎已启动时,新设备直接标记为运行状态
if (_isEngineStarted)
device.IsRunning = true;
}
/// <summary>
/// 根据设备ID获取指定的视频源实例
/// </summary>
public BaseVideoSource? GetDevice(long id)
=> _cameraPool.TryGetValue(id, out var source) ? source : null;
/// <summary>
/// 获取当前管理的所有相机设备
/// </summary>
public IEnumerable<BaseVideoSource> GetAllDevices()
{
return _cameraPool.Values.ToList();
}
/// <summary>
/// [修改] 异步移除设备 (从 RemoveDevice 改为 RemoveDeviceAsync)
/// </summary>
public async Task RemoveDeviceAsync(long id)
{
if (_cameraPool.TryRemove(id, out var device))
{
// 记录日志
_sysLog.Information("[Core] 正在移除设备, ID {0} ", id);
// 1. 停止物理连接
await device.StopAsync();
// 2. 释放资源
await device.DisposeAsync();
_sysLog.Warning("[Core] 设备已彻底移除, ID {0} ", id);
}
}
// 为了兼容旧代码保留同步方法,但不推荐使用
public void RemoveDevice(long id) => RemoveDeviceAsync(id).Wait();
#endregion
#region --- 3. (Engine Lifecycle) ---
/// <summary>
/// 启动视频管理引擎加载配置初始化SDK并启动协调器自愈循环
/// </summary>
public async Task StartAsync()
{
if (_isDisposed) throw new ObjectDisposedException(nameof(CameraManager));
if (_isEngineStarted) return;
// =========================================================
// 1. 全局驱动环境预初始化
// =========================================================
HikSdkManager.Initialize();
DahuaSdkManager.Initialize();
// 标记引擎启动状态
_isEngineStarted = true;
// =========================================================
// 2. 启动协调器后台自愈循环
// =========================================================
_ = Task.Factory.StartNew(
() => _coordinator.RunCoordinationLoopAsync(_globalCts.Token),
_globalCts.Token,
TaskCreationOptions.LongRunning,
TaskScheduler.Default);
// 这里传递 _cameraPool 给协调器(如果协调器是独立引用的,可能需要 Register 逻辑,
// 但根据您的代码,协调器似乎是依赖外部注册或共享引用的。
// *注意*:如果 Coordinator 需要显式注册,请在这里补上:
foreach (var dev in _cameraPool.Values) _coordinator.Register(dev);
_sysLog.Warning($"[Core] 设备管理引擎启动成功, 当前管理 {_cameraPool.Count} 路设备");
await Task.CompletedTask;
}
/// <summary>
/// 获取当前所有相机的全局状态简报
/// </summary>
public IEnumerable<(long Id, string Ip, VideoSourceStatus Status)> GetGlobalStatus()
{
return _cameraPool.Values.Select(v => (v.Id, v.Config.IpAddress, v.Status));
}
#endregion
#region --- 4. (Telemetry Collection) ---
/// <summary>
/// 获取全量相机实时遥测数据快照 (MonitorController 使用)
/// </summary>
public IEnumerable<CameraTelemetryInfo> GetTelemetrySnapshot()
{
return _cameraPool.Values.Select(cam =>
{
// 健康度评分算法
int healthScore = 100;
if (cam.Status == VideoSourceStatus.Faulted)
healthScore = 0;
else if (cam.Status == VideoSourceStatus.Reconnecting)
healthScore = 60;
else if (cam.RealFps < 1.0 && cam.Status == VideoSourceStatus.Playing)
healthScore = 40;
return new CameraTelemetryInfo
{
DeviceId = cam.Id,
Name = cam.Config.Name,
IpAddress = cam.Config.IpAddress,
Status = cam.Status.ToString(),
IsOnline = cam.IsPhysicalOnline,
Fps = cam.RealFps,
Bitrate = cam.RealBitrate,
TotalFrames = cam.TotalFrames,
HealthScore = healthScore,
LastErrorMessage = cam.Status == VideoSourceStatus.Faulted ? "设备故障或网络中断" : null,
Timestamp = DateTime.Now,
Width = cam.Width,
Height = cam.Height,
};
}).ToList();
}
#endregion
#region --- 5. (Config Hot Update) ---
/// <summary>
/// 智能更新设备配置 (含冷热分离逻辑)
/// </summary>
public async Task UpdateDeviceConfigAsync(long deviceId, DeviceUpdateDto dto)
{
if (!_cameraPool.TryGetValue(deviceId, out var device))
{
_sysLog.Warning($"[Core] 设备更新制作, ID:{deviceId} 不存在.");
throw new KeyNotFoundException($"设备 ID:{deviceId} 不存在.");
}
// 1. 审计
_sysLog.Debug($"[Core] 响应设备配置更新请求, ID:{deviceId}.");
// ============================================================
// 【核心修复:手动解除冷冻】
// [原因] 用户已干预配置,无论之前是否认证失败,都应立即重置标记
// 这样下一次 Coordinator (5秒内) 扫描时,会因为 IsAuthFailed == false
// 且经过了 NormalRetryMs (30s) 而立即尝试拉起。
// ============================================================
device.ResetResilience();
// 2. 创建副本进行对比
var oldConfig = device.Config;
var newConfig = oldConfig.DeepCopy();
// 3. 映射 DTO 值
if (dto.IpAddress != null) newConfig.IpAddress = dto.IpAddress;
if (dto.Port != null) newConfig.Port = dto.Port.Value;
if (dto.Username != null) newConfig.Username = dto.Username;
if (dto.Password != null) newConfig.Password = dto.Password;
if (dto.ChannelIndex != null) newConfig.ChannelIndex = dto.ChannelIndex.Value;
if (dto.StreamType != null) newConfig.StreamType = dto.StreamType.Value;
if (dto.Name != null) newConfig.Name = dto.Name;
if (dto.Brand != null) newConfig.Brand = (DeviceBrand)dto.Brand;
newConfig.RtspPath = dto.RtspPath;
newConfig.MainboardIp = dto.MainboardIp;
newConfig.MainboardPort = dto.MainboardPort;
newConfig.RenderHandle = dto.RenderHandle;
// 4. 判定冷热更新
bool needColdRestart =
newConfig.IpAddress != oldConfig.IpAddress ||
newConfig.Port != oldConfig.Port ||
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)
{
_sysLog.Debug($"[Core] 检测到核心参数变更, 执行冷重启, ID:{deviceId}.");
bool wasRunning = device.IsRunning;
// A. 彻底停止
if (device.IsActived) await device.StopAsync();
// B. 写入新配置
device.UpdateConfig(newConfig);
// C. 自动重启
if (wasRunning)
await device.StartAsync();
}
else
{
_sysLog.Debug($"[Core] 检测到运行时参数变更, 执行热更新, ID:{deviceId}.");
// A. 更新配置数据
device.UpdateConfig(newConfig);
// B. 在线应用策略
if (device.IsActived)
{
// Optimized: 仅构造真正发生变化的参数
var options = new DynamicStreamOptions();
// 判定码流是否真的变了(或者 DTO 明确传了新值)
if (dto.StreamType.HasValue && dto.StreamType != oldConfig.StreamType)
{
options.StreamType = dto.StreamType.Value;
}
// 判定句柄是否真的变了
// Modified: 只有当 DTO 的句柄与旧配置不一致时,才放入 options
if (dto.RenderHandle != oldConfig.RenderHandle)
{
options.RenderHandle = (IntPtr)dto.RenderHandle;
}
// 只有当至少有一个参数需要更新时,才调用底层
// 假设 DynamicStreamOptions 内部有检测是否有值的方法,或者判断其属性
if (options.StreamType.HasValue || options.RenderHandle.HasValue)
{
device.ApplyOptions(options);
}
}
}
}
#endregion
#region --- 6. (Disposal) ---
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
/// <summary>
/// 释放资源
/// </summary>
/// <returns></returns>
public async ValueTask DisposeAsync()
{
if (_isDisposed) return;
_isDisposed = true;
_isEngineStarted = false;
try
{
_globalCts.Cancel();
var devices = _cameraPool.Values.ToArray();
_cameraPool.Clear();
var disposeTasks = devices.Select(async device =>
{
try { await device.DisposeAsync(); }
catch { }
});
await Task.WhenAll(disposeTasks);
try
{
HikSdkManager.Uninitialize();
}
catch { }
try
{
DahuaSdkManager.Uninitialize();
}
catch { }
}
finally
{
_globalCts.Dispose();
}
}
#endregion
#region --- 7. (Helpers) ---
/// <summary>
/// 创建设备实例
/// </summary>
/// <param name="config"></param>
/// <returns></returns>
private BaseVideoSource CreateDeviceInstance(VideoSourceConfig config)
{
return config.Brand switch
{
DeviceBrand.HikVision => new HikVideoSource(config),
DeviceBrand.Dahua => new DahuaVideoSource(config),
DeviceBrand.Usb => new UsbVideoSource(config),
// 使用模式匹配获取不匹配的值,记录详细的 DTO 上下文
_ => HandleUnsupportedBrand(config)
};
}
/// <summary>
/// 处理不支持的设备品牌
/// </summary>
/// <param name="config"></param>
/// <returns></returns>
/// <exception cref="NotSupportedException"></exception>
private BaseVideoSource HandleUnsupportedBrand(VideoSourceConfig config)
{
// 1. 构造错误消息
string errorMsg = $"❌ 不支持的设备品牌: {config.Brand} (ID: {config.Id}, Name: {config.Name})";
// 2. 写入日志 - 建议带上 config 的解构信息,方便排查是否是前端传参错误
_sysLog.Error($"[Core] {errorMsg} | 配置详情: " + "{@Config}", config);
// 3. 抛出异常,阻止程序进入不确定状态
throw new NotSupportedException(errorMsg);
}
#endregion
/// <summary>
/// [新增] 获取当前管理的所有相机设备(兼容网络引擎接口)
/// </summary>
public IEnumerable<BaseVideoSource> GetAllCameras()
{
// 复用现有的 GetAllDevices 逻辑
return GetAllDevices();
}
#region --- [] 线 (SDK ) ---
/// <summary>
/// 当设备在线/离线状态发生变更时触发
/// <para>参数1: DeviceId</para>
/// <para>参数2: IsOnline (true=在线, false=离线)</para>
/// <para>参数3: Reason (变更原因)</para>
/// </summary>
public event Action<long, string, bool, string>? OnDeviceStatusChanged;
/// <summary>
/// [内部方法] 供 Sentinel 调用,触发事件冒泡
/// </summary>
internal void NotifyStatusChange(long deviceId, string ipAddress, bool isOnline, string reason)
{
// 仅仅是触发 C# 事件,完全不知道网络发送的存在
OnDeviceStatusChanged?.Invoke(deviceId, ipAddress, isOnline, reason);
}
#endregion
}