增加了通过网络主动上报图像的支持

增加了指令维护通道的支持
This commit is contained in:
2026-01-07 10:59:03 +08:00
parent a697aab3e0
commit 3d47c8f009
47 changed files with 1613 additions and 1734 deletions

View File

@@ -1,113 +0,0 @@
using Microsoft.Extensions.Hosting;
using SHH.CameraSdk;
namespace SHH.CameraService;
/// <summary>
/// 相机服务核心引擎工作者(后台长驻服务)
/// <para>核心职责:</para>
/// <para>1. 管理 CameraManager 全生命周期(启动、配置、释放)</para>
/// <para>2. 初始化网络哨兵ConnectivitySentinel监控设备网络连通性</para>
/// <para>3. 无配置时自动添加默认测试设备,降低调试门槛</para>
/// <para>设计说明:</para>
/// <para>- 基于 BackgroundService运行在独立后台线程不阻塞 Web 主线程</para>
/// <para>- 与 CameraManager 强绑定,是所有相机设备的统一入口</para>
/// <para>- 包含容错机制,添加设备失败不影响整体服务启动</para>
public class CameraEngineWorker : BackgroundService
{
#region --- ---
/// <summary>
/// 相机管理器实例(核心业务对象)
/// 功能:管理所有相机设备的生命周期、状态监控、配置更新
/// </summary>
private readonly CameraManager _manager;
/// <summary>
/// 网络连通性哨兵实例
/// 功能:周期性 Ping 设备、检测网络状态、触发断线重连
/// </summary>
private readonly ConnectivitySentinel _sentinel;
#endregion
#region --- ---
/// <summary>
/// 初始化相机引擎工作者实例
/// </summary>
/// <param name="manager">相机管理器(通过 DI 注入,已关联存储服务)</param>
/// <param name="sentinel">网络哨兵(通过 DI 注入,已预设监控周期)</param>
public CameraEngineWorker(CameraManager manager, ConnectivitySentinel sentinel)
{
_manager = manager ?? throw new ArgumentNullException(
nameof(manager), "相机管理器实例不能为空,核心引擎启动失败");
_sentinel = sentinel ?? throw new ArgumentNullException(
nameof(sentinel), "网络哨兵实例不能为空,设备监控功能失效");
}
#endregion
#region --- BackgroundService ---
/// <summary>
/// 启动引擎:初始化相机管理器并加载业务配置
/// <para>执行流程:</para>
/// <para>1. 启动 CameraManager加载本地配置文件中的设备信息</para>
/// <para>2. 加载默认业务逻辑(无设备时添加测试设备)</para>
/// <para>注意:网络哨兵的启动逻辑已内置在其构造函数中,此处无需额外调用</para>
/// </summary>
/// <param name="stoppingToken">服务停止令牌(响应应用关闭/重启信号)</param>
/// <returns>异步任务(引擎启动完成后结束)</returns>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Console.WriteLine("[Engine] 核心引擎启动中...");
Console.WriteLine("[Engine] 启动相机管理器(加载设备配置)");
try
{
// 启动相机管理器:加载 App_Data/Process_X 目录下的设备配置文件
await _manager.StartAsync();
Console.WriteLine("[Engine] 相机管理器启动成功,已加载配置文件中的设备");
}
catch (Exception ex)
{
Console.WriteLine($"[Engine] 相机管理器启动失败:{ex.Message}");
Console.WriteLine("[Engine] 警告:核心引擎将继续运行,但无法管理任何相机设备");
return;
}
Console.WriteLine("[Engine] 核心引擎启动完成,进入运行状态");
Console.WriteLine("[Engine] 提示:可通过 API 接口添加/编辑/删除设备,实时生效");
}
/// <summary>
/// 停止引擎:优雅释放资源
/// <para>执行流程:</para>
/// <para>1. 调用 CameraManager.DisposeAsync(),释放所有设备连接、句柄、线程资源</para>
/// <para>2. 调用基类 StopAsync(),标记服务停止状态</para>
/// <para>注意:必须先释放 CameraManager避免设备连接泄露</para>
/// </summary>
/// <param name="cancellationToken">取消令牌(用于强制终止释放流程)</param>
/// <returns>异步任务(资源释放完成后结束)</returns>
public override async Task StopAsync(CancellationToken cancellationToken)
{
Console.WriteLine("[Engine] 核心引擎正在停止...");
try
{
// 释放相机管理器:停止所有设备取流、注销登录、释放非托管资源
await _manager.DisposeAsync();
Console.WriteLine("[Engine] 相机管理器资源已释放");
}
catch (Exception ex)
{
Console.WriteLine($"[Engine] 资源释放异常:{ex.Message}");
}
// 调用基类方法,完成服务停止
await base.StopAsync(cancellationToken);
Console.WriteLine("[Engine] 核心引擎已停止");
}
#endregion
}

View File

@@ -1,123 +0,0 @@
using Newtonsoft.Json;
using SHH.CameraSdk;
using SHH.Contracts;
namespace SHH.CameraService
{
/// <summary>
/// 指令业务逻辑分发器 (纯逻辑层)
/// <para>职责:解析业务参数 -> 调用 CameraManager -> 返回执行结果</para>
/// <para>注意:本类不处理网络协议,也不负责 RequestId 的回填,只关注业务本身</para>
/// </summary>
public static class CommandBusProcessor
{
/// <summary>
/// 核心业务入口
/// </summary>
/// <param name="manager">相机管理器实例</param>
/// <param name="payload">已解析的指令包</param>
/// <returns>执行结果 (不含 RequestId由调用方补充)</returns>
public static CommandResult ProcessBusinessLogic(CameraManager manager, CommandPayload payload)
{
string cmd = payload.CmdCode.ToUpper();
// 忽略客户端发回的 ACK (如果是双向确认模式)
if (cmd == "REGISTER_ACK") return CommandResult.Ok();
// 解析 TargetId (CameraId)
long deviceId = 0;
// 只有非 SYSTEM 指令才需要解析设备ID
if (payload.TargetId != "SYSTEM" && !long.TryParse(payload.TargetId, out deviceId))
{
return CommandResult.Fail($"Invalid Device ID: {payload.TargetId}");
}
try
{
switch (cmd)
{
// ==========================================
// 1. PTZ 云台控制
// ==========================================
case "PTZ":
{
var device = manager.GetDevice(deviceId);
if (device == null) return CommandResult.Fail("Device Not Found");
if (!device.IsOnline) return CommandResult.Fail("Device Offline");
// 检查设备是否支持 PTZ 能力 (接口模式匹配)
if (device is IPtzFeature ptzFeature)
{
var ptzDto = JsonConvert.DeserializeObject<PtzControlDto>(payload.JsonParams);
if (ptzDto == null) return CommandResult.Fail("Invalid PTZ Params");
// 异步转同步执行 (Task.Wait 在后台线程是安全的)
if (ptzDto.Duration > 0)
{
// 点动模式 (例如:向左转 500ms)
ptzFeature.PtzStepAsync(ptzDto.Action, ptzDto.Duration, ptzDto.Speed).Wait();
}
else
{
// 持续模式 (开始转/停止转)
ptzFeature.PtzControlAsync(ptzDto.Action, ptzDto.Stop, ptzDto.Speed).Wait();
}
return CommandResult.Ok("PTZ Executed");
}
return CommandResult.Fail("Device does not support PTZ");
}
// ==========================================
// 2. 远程重启
// ==========================================
case "REBOOT":
{
var device = manager.GetDevice(deviceId);
if (device == null) return CommandResult.Fail("Device Not Found");
if (device is IRebootFeature rebootFeature)
{
rebootFeature.RebootAsync().Wait();
return CommandResult.Ok("Reboot command sent");
}
return CommandResult.Fail("Device does not support Reboot");
}
// ==========================================
// 3. 时间同步
// ==========================================
case "SYNC_TIME":
{
var device = manager.GetDevice(deviceId);
if (device == null) return CommandResult.Fail("Device Not Found");
if (device is ITimeSyncFeature timeFeature)
{
timeFeature.SetTimeAsync(DateTime.Now).Wait();
return CommandResult.Ok("Time synced");
}
return CommandResult.Fail("Device does not support TimeSync");
}
// ==========================================
// 4. 系统级指令 (心跳/诊断)
// ==========================================
case "PING":
return CommandResult.Ok("PONG");
default:
return CommandResult.Fail($"Unknown Command: {cmd}");
}
}
catch (AggregateException ae)
{
// 捕获异步任务内部的异常
return CommandResult.Fail($"Execution Error: {ae.InnerException?.Message}");
}
catch (Exception ex)
{
return CommandResult.Fail($"Execution Error: {ex.Message}");
}
}
}
}

View File

@@ -1,346 +0,0 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Configuration;
using NetMQ;
using NetMQ.Sockets;
using Newtonsoft.Json;
using SHH.CameraSdk;
using SHH.Contracts;
namespace SHH.CameraService
{
/// <summary>
/// 双模指令总线服务 (Enterprise V2)
/// <para>核心职责:建立 TCP 指令通道,接收客户端指令并分发给 CameraManager</para>
/// <para>增强特性:</para>
/// <para>1. 支持双模:被动监听 (Bind) 与 主动投递 (Connect)</para>
/// <para>2. 幂等性控制:利用 MemoryCache 防止客户端重试导致的重复执行</para>
/// <para>3. 顺序一致性:利用时间戳防止指令乱序</para>
/// </summary>
public class CommandBusService : BackgroundService
{
#region --- 1. ---
private readonly CameraManager _cameraManager;
private readonly IConfiguration _config;
private readonly IMemoryCache _cache; // 核心:用于请求去重
private readonly int _processId;
// 运行状态标志
private volatile bool _isRunning = false;
// 两种模式的 Socket (互斥存在)
private ResponseSocket? _repSocket; // 模式A: 被动监听 (Server-Listening)
private DealerSocket? _dealerSocket; // 模式B: 主动投递 (Server-Dialing)
// 顺序一致性锁:记录每个设备最后处理的指令时间戳
// Key: TargetId (设备ID), Value: Timestamp (最后执行时间)
private readonly Dictionary<string, DateTime> _deviceLastCmdTime = new();
#endregion
#region --- 2. ---
/// <summary>
/// 构造函数 (注意:必须在 Program.cs 注册 AddMemoryCache)
/// </summary>
public CommandBusService(CameraManager manager, IConfiguration config, IMemoryCache cache)
{
_cameraManager = manager;
_config = config;
_cache = cache;
// 获取当前进程 ID (默认为 1)
_processId = _config.GetValue<int>("ProcessId", 1);
}
#endregion
#region --- 3. ---
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
// 在后台线程启动,避免阻塞 Web 主线程
return Task.Run(() =>
{
_isRunning = true;
// 1. 读取网络策略
// 优先读取配置中的主动目标,如果没有则回退到被动监听
string? activeTargetIp = _config["Network:ActiveTargets:0:Ip"];
bool isActiveMode = !string.IsNullOrEmpty(activeTargetIp);
try
{
if (isActiveMode)
{
// === 模式 B: 主动投递 (Server Connects Client) ===
// 场景:服务器在内网,主动连接公网/固定IP的客户端
int cmdPort = _config.GetValue<int>("Network:ActiveTargets:0:CmdPort", 7000);
string addr = $"tcp://{activeTargetIp}:{cmdPort}";
RunActiveMode(addr, stoppingToken);
}
else
{
// === 模式 A: 被动监听 (Server Binds Port) ===
// 场景服务器有固定IP等待客户端连接
int basePort = _config.GetValue<int>("Network:Passive:CmdPortBase", 7000);
int listenPort = basePort + (_processId - 1);
string addr = $"tcp://*:{listenPort}";
RunPassiveMode(addr, stoppingToken);
}
}
catch (Exception ex)
{
Console.WriteLine($"[CmdBus] 致命错误停止: {ex.Message}");
}
finally
{
_isRunning = false;
CleanupSockets();
}
}, stoppingToken);
}
private void CleanupSockets()
{
try
{
_repSocket?.Dispose();
_dealerSocket?.Dispose();
}
catch { /* 忽略销毁时的异常 */ }
}
#endregion
#region --- 4. (Passive) ---
private void RunPassiveMode(string address, CancellationToken token)
{
using (_repSocket = new ResponseSocket())
{
_repSocket.Bind(address);
Console.WriteLine($"[CmdBus] [被动模式] 指令监听已启动: {address}");
while (!token.IsCancellationRequested)
{
try
{
// 1. 阻塞等待请求 (超时1秒以便响应 Cancel 信号)
if (!_repSocket.TryReceiveFrameString(TimeSpan.FromSeconds(1), out string reqJson))
continue;
// 2. 处理业务 (带 去重 + ID回填 逻辑)
CommandResult result = this.ProcessRequest(reqJson);
// 3. 发送回执
// 注意REP 模式必须发送应答,即使 result 为 null (Fire-and-Forget) 也建议发一个空 ACK 防止 Socket 状态错乱
// 但为了协议统一,建议 Passive 模式下总是返回结果
string respJson = result != null ? JsonConvert.SerializeObject(result) : "{}";
_repSocket.SendFrame(respJson);
}
catch (Exception ex)
{
Console.WriteLine($"[CmdBus-Passive] 异常: {ex.Message}");
}
}
}
}
#endregion
#region --- 5. (Active) ---
private void RunActiveMode(string address, CancellationToken token)
{
// 外层循环:断线重连机制
while (!token.IsCancellationRequested)
{
try
{
using (_dealerSocket = new DealerSocket())
{
Console.WriteLine($"[CmdBus] [主动模式] 正在连接指令中心: {address}");
_dealerSocket.Connect(address);
// ★★★ 关键步骤:连接成功后,立即发送【身份注册包】 ★★★
// 客户端收到这个包后,才能在界面上显示"设备在线"
SendRegistration(_dealerSocket);
// 内层循环:消息收发
while (!token.IsCancellationRequested)
{
// 1. 接收指令
// DealerSocket 是异步全双工的,这里即使没收到消息也不会阻塞发送
if (!_dealerSocket.TryReceiveFrameString(TimeSpan.FromSeconds(1), out string reqJson))
{
// 空闲周期,可在此处添加心跳发送逻辑 (Ping)
continue;
}
// 2. 处理业务 (带 去重 + ID回填 逻辑)
CommandResult result = this.ProcessRequest(reqJson);
// 3. 发送结果 (QoS控制)
// 如果结果为 null说明指令是 Fire-and-Forget (无需回执),则不发送网络包节省带宽
if (result != null)
{
_dealerSocket.SendFrame(JsonConvert.SerializeObject(result));
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[CmdBus-Active] 连接中断或异常: {ex.Message}");
// 避免死循环狂刷 CPU等待 3 秒再重连
Thread.Sleep(3000);
}
}
}
/// <summary>
/// 发送身份注册包 (Active 模式专用)
/// </summary>
private void SendRegistration(DealerSocket socket)
{
try
{
// 计算实际端口信息
int portOffset = _processId - 1;
var regInfo = new ServerRegistrationDto
{
ProcessId = _processId,
InstanceId = $"Gateway_{_processId}",
ServerIp = GetLocalIpAddress(),
WebApiPort = 5000 + portOffset,
VideoPort = 5555 + portOffset,
CmdPort = 7000 + portOffset,
StartTime = DateTime.Now,
Description = "Active Mode Connection (V2)"
};
// 封装信封 (系统级指令)
var payload = new CommandPayload
{
CmdCode = "SERVER_REGISTER",
TargetId = "SYSTEM",
JsonParams = JsonConvert.SerializeObject(regInfo),
RequestId = Guid.NewGuid().ToString("N"),
RequireAck = false // 注册包通常不需要回执,只要连上就行
};
socket.SendFrame(JsonConvert.SerializeObject(payload));
Console.WriteLine($"[CmdBus] 身份注册包已发送 -> {regInfo.ServerIp}:{regInfo.WebApiPort}");
}
catch (Exception ex)
{
Console.WriteLine($"[CmdBus] 注册包发送失败: {ex.Message}");
}
}
private string GetLocalIpAddress()
{
try
{
var host = System.Net.Dns.GetHostEntry(System.Net.Dns.GetHostName());
foreach (var ip in host.AddressList)
{
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
return ip.ToString();
}
}
catch { }
return "127.0.0.1";
}
#endregion
#region --- 6. ( V2 ) ---
/// <summary>
/// 统一处理请求协议:去重 -> 排序 -> 执行 -> 回填 ID
/// </summary>
private CommandResult ProcessRequest(string json)
{
if (string.IsNullOrWhiteSpace(json)) return CommandResult.Fail("Empty Request");
CommandPayload? payload;
try { payload = JsonConvert.DeserializeObject<CommandPayload>(json); }
catch { return CommandResult.Fail("Invalid JSON Protocol"); }
if (payload == null) return CommandResult.Fail("Null Payload");
// =========================================================
// A. 【幂等性检查】(Idempotency Check)
// =========================================================
// 查缓存:如果这个 RequestId 10秒内处理过直接返回上次的结果
// 这样即使客户端重试发了 10 次,业务逻辑也只跑 1 次
if (_cache.TryGetValue(payload.RequestId, out CommandResult cachedResult))
{
Console.WriteLine($"[Dedup] 拦截重复请求: {payload.RequestId} (Retry: {payload.RetryCount})");
return cachedResult;
}
// =========================================================
// B. 【顺序一致性检查】(Order Guarantee)
// =========================================================
// 防止乱序:比如先发的“停止”因为网络卡顿,比后发的“开始”晚到
if (payload.TargetId != "SYSTEM")
{
lock (_deviceLastCmdTime)
{
if (_deviceLastCmdTime.TryGetValue(payload.TargetId, out DateTime lastTime))
{
if (payload.Timestamp < lastTime)
{
Console.WriteLine($"[Order] 丢弃乱序指令: {payload.CmdCode}");
return CommandResult.Fail("Order Violation: Stale Command Dropped");
}
}
_deviceLastCmdTime[payload.TargetId] = payload.Timestamp;
}
}
// =========================================================
// C. 【业务执行】
// =========================================================
CommandResult result;
try
{
// 调用纯逻辑层
result = CommandBusProcessor.ProcessBusinessLogic(_cameraManager, payload);
}
catch (Exception ex)
{
result = CommandResult.Fail($"Internal Logic Error: {ex.Message}");
}
// =========================================================
// D. 【闭环回填】
// =========================================================
// 必须把身份证号贴回去,不然客户端不知道这是谁的回执
result.RequestId = payload.RequestId;
// =========================================================
// E. 【存入缓存】
// =========================================================
// 缓存 10 秒,覆盖客户端的重试窗口
_cache.Set(payload.RequestId, result, TimeSpan.FromSeconds(10));
// =========================================================
// F. 【QoS 过滤】
// =========================================================
// 如果客户端说不需要回信,返回 null
if (!payload.RequireAck)
{
return null;
}
return result;
}
#endregion
}
}

View File

@@ -1,97 +0,0 @@
using Microsoft.Extensions.Hosting;
using NetMQ;
using NetMQ.Sockets;
using Newtonsoft.Json;
using SHH.CameraSdk;
using System.Text;
namespace SHH.CameraService;
public class CommandClientWorker : BackgroundService
{
private readonly ServiceConfig _config;
public CommandClientWorker(ServiceConfig config)
{
_config = config;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// 1. 如果不是主动/混合模式,不需要连接
if (!_config.ShouldConnect) return;
// ★★★ 核心修正:直接读取解析好的指令地址列表 ★★★
// 这些地址来自参数 --uris "IP,VideoPort&CommandPort" 中的 CommandPort 部分
var cmdUris = _config.CommandEndpoints;
if (cmdUris.Count == 0)
{
Console.WriteLine("[指令] 未在参数中找到指令通道地址(位于&符号右侧),跳过连接。");
return;
}
// 2. 初始化 Dealer Socket
using var dealer = new DealerSocket();
// 设置身份 (Identity),让 Dashboard 知道我是 "CameraApp_01"
string myIdentity = _config.AppId;
dealer.Options.Identity = Encoding.UTF8.GetBytes(myIdentity);
// 3. 连接所有目标 (支持多点控制)
foreach (var uri in cmdUris)
{
Console.WriteLine($"[指令] 连接控制端: {uri}");
dealer.Connect(uri);
}
// 4. 发送登录包 (握手)
// 构造心跳包
var heartbeat = new
{
Type = "Login",
Id = myIdentity,
Time = DateTime.Now
};
string loginJson = JsonConvert.SerializeObject(heartbeat);
// 注意Dealer Socket 发送是负载均衡的 (Round-Robin)。
// 如果连接了多个 DashboardSendFrame 一次只会发给其中一个。
// 为了确保所有 Dashboard 都能收到上线通知,我们根据连接数循环发送几次。
// (注:这只是初始化时的权宜之计,心跳包后续可以定时发送)
for (int i = 0; i < cmdUris.Count; i++)
{
dealer.SendFrame(loginJson);
await Task.Delay(10); // 稍微间隔,给 ZMQ 内部调度一点时间
}
Console.WriteLine($"[指令] 已发送登录包 (ID: {myIdentity}),进入监听循环...");
// 5. 监听循环
while (!stoppingToken.IsCancellationRequested)
{
try
{
// 非阻塞接收 (500ms 超时),避免卡死线程
if (dealer.TryReceiveFrameString(TimeSpan.FromMilliseconds(500), out string msg))
{
Console.WriteLine($"[指令] 收到: {msg}");
// TODO: 在这里解析 JSON 并调用 CameraSDK 执行业务
// var cmd = JsonConvert.DeserializeObject<CommandModel>(msg);
// if (cmd.Action == "Reboot") ...
// 回复 ACK (确认收到)
// Dealer 会自动根据 Router 发来的 RoutingID 路由回去
dealer.SendFrame($"ACK: {msg} (From {myIdentity})");
}
}
catch (Exception ex)
{
Console.WriteLine($"[指令] 通信异常: {ex.Message}");
// 防止异常死循环刷屏
await Task.Delay(1000, stoppingToken);
}
}
}
}

View File

@@ -0,0 +1,139 @@
using Microsoft.Extensions.Hosting;
using NetMQ;
using NetMQ.Sockets;
using Newtonsoft.Json;
using SHH.CameraSdk;
using System.Text;
namespace SHH.CameraService;
public class CommandClientWorker : BackgroundService
{
private readonly ServiceConfig _config;
private readonly CommandDispatcher _dispatcher; // 注入分发器
public CommandClientWorker(ServiceConfig config, CommandDispatcher dispatcher)
{
_config = config;
_dispatcher = dispatcher;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// =================================================================
// ★★★ 核心修复:强制让出主线程 ★★★
// 这行代码会让当前的 ExecuteAsync 立即返回一个未完成的 Task 给 Host
// Host 就会认为 "这个服务启动好了",然后继续去启动 WebAPI。
// 而剩下的代码会被调度到线程池里异步执行,互不干扰。
// =================================================================
await Task.Yield();
// 1. 如果不是主动/混合模式,不需要连接
if (!_config.ShouldConnect) return;
var cmdEndpoints = _config.CommandEndpoints;
if (cmdEndpoints.Count == 0)
{
Console.WriteLine("[指令] 未配置指令通道,跳过注册。");
return;
}
// 2. 初始化 Dealer Socket
using var dealer = new DealerSocket();
// ★★★ 关键:设置身份标识 (Identity) ★★★
// 服务端 (Router) 收到消息时,第一帧就是这个 ID
// 如果不设ZMQ 会随机生成一个二进制 ID服务端就不知道你是谁了
string myIdentity = _config.AppId;
dealer.Options.Identity = Encoding.UTF8.GetBytes(myIdentity);
// 3. 连接所有目标 (遍历 ServiceEndpoint 对象)
foreach (var ep in cmdEndpoints)
{
Console.WriteLine($"[指令] 连接控制端: {ep.Uri} [{ep.Description}]");
try
{
dealer.Connect(ep.Uri);
}
catch (Exception ex)
{
Console.WriteLine($"[指令] 连接失败 {ep.Uri}: {ex.Message}");
}
}
// 1. 获取本机 IP (简单的获取方式,用于上报给 Dashboard)
string localIp = "127.0.0.1";
try
{
// 简单获取首个非回环 IP生产环境建议用更严谨的帮助类
var host = System.Net.Dns.GetHostEntry(System.Net.Dns.GetHostName());
localIp = host.AddressList.FirstOrDefault(ip =>
ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)?.ToString() ?? "127.0.0.1";
}
catch { }
// 4. 构建注册/登录包
var registerPayload = new
{
Action = "Register",
Payload = new
{
// 1. AppId (身份)
Id = _config.AppId,
// 2. Version (程序集版本)
Version = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "1.0.0",
// 3. 进程 ID (用于远程监控)
Pid = Environment.ProcessId,
// 4. 关键端口信息
// 告诉 Dashboard如果你想调我的 REST API请访问这个端口
WebPort = _config.BasePort,
// 如果您有本地绑定的 ZMQ 端口也可以在这里上报
// VideoPort = _config.BasePort + 1,
// 基础网络信息
Ip = localIp,
// 附带信息:我是要把视频推给谁 (供 Dashboard 调试用)
TargetVideoNodes = _config.VideoEndpoints.Select(e => e.Uri).ToList()
},
Time = DateTime.Now
};
string json = JsonConvert.SerializeObject(registerPayload);
// 5. 发送注册包
// Dealer 连接建立是异步的所以这里直接发ZMQ 会在底层连接成功后自动把消息推出去
// 为了保险,对于多个 EndpointDealer 默认是负载均衡发送的(轮询)。
// 如果想让每个 Endpoint 都收到注册包,这在 Dealer 模式下稍微有点特殊。
// 但通常我们只需要发一次,只要有一个 Dashboard 收到并建立会话即可。
// 或者简单粗暴:循环发送几次,确保覆盖。
Console.WriteLine($"[指令] 发送注册包: {json}");
dealer.SendFrame(json);
// 6. 进入监听循环 (等待 ACK 或 指令)
// 进入监听循环
while (!stoppingToken.IsCancellationRequested)
{
try
{
if (dealer.TryReceiveFrameString(TimeSpan.FromMilliseconds(500), out string msg))
{
Console.WriteLine($"[指令] 收到消息: {msg}");
// ★★★ 核心变化:直接扔给分发器 ★★★
// 无论未来加多少指令,这里都不用改代码
await _dispatcher.DispatchAsync(msg);
}
}
catch (Exception ex)
{
Console.WriteLine($"[指令] 异常: {ex.Message}");
}
}
}
}

View File

@@ -0,0 +1,46 @@
using Newtonsoft.Json.Linq;
namespace SHH.CameraService;
public class CommandDispatcher
{
// 路由表Key = ActionName, Value = Handler
private readonly Dictionary<string, ICommandHandler> _handlers;
// 通过依赖注入拿到所有实现了 ICommandHandler 的类
public CommandDispatcher(IEnumerable<ICommandHandler> handlers)
{
_handlers = handlers.ToDictionary(h => h.ActionName, h => h);
}
public async Task DispatchAsync(string jsonMessage)
{
try
{
var jObj = JObject.Parse(jsonMessage);
string action = jObj["Action"]?.ToString();
var payload = jObj["Payload"];
if (string.IsNullOrEmpty(action)) return;
// 1. 查找是否有对应的处理器
if (_handlers.TryGetValue(action, out var handler))
{
await handler.ExecuteAsync(payload);
}
else if (action == "ACK")
{
// ACK 是特殊的,可以直接在这里处理或者忽略
Console.WriteLine($"[指令] 握手成功: {jObj["Message"]}");
}
else
{
Console.WriteLine($"[警告] 未知的指令: {action}");
}
}
catch (Exception ex)
{
Console.WriteLine($"[分发错误] {ex.Message}");
}
}
}

View File

@@ -0,0 +1,30 @@
namespace SHH.CameraService;
/// <summary>
/// 在线客户端信息模型 (已更新)
/// </summary>
public class ConnectedClient
{
/// <summary> 唯一标识 (AppId) </summary>
public string ServiceId { get; set; } = string.Empty;
/// <summary> 版本号 </summary>
public string Version { get; set; } = "1.0.0";
/// <summary> 远程进程 ID </summary>
public int Pid { get; set; }
/// <summary> 客户端 IP </summary>
public string Ip { get; set; } = string.Empty;
/// <summary> WebAPI 端口 (Dashboard 调用 REST 接口用) </summary>
public int WebPort { get; set; }
/// <summary> 该客户端正在推流的目标地址 </summary>
public List<string> TargetVideoNodes { get; set; } = new List<string>();
public DateTime LastHeartbeat { get; set; }
// 辅助属性:拼接出完整的 API BaseUrl
public string WebApiUrl => $"http://{Ip}:{WebPort}";
}

View File

@@ -0,0 +1,18 @@
namespace SHH.CameraService;
/// <summary>
/// 抽象指令处理器接口
/// </summary>
public interface ICommandHandler
{
/// <summary>
/// 该处理器支持的 Action 名称 (如 "AddCamera", "Reboot")
/// </summary>
string ActionName { get; }
/// <summary>
/// 执行指令逻辑
/// </summary>
/// <param name="payload">指令携带的数据 (JSON JToken)</param>
Task ExecuteAsync(Newtonsoft.Json.Linq.JToken payload);
}

View File

@@ -0,0 +1,103 @@
using Newtonsoft.Json.Linq;
using SHH.CameraSdk; // 引用包含 FrameController 和 FrameRequirement 的命名空间
using SHH.Contracts;
namespace SHH.CameraService;
public class SyncCameraHandler : ICommandHandler
{
private readonly CameraManager _cameraManager;
public string ActionName => "SyncCamera";
public SyncCameraHandler(CameraManager cameraManager)
{
_cameraManager = cameraManager;
}
public async Task ExecuteAsync(JToken payload)
{
// 1. 解析配置
var dto = payload.ToObject<CameraConfigDto>();
if (dto == null) return;
// 2. 添加设备到管理器 (这一步是必须的,不然没有 Device 就没有 Controller)
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);
if (device == null)
{
Console.WriteLine($"[SyncError] 设备 {dto.Id} 创建失败,无法执行自动订阅。");
return;
}
// 4. 拿到你的“宝贝”控制器 (FrameController)
var controller = device.Controller;
if (controller == null)
{
Console.WriteLine($"[SyncError] 设备 {dto.Id} 不支持流控调度 (Controller is null)。");
return;
}
// 5. 暴力注册订阅需求 (Loop AutoSubscriptions)
if (dto.AutoSubscriptions != null && dto.AutoSubscriptions.Count > 0)
{
foreach (var subItem in dto.AutoSubscriptions)
{
// 生成 AppId (照抄你给的逻辑)
string finalAppId = string.IsNullOrWhiteSpace(subItem.AppId)
? $"SUB_{Guid.NewGuid().ToString("N").Substring(0, 8).ToUpper()}"
: subItem.AppId;
Console.WriteLine($"[自动化] 正在注册流控: {finalAppId}, 目标: {subItem.TargetFps} FPS");
// 构造 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 = "",
SavePath = ""
};
// ★★★ 见证奇迹的时刻:直接调用 Register ★★★
controller.Register(requirement);
}
}
//// 6. 启动设备
//// 你的积分算法会在 device 内部的推流循环中被 MakeDecision 调用
if (dto.ImmediateExecution)
await device.StartAsync();
Console.WriteLine($"[SyncSuccess] 设备 {dto.Id} 同步完成,策略已下发。");
}
}

View File

@@ -0,0 +1,24 @@
namespace SHH.CameraService;
/// <summary>
/// 定义发送的目标
/// </summary>
public class PushTargetConfig
{
/// <summary>
/// 目标名称
/// </summary>
public string Name { get; set; }
= string.Empty;
/// <summary>
/// NetMQ 地址 (如 "tcp://1.2.3.4:5555")
/// </summary>
public string Endpoint { get; set; }
= string.Empty;
/// <summary>
/// 独立队列容量 (隔离的关键)
/// </summary>
public int QueueCapacity { get; set; } = 10;
}

View File

@@ -0,0 +1,30 @@
namespace SHH.CameraService;
/// <summary>
/// 代表一个独立的推送目标
/// 包含:配置信息 + 专属于它的数据管道
/// </summary>
public class StreamTarget
{
/// <summary>
/// 配置
/// </summary>
public PushTargetConfig Config { get; }
/// <summary>
/// 管道
/// </summary>
public VideoDataChannel Channel { get; }
/// <summary>
/// 构造函数
/// </summary>
/// <param name="config"></param>
public StreamTarget(PushTargetConfig config)
{
Config = config;
// 为这个目标创建独立的管道,容量由配置决定
Channel = new VideoDataChannel(capacity: config.QueueCapacity);
}
}

View File

@@ -0,0 +1,52 @@
using Microsoft.Extensions.Hosting;
using SHH.CameraSdk;
public class CameraEngineWorker : BackgroundService
{
private readonly CameraManager _manager;
public CameraEngineWorker(CameraManager manager)
{
// 理由:严谨性检查,防止因配置错误导致的空指针崩溃
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Console.WriteLine("[Engine] 正在启动核心引擎...");
try
{
// 1. 理由:启动 SDK 内部加载流程(从本地存储恢复设备)
await _manager.StartAsync();
Console.WriteLine("[Engine] 设备管理服务已启动。");
}
catch (Exception ex)
{
Console.WriteLine($"[Engine] 严重启动异常: {ex.Message}");
return; // 理由:核心组件失败,终止后续逻辑
}
// 2. 理由Worker 必须保持活跃状态,以便作为宿主生命周期的一部分
while (!stoppingToken.IsCancellationRequested)
{
// 你可以在这里定期输出一些状态统计
// Console.WriteLine($"[Engine] 活跃设备数: {_manager.GetActiveCount()}");
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
Console.WriteLine("[Engine] 正在执行优雅停机...");
try
{
// 理由:这是重构的核心。必须在 SDK 退出前释放所有非托管句柄
await _manager.DisposeAsync();
}
finally
{
await base.StopAsync(cancellationToken);
}
}
}

View File

@@ -0,0 +1,73 @@
using NetMQ;
using SHH.Contracts;
namespace SHH.CameraService
{
/// <summary>
/// 负责将业务契约转换为 ZeroMQ 传输协议
/// </summary>
public static class NetMQProtocolExtensions
{
private const string PROTOCOL_HEADER = "SHH_V1";
/// <summary>
/// 扩展方法:将 Payload 转为 NetMQMessage
/// 使用方法var msg = payload.ToNetMqMessage();
/// </summary>
public static NetMQMessage ToNetMqMessage(this VideoPayload payload)
{
var msg = new NetMQMessage();
// Frame 0: 协议魔数
msg.Append(PROTOCOL_HEADER);
// Frame 1: 元数据 JSON
msg.Append(payload.GetMetadataJson());
// Frame 2: 原始图 (保持帧位对齐,无数据则发空帧)
if (payload.HasOriginalImage && payload.OriginalImageBytes != null)
msg.Append(payload.OriginalImageBytes);
else
msg.Append(Array.Empty<byte>());
// Frame 3: 处理图
if (payload.HasTargetImage && payload.TargetImageBytes != null)
msg.Append(payload.TargetImageBytes);
else
msg.Append(Array.Empty<byte>());
return msg;
}
/// <summary>
/// 扩展方法:从 NetMQMessage 还原 Payload
/// </summary>
public static VideoPayload ToVideoPayload(this NetMQMessage msg)
{
if (msg == null || msg.FrameCount < 4) return null;
// Frame 0 Check
if (msg[0].ConvertToString() != PROTOCOL_HEADER) return null;
// Frame 1: Metadata
string json = msg[1].ConvertToString();
var payload = VideoPayload.FromMetadataJson(json);
if (payload == null) return null;
// Frame 2: Raw Image
// 利用 BufferSize 避免不必要的内存拷贝如果长度为0则跳过
if (payload.HasOriginalImage && msg[2].BufferSize > 0)
{
payload.OriginalImageBytes = msg[2].ToByteArray();
}
// Frame 3: Processed Image
if (payload.HasTargetImage && msg[3].BufferSize > 0)
{
payload.TargetImageBytes = msg[3].ToByteArray();
}
return payload;
}
}
}

View File

@@ -0,0 +1,62 @@
using Microsoft.Extensions.Hosting;
using NetMQ;
using NetMQ.Sockets;
namespace SHH.CameraService;
/// <summary>
/// NetMQ 发送工作者
/// 职责:从指定目标的 VideoDataChannel 读取 Payload通过 ZeroMQ 发送出去
/// </summary>
public class NetMqSenderWorker : BackgroundService
{
private readonly StreamTarget _target;
// 构造函数注入特定的目标对象 (由 Program.cs 的工厂方法提供)
public NetMqSenderWorker(StreamTarget target)
{
_target = target;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Console.WriteLine($"[NetMqSender] 正在连接至服务端: {_target.Config.Endpoint} ...");
// ★★★ 修正点:必须使用 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
{
var msg = payload.ToNetMqMessage();
// 发送消息
// PublisherSocket 的 TrySend 如果没人订阅或者队列满了,通常不会阻塞,而是直接丢弃或返回
// 注意PUB 模式下,第一帧 ("SHH_V1") 会被当作订阅的主题 (Topic)。
// 你的接收端订阅了 "" (空字符串),所以能收到以任何字符串开头的数据。
bool sent = clientSocket.TrySendMultipartMessage(msg);
if (!sent)
{
// 这种情况通常意味着网络断了且 HWM 队列也满了
Console.WriteLine($"[NetMqSender] 警告: 发送队列已满,正在丢帧...");
msg.Clear(); // 手动清理(可选)
}
}
catch (Exception ex)
{
Console.WriteLine($"[NetMqSender] 异常: {ex.Message}");
}
}
}
}

View File

@@ -0,0 +1,126 @@
using Microsoft.Extensions.Hosting;
using OpenCvSharp;
using SHH.CameraSdk; // 引用 SDK 核心
using SHH.Contracts;
using System.Diagnostics;
namespace SHH.CameraService;
public class NetworkStreamingWorker : BackgroundService
{
// 注入所有注册的目标(云端、大屏等),实现动态分发
private readonly IEnumerable<StreamTarget> _targets;
// 编码参数JPG 质量 75 (平衡画质与带宽)
// 工业经验75 是甜点,体积只有 100 的 1/3肉眼几无区别。
// 如果您确实需要 100请注意带宽压力。此处我保留您要求的 100但建议未来调优。
private readonly int[] _encodeParams = { (int)ImwriteFlags.JpegQuality, 100 };
public NetworkStreamingWorker(IEnumerable<StreamTarget> targets)
{
_targets = targets;
}
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
Console.WriteLine("[StreamWorker] 启动流媒体采集引擎...");
// =========================================================
// 订阅逻辑:接入 "上帝模式" (God Mode)
// =========================================================
// 理由NetMQ 网关需要无差别地获取所有设备的图像。
GlobalStreamDispatcher.OnGlobalFrame += ProcessFrame;
//Console.WriteLine($"[StreamWorker] 已挂载至全局广播总线,正在监听 {GlobalStreamDispatcher.OnGlobalFrame?.GetInvocationList().Length ?? 0} 个订阅者...");
var tcs = new TaskCompletionSource();
stoppingToken.Register(() =>
{
// 停止时反注册,防止静态事件内存泄漏
GlobalStreamDispatcher.OnGlobalFrame -= ProcessFrame;
Console.WriteLine("[StreamWorker] 已断开全局广播连接");
tcs.SetResult();
});
return tcs.Task;
}
/// <summary>
/// [回调函数] 处理每一帧图像
/// 注意:此方法运行在 SDK 的采集线程池中,必须极速处理,严禁阻塞!
/// </summary>
private void ProcessFrame(long deviceId, SmartFrame frame)
{
try
{
// 1. 基础校验 (合法性检查)
if (frame == null || frame.InternalMat.Empty()) return;
long startTick = Stopwatch.GetTimestamp();
// =========================================================
// 2. 一次编码 (One Encode) - CPU 消耗点
// =========================================================
// 理由:在这里同步编码是最安全的,因为出了这个函数 frame 内存就会失效。
// 且只编一次,后续分发给 10 个目标也只用这一份数据。
byte[] jpgBytes = null;
// 如果有更小的图片, 原始图片不压缩, 除非有特殊需求
if (frame.TargetMat == null)
{
jpgBytes = EncodeImage(frame.InternalMat);
}
// 双流支持:如果存在处理后的 AI 图,也一并编码
byte[] targetBytes = null;
if (frame.TargetMat != null && !frame.TargetMat.Empty())
{
targetBytes = EncodeImage(frame.TargetMat);
}
// =========================================================
// 3. 构建 Payload (数据载荷)
// =========================================================
var payload = new VideoPayload
{
CameraId = deviceId.ToString(),
CaptureTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
OriginalImageBytes = jpgBytes, // 引用赋值
TargetImageBytes = targetBytes, // 引用赋值
OriginalWidth = frame.TargetWidth,
OriginalHeight = frame.TargetHeight,
DispatchTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
};
// 计算转码耗时(ms)
double processMs = (Stopwatch.GetTimestamp() - startTick) * 1000.0 / Stopwatch.Frequency;
payload.Diagnostics["encode_ms"] = Math.Round(processMs, 2);
// =========================================================
// 4. 动态扇出 (Dynamic Fan-Out) - 内存消耗极低
// =========================================================
// 遍历所有目标,往各自独立的管道里写数据。
// 实现了"物理隔离":一个管道满了(云端卡顿),不影响另一个管道(大屏流畅)。
foreach (var target in _targets)
{
// WriteLog 是非阻塞的。满了就丢弃,返回 false。
target.Channel.WriteLog(payload);
}
}
catch (Exception ex)
{
// 极少发生的内存错误,打印日志但不抛出,避免崩溃 SDK 线程
Console.WriteLine($"[StreamWorker] 采集处理异常: {ex.Message}");
}
}
/// <summary>
/// 辅助OpenCV 内存编码
/// </summary>
private byte[] EncodeImage(Mat mat)
{
// ImEncode 将 Mat 编码为一维字节数组 (托管内存)
Cv2.ImEncode(".jpg", mat, out byte[] buf, _encodeParams);
return buf;
}
}

View File

@@ -0,0 +1,40 @@
using System.Threading.Channels;
using SHH.Contracts;
namespace SHH.CameraService
{
/// <summary>
/// 视频数据内部总线 (线程安全的生产者-消费者通道)
/// <para>作用:解耦 [采集编码线程] 与 [网络发送线程]</para>
/// </summary>
public class VideoDataChannel
{
// 限制容量为 100 帧。如果积压超过 100 帧,说明发送端彻底堵死了,必须丢帧。
private readonly Channel<VideoPayload> _channel;
public VideoDataChannel(int capacity = 10)
{
var options = new BoundedChannelOptions(capacity)
{
FullMode = BoundedChannelFullMode.DropOldest, // 核心策略:满了就丢弃最旧的帧
SingleReader = false, // 允许多个发送 Worker (如 CloudWorker, ScreenWorker) 同时读取
SingleWriter = true // 只有一个采集线程在写
};
_channel = Channel.CreateBounded<VideoPayload>(options);
}
/// <summary>
/// [生产者] 写入一个封装好的数据包 (非阻塞)
/// </summary>
public void WriteLog(VideoPayload payload)
{
// TryWrite 永远不会等待,满了就丢旧的写入新的,返回 true
_channel.Writer.TryWrite(payload);
}
/// <summary>
/// [消费者] 读取器
/// </summary>
public ChannelReader<VideoPayload> Reader => _channel.Reader;
}
}

View File

@@ -1,91 +0,0 @@
using SHH.CameraSdk;
using System.Collections.Concurrent;
namespace SHH.CameraService;
/// <summary>
/// 网络推流管理器
/// 职责:管理 ZeroMQ 推流任务的生命周期
/// 类似于 DisplayWindowManager它负责订阅数据并将其桥接到传输层
/// </summary>
public class NetworkStreamManager
{
private readonly VideoDataChannel _channel;
// 记录当前活跃的推流任务,防止重复订阅
private readonly ConcurrentDictionary<string, bool> _activeStreams = new();
public NetworkStreamManager(VideoDataChannel channel)
{
_channel = channel;
}
/// <summary>
/// 启动推流任务
/// </summary>
public void StartStream(string appId, long deviceId)
{
// 1. 防止重复启动
if (_activeStreams.ContainsKey(appId)) return;
// 2. 向全局分发器订阅精准数据
// 这里实现了业务逻辑的闭环:只有被 Manager 管理的任务才会消耗 CPU 去转码
GlobalStreamDispatcher.Subscribe(appId, deviceId, (frame) =>
{
// --- 这里的代码运行在分发线程中 ---
// A. 转码 (耗时操作封装在这里,不污染 Controller)
byte[] jpgBytes = EncodeFrameToJpg(frame);
if (jpgBytes != null && jpgBytes.Length > 0)
{
var payload = new VideoPayload
{
CameraId = appId, // 使用 AppId 作为 Topic (给 Dashboard 订阅用)
OriginalImageBytes = jpgBytes,
CaptureTime = DateTime.Now,
OriginalWidth = frame.TargetWidth,
OriginalHeight = frame.TargetHeight
};
// B. 写入传输通道
_ = _channel.WriteAsync(payload);
}
});
_activeStreams.TryAdd(appId, true);
Console.WriteLine($"[Network] 推流任务已启动: {appId} -> Device {deviceId}");
}
/// <summary>
/// 停止推流任务
/// </summary>
public void StopStream(string appId)
{
if (_activeStreams.TryRemove(appId, out _))
{
// 1. 从全局分发器注销
GlobalStreamDispatcher.Unsubscribe(appId);
Console.WriteLine($"[Network] 推流任务已停止: {appId}");
}
}
// --- 辅助方法 ---
private byte[] EncodeFrameToJpg(SmartFrame frame)
{
try
{
// 优先使用处理后的 TargetMat如果没有则用原始的 InternalMat
var mat = frame.TargetMat ?? frame.InternalMat;
if (mat != null && !mat.Empty())
{
// 80 质量平衡体积与画质
return mat.ImEncode(".jpg", new int[] { 1, 80 });
}
}
catch (Exception ex)
{
Console.WriteLine($"[Network] 转码失败: {ex.Message}");
}
return Array.Empty<byte>();
}
}

View File

@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi.Models;
using SHH.CameraSdk; // 引用你的业务核心
using SHH.CameraSdk;
namespace SHH.CameraService;
@@ -9,163 +9,152 @@ public class Program
{
public static async Task Main(string[] args)
{
// 缓冲时间 (您之前写了20000ms即20秒可能是为了附加调试器。如果觉得太慢可以改回 2000)
for(var i=1; i<10; i++)
// 1. 理由:缓冲时间 10 秒, 供附加调试工具使用
for (var i = 1; i < 10; i++)
Thread.Sleep(1000);
// 1. 解析配置
// =============================================================
// 2. 基础环境与配置 (理由:明确身份 ID 和 监听端口)
// =============================================================
var config = ServiceConfig.BuildFromArgs(args);
// ---【补全变量定义】---
// A. 补全 webPort (统一使用 config.BasePort)
int webPort = config.BasePort;
// B. 补全 processIdInt (用于 FileStorage 和 CameraSdk)
// 逻辑:尝试将 AppId 解析为数字;如果 AppId 是字符串(如"CameraApp_01"),则默认给 1或者根据 BasePort 推算
int processIdInt = config.NumericId;
Console.Title = $"SHH Gateway - {config.AppId} (Web: {webPort})";
#region --- 2. ---
InitHardwareEnv();
#endregion
#region --- 3. WebHost ---
// 硬件预热 (理由:确保底层驱动库在 Web 容器启动前完全就绪)
HikNativeMethods.NET_DVR_Init();
HikSdkManager.ForceWarmUp();
var builder = WebApplication.CreateBuilder(args);
// ★★★ 核心:注入全局配置 ★★★
// =============================================================
// 3. 依赖注入注册 (DI)
// =============================================================
builder.Services.AddSingleton(config);
// -------------------------------------------------------------
// A. 注册新架构组件
// -------------------------------------------------------------
builder.Services.AddSingleton<VideoDataChannel>();
// 推流服务 (连接 config.TargetClients 里的 :6002)
builder.Services.AddHostedService<ZeroMQBridgeWorker>();
// 指令客户端 (连接 config.TargetClients 里的 :6001)
builder.Services.AddHostedService<CommandClientWorker>();
// 进程守护
builder.Services.AddHostedService<ParentProcessSentinel>();
// -------------------------------------------------------------
// B. 注册 SDK 业务服务
// -------------------------------------------------------------
// 使用刚刚补全的 processIdInt
builder.Services.AddSingleton<IStorageService>(new FileStorageService(processIdInt));
builder.Services.AddSingleton<CameraManager>();
// 注册缩放与增亮业务(不注册则不实现)
builder.Services.AddSingleton<ProcessingConfigManager>();
builder.Services.AddSingleton<DisplayWindowManager>();
builder.Services.AddSingleton<NetworkStreamManager>();
builder.Services.AddSingleton<ImageScaleCluster>(sp => new ImageScaleCluster(4, sp.GetRequiredService<ProcessingConfigManager>()));
builder.Services.AddSingleton<ImageEnhanceCluster>(sp => new ImageEnhanceCluster(4, sp.GetRequiredService<ProcessingConfigManager>()));
builder.Services.AddSingleton(sp => new ImageScaleCluster(4, sp.GetRequiredService<ProcessingConfigManager>()));
builder.Services.AddSingleton(sp => new ImageEnhanceCluster(4, sp.GetRequiredService<ProcessingConfigManager>()));
builder.Services.AddHostedService<PipelineConfigurator>();
// 使用补全的 processIdInt
builder.Services.AddCameraSdk(processIdInt);
// 接入 SDK 核心逻辑
builder.Services.AddCameraSdk(config.NumericId);
// 注册后台引擎 (理由:托管长周期的硬件状态监控)
builder.Services.AddHostedService<CameraEngineWorker>();
builder.Services.AddSingleton<ConnectivitySentinel>();
builder.Services.AddControllers().AddApplicationPart(typeof(CamerasController).Assembly);
builder.Services.AddControllers().AddApplicationPart(typeof(MonitorController).Assembly);
// 配置 Web 相关的服务
ConfigureWebServices(builder, config);
// -------------------------------------------------------------
// C. Web API 基础
// -------------------------------------------------------------
builder.Services.AddControllers().AddControllersAsServices();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
// 配置进程守护
builder.Services.AddHostedService<ParentProcessSentinel>();
// =============================================================
// 4. 接受启动传参, 并支持将视频进行网络广播
// =============================================================
// 1. 读取配置创建 targets (可以是 1 个,也可以是 10 个)
var netTargets = new List<StreamTarget>();
if (config.VideoEndpoints != null)
{
// 【修正】使用 config.AppId
c.SwaggerDoc("v1", new OpenApiInfo { Title = $"Gateway {config.AppId}", Version = "v1" });
});
foreach(var cfgVideo in config.VideoEndpoints)
{
netTargets.Add(new StreamTarget(new PushTargetConfig
{
Name = cfgVideo.Description, Endpoint = cfgVideo.Uri, QueueCapacity = 10,
}));
}
}
// 2. 注册 Targets (供采集者用)
builder.Services.AddSingleton<IEnumerable<StreamTarget>>(netTargets);
builder.Services.AddCors(o => o.AddPolicy("AllowAll", p => p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
// 3. 注册采集者 (它会注入上面的 targets进行编码和分发)
builder.Services.AddHostedService<NetworkStreamingWorker>();
// 4. 为每个 Target 注册一个独立的发送者
foreach (var target in netTargets)
{
builder.Services.AddHostedService(sp => new NetMqSenderWorker(target));
}
// =============================================================
// 5. 命令管道配置
// =============================================================
// 负责连接 Dashboard注册身份接收重启/控制指令
builder.Services.AddHostedService<CommandClientWorker>();
// 1. 注册分发器
builder.Services.AddSingleton<CommandDispatcher>();
// 2. 注册具体的指令处理器 (每写一个新的 Handler就在这里注册一下或者用反射批量注册)
builder.Services.AddSingleton<ICommandHandler, SyncCameraHandler>();
// =============================================================
// 6. 构建与管道配置
// =============================================================
var app = builder.Build();
//// =======================================================================
//// ★★★ 核心接入点:连接 [现有分发器] 与 [新推流通道] ★★★
//// =======================================================================
//// 1. 获取刚刚注册的数据通道
//var videoChannel = app.Services.GetRequiredService<VideoDataChannel>();
////var config = app.Services.GetRequiredService<ServiceConfig>();
//// 2. 订阅你现有的全局事件 (这里就是“取货”的地方)
//// 每当 HikVideoSource 采集到一帧并调用 Dispatch 时,这里就会触发
//GlobalStreamDispatcher.OnGlobalFrame += (deviceId, smartFrame) =>
//{
// // 3. 数据处理:将 OpenCvSharp Mat 转为 JPG 字节流 (网络传输必须压缩)
// byte[] jpgData = EncodeToJpg(smartFrame);
// if (jpgData != null && jpgData.Length > 0)
// {
// // 4. 封装载荷
// var payload = new VideoPayload
// {
// // 使用 AppId 或 DeviceId 作为标识
// CameraId = config.AppId,
// OriginalImageBytes = jpgData,
// CaptureTime = DateTime.Now,
// OriginalWidth = smartFrame.TargetWidth,
// OriginalHeight = smartFrame.TargetHeight
// };
// // 5. 扔进通道 (Fire-and-Forget不阻塞你原来的显示逻辑)
// // WriteAsync 是 ValueTask这里忽略等待追求最高吞吐
// _ = videoChannel.WriteAsync(payload);
// }
//};
//Console.WriteLine("[System] 全局流已桥接到 ZeroMQ 推流通道");
// 核心修复:同步点火逻辑 (理由:在 Web 开启前完成设备池的初步构建)
await StartBusinessLogic(app);
app.UseSwagger();
app.UseSwaggerUI();
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/v1/swagger.json", $"SHH Gateway #{config.AppId}");
});
app.MapGet("/", () => $"SHH Gateway {config.AppId} is running.");
app.UseCors("AllowAll");
// 理由:正式映射控制器路由
app.MapControllers();
// 【修正】使用 webPort
Console.WriteLine($"[System] Web API 已启动: http://0.0.0.0:{webPort}");
await app.RunAsync($"http://0.0.0.0:{webPort}");
#endregion
}
static void InitHardwareEnv()
{
Console.WriteLine("=== 工业级视频接入服务启动 ===");
// =============================================================
// 5. 正式启动
// =============================================================
await app.RunAsync($"http://0.0.0.0:{config.BasePort}");
}
/// <summary>
/// 内存转码Mat -> Jpg Bytes
/// 对齐业务启动:激活单例并启动相机管理器
/// </summary>
static byte[] EncodeToJpg(SmartFrame frame)
static async Task StartBusinessLogic(WebApplication app)
{
try
var manager = app.Services.GetRequiredService<CameraManager>();
// 激活哨兵逻辑 (理由:显式 Get 触发单例构造,否则不工作)
_ = app.Services.GetRequiredService<ConnectivitySentinel>();
// 启动相机任务加载
await manager.StartAsync();
Console.WriteLine("[System] 核心业务逻辑已激活。");
}
/// <summary>
/// 注册 Web API 支持
/// </summary>
static void ConfigureWebServices(WebApplicationBuilder builder, ServiceConfig cfg)
{
builder.Services.AddCors(options =>
{
// 假设 SmartFrame 内部持有 OpenCvSharp.Mat 类型的 InternalMat
if (frame != null && frame.InternalMat != null && !frame.InternalMat.Empty())
{
// 80 是 JPG 质量参数,平衡画质与带宽
return frame.InternalMat.ImEncode(".jpg", new int[] { 1, 80 });
}
}
catch
options.AddPolicy("AllowAll", p => p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod());
});
// ★★★★★ 补全点:跨项目控制器加载 ★★★★★
// 理由Controller 定义在 SDK 项目中,必须通过 AddApplicationPart 显式挂载
builder.Services.AddControllers(options =>
{
// 容错处理,防止一帧损坏导致程序崩溃
}
return Array.Empty<byte>();
options.Filters.Add<UserActionFilter>();
})
.AddApplicationPart(typeof(CamerasController).Assembly) // 必备:加载相机控制接口
.AddApplicationPart(typeof(MonitorController).Assembly); // 必备:加载监控接口
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = $"SHH Gateway #{cfg.AppId}", Version = "v1" });
});
}
}

View File

@@ -7,6 +7,12 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Dels\**" />
<EmbeddedResource Remove="Dels\**" />
<None Remove="Dels\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="NetMQ" Version="4.0.2.2" />
</ItemGroup>

View File

@@ -1,63 +0,0 @@
using System.Threading.Channels;
namespace SHH.CameraService;
/// <summary>
/// 视频数据高速通道
/// <para>作用:解耦 采集线程(Producer) 和 发送线程(Consumer)</para>
/// <para>特性:使用 BoundedChannel当网络发送慢时自动丢弃旧帧(DropOldest),防止内存溢出。</para>
/// </summary>
public class VideoDataChannel
{
// 创建一个有限容量的通道 (容量 5)
// 如果发送端太慢这就满了DropOldest 会丢弃最旧的帧,保证实时性
private readonly Channel<VideoPayload> _channel = Channel.CreateBounded<VideoPayload>(
new BoundedChannelOptions(5)
{
FullMode = BoundedChannelFullMode.DropOldest, // 核心策略:丢弃旧帧
SingleReader = true, // 只有一个 ZeroMQWorker 在读
SingleWriter = false //可能有多个相机线程在写
});
// ★★★ 新增:公开 Reader 属性,让外部可以直接调用 ReadAsync ★★★
public ChannelReader<VideoPayload> Reader => _channel.Reader;
/// <summary>
/// 写入数据 (生产者调用)
/// </summary>
public ValueTask WriteAsync(VideoPayload payload)
{
return _channel.Writer.WriteAsync(payload);
}
/// <summary>
/// 读取数据流 (消费者调用)
/// </summary>
public IAsyncEnumerable<VideoPayload> ReadAllAsync(CancellationToken ct)
{
return _channel.Reader.ReadAllAsync(ct);
}
}
// 附带:如果您的项目中还没有定义 VideoPayload这里是一个最小实现
// 如果 SHH.Contracts 中已有,请忽略此类
public class VideoPayload
{
/// <summary> 相机唯一标识 </summary>
public string CameraId { get; set; } = string.Empty;
/// <summary> 采集时间 </summary>
public DateTime CaptureTime { get; set; }
/// <summary> 发送时间 </summary>
public DateTime DispatchTime { get; set; }
/// <summary> 原始宽 </summary>
public int OriginalWidth { get; set; }
/// <summary> 原始高 </summary>
public int OriginalHeight { get; set; }
/// <summary> 已编码的图片数据 (JPG) </summary>
public byte[] OriginalImageBytes { get; set; } = Array.Empty<byte>();
}

View File

@@ -1,87 +0,0 @@
using Microsoft.Extensions.Hosting;
using NetMQ;
using NetMQ.Sockets;
using SHH.CameraSdk;
namespace SHH.CameraService;
public class ZeroMQBridgeWorker : BackgroundService
{
private readonly ServiceConfig _config;
private readonly VideoDataChannel _channel; // 数据源
public ZeroMQBridgeWorker(ServiceConfig config, VideoDataChannel channel)
{
_config = config;
_channel = channel;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// 1. 如果不是主动/混合模式,不需要连接
if (!_config.ShouldConnect) return;
// ★★★ 核心修正:直接读取解析好的视频地址列表 ★★★
// 这些地址来自参数 --uris "IP,VideoPort&CommandPort" 中的 VideoPort 部分 (符号左边)
var streamUris = _config.VideoEndpoints;
if (streamUris.Count == 0)
{
Console.WriteLine("[推流] 未在参数中找到视频通道地址(位于&符号左侧),跳过连接。");
return;
}
// 2. 初始化 Publisher Socket
// 特点:只需 Send 一次,底层会自动分发给所有 Connect 的 Dashboard
using var pubSocket = new PublisherSocket();
// 设置发送高水位 (HWM)
// 防止网络拥塞或接收端处理慢时内存无限增长。超过50帧积压就开始丢弃旧帧。
pubSocket.Options.SendHighWatermark = 50;
// 3. 连接所有视频目标
foreach (var uri in streamUris)
{
Console.WriteLine($"[推流] 连接视频接收端: {uri}");
pubSocket.Connect(uri);
}
Console.WriteLine($"[推流] 服务就绪 (AppId: {_config.AppId}),等待视频帧...");
// 4. 推流循环
while (!stoppingToken.IsCancellationRequested)
{
try
{
// 从通道读取最新帧 (支持异步等待)
// 注意:这里使用了之前 VideoDataChannel 暴露出来的 Reader 属性
var payload = await _channel.Reader.ReadAsync(stoppingToken);
// 简单校验
if (payload == null || payload.OriginalImageBytes == null || payload.OriginalImageBytes.Length == 0)
continue;
// 构造 Topic (通常用 AppId 作为 Topic这样 Dashboard 可以按需订阅)
string topic = _config.AppId;
// 发送两帧:[Topic] [ImageBytes]
// 这样 Dashboard 的 Subscriber 可以通过 Subscribe(topic) 来过滤
pubSocket.SendMoreFrame(topic)
.SendFrame(payload.OriginalImageBytes);
// 调试日志 (生产环境建议注释掉,否则刷屏)
// Console.WriteLine($"[推流] Sent {payload.OriginalImageBytes.Length} bytes");
}
catch (OperationCanceledException)
{
break; // 正常退出
}
catch (Exception ex)
{
Console.WriteLine($"[推流] 发送异常: {ex.Message}");
// 发生错误稍微停顿,防止死循环占用 CPU
await Task.Delay(1000, stoppingToken);
}
}
}
}

View File

@@ -1,140 +0,0 @@
using Microsoft.Extensions.Hosting;
using OpenCvSharp;
using SHH.Contracts;
using SHH.NetMQ;
namespace SHH.CameraSdk
{
/// <summary>
/// ZeroMQ 消息桥接服务(后台服务)。
/// 核心职责:订阅系统全局视频帧广播,将帧数据编码为标准协议格式后,通过 ZeroMQ 分发至外部系统。
/// 设计特性:
/// <para>1. 无侵入集成:通过订阅 GlobalStreamDispatcher 事件,无需修改原有帧处理流程。</para>
/// <para>2. 自动适配:支持动态增删设备,无需手动注册设备监听。</para>
/// <para>3. 安全隔离:帧数据深拷贝,避免跨线程内存访问冲突。</para>
/// </summary>
public class ZeroMqBridgeService : BackgroundService
{
#region --- ---
/// <summary>
/// ZeroMQ 分发服务器(用于广播帧数据至多个订阅端)
/// </summary>
private readonly DistributorServer _distributor;
/// <summary>
/// ZeroMQ 转发客户端(用于定向推送帧数据至指定目标)
/// </summary>
private readonly ForwarderClient _forwarder;
#endregion
#region --- ---
/// <summary>
/// 初始化 <see cref="ZeroMqBridgeService"/> 实例。
/// </summary>
/// <param name="distributor">ZeroMQ 分发服务器实例(通过 DI 注入)</param>
/// <param name="forwarder">ZeroMQ 转发客户端实例(通过 DI 注入)</param>
public ZeroMqBridgeService(DistributorServer distributor, ForwarderClient forwarder)
{
_distributor = distributor ?? throw new ArgumentNullException(nameof(distributor));
_forwarder = forwarder ?? throw new ArgumentNullException(nameof(forwarder));
}
#endregion
#region --- ---
/// <summary>
/// 启动后台服务,订阅全局视频帧广播。
/// </summary>
/// <param name="stoppingToken">服务停止令牌(用于优雅关闭)</param>
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
Console.WriteLine("[ZeroMQ Bridge] 正在连接全局视频帧广播总线...");
// 订阅全局帧广播事件:所有设备的帧数据都会触发该事件
// 无需手动绑定设备,动态增删的设备自动适配
GlobalStreamDispatcher.OnGlobalFrame += OnGlobalFrameReceived;
Console.WriteLine("[ZeroMQ Bridge] 全局总线连接成功!已开始监听所有设备帧数据。");
Console.WriteLine("[ZeroMQ Bridge] 说明:动态增删的设备会自动转发,无需重启服务。");
// 返回空任务:服务通过事件驱动,无需阻塞主线程
return Task.CompletedTask;
}
/// <summary>
/// 停止后台服务,取消事件订阅以避免内存泄漏。
/// </summary>
/// <param name="cancellationToken">取消令牌</param>
public override Task StopAsync(CancellationToken cancellationToken)
{
Console.WriteLine("[ZeroMQ Bridge] 正在停止服务,取消全局总线订阅...");
// 取消事件订阅:必须执行,否则会导致内存泄漏
GlobalStreamDispatcher.OnGlobalFrame -= OnGlobalFrameReceived;
Console.WriteLine("[ZeroMQ Bridge] 服务已安全停止。");
return base.StopAsync(cancellationToken);
}
#endregion
#region --- ---
/// <summary>
/// 全局帧数据接收回调(事件处理函数)。
/// 处理流程:安全检查 → 帧数据深拷贝 → JPG 编码 → 封装为标准协议 → ZeroMQ 分发。
/// </summary>
/// <param name="deviceId">产生该帧的设备唯一标识</param>
/// <param name="frame">智能帧对象(包含原始/处理后图像数据)</param>
private void OnGlobalFrameReceived(long deviceId, SmartFrame frame)
{
try
{
// 1. 安全校验:跳过空帧或已释放的帧
var sourceMat = frame.TargetMat ?? frame.InternalMat;
if (sourceMat == null || sourceMat.Empty() || sourceMat.IsDisposed)
return;
// 2. 深拷贝图像数据:避免跨线程访问冲突(原帧可能被其他模块异步释放)
using var safeMat = sourceMat.Clone();
// 3. 图像编码:将 OpenCV Mat 转换为 JPG 字节数组质量70平衡画质与性能
var jpgEncodeParams = new int[] { (int)ImwriteFlags.JpegQuality, 70 };
byte[] jpgBytes = safeMat.ImEncode(".jpg", jpgEncodeParams);
// 4. 封装为标准传输协议:使用 SHH.Contracts 中的 VideoPayload 统一格式
var videoPayload = new VideoPayload
{
CameraId = deviceId.ToString(), // 设备ID转为字符串兼容协议标准
CaptureTime = DateTime.Now, // 帧采集时间(当前时间)
DispatchTime = DateTime.Now, // 帧分发时间(当前时间)
OriginalWidth = safeMat.Width, // 图像原始宽度(编码后宽度)
OriginalHeight = safeMat.Height, // 图像原始高度(编码后高度)
OriginalImageBytes = jpgBytes // JPG 编码后的二进制数据
};
// 5. 传递订阅者ID保持与原帧的订阅者关联
if (frame.SubscriberIds.Any())
videoPayload.SubscriberIds.AddRange(frame.SubscriberIds);
// 6. ZeroMQ 分发:同时执行广播和定向推送(根据业务需求选择,可按需注释)
_distributor.Broadcast(videoPayload); // 广播给所有订阅端
_forwarder.Push(videoPayload); // 定向推送给指定目标
// 调试日志(生产环境建议注释,避免性能损耗)
// Console.WriteLine($"[ZeroMQ Bridge] 转发设备 {deviceId} 帧数据,大小:{jpgBytes.Length / 1024}KB");
}
catch (Exception ex)
{
// 异常隔离:单个帧处理失败不影响整体服务运行
Console.WriteLine($"[ZeroMQ Bridge] 帧转发失败设备ID{deviceId}{ex.Message}");
}
}
#endregion
}
}