增加了通过网络主动上报图像的支持
增加了指令维护通道的支持
This commit is contained in:
139
SHH.CameraService/Core/CmdClients/CommandClientWorker.cs
Normal file
139
SHH.CameraService/Core/CmdClients/CommandClientWorker.cs
Normal 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 会在底层连接成功后自动把消息推出去
|
||||
// 为了保险,对于多个 Endpoint,Dealer 默认是负载均衡发送的(轮询)。
|
||||
// 如果想让每个 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
46
SHH.CameraService/Core/CmdClients/CommandDispatcher.cs
Normal file
46
SHH.CameraService/Core/CmdClients/CommandDispatcher.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
30
SHH.CameraService/Core/CmdClients/ConnectedClient.cs
Normal file
30
SHH.CameraService/Core/CmdClients/ConnectedClient.cs
Normal 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}";
|
||||
}
|
||||
18
SHH.CameraService/Core/CmdClients/ICommandHandler.cs
Normal file
18
SHH.CameraService/Core/CmdClients/ICommandHandler.cs
Normal 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);
|
||||
}
|
||||
103
SHH.CameraService/Core/CmdClients/SyncCameraHandler.cs
Normal file
103
SHH.CameraService/Core/CmdClients/SyncCameraHandler.cs
Normal 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} 同步完成,策略已下发。");
|
||||
}
|
||||
}
|
||||
24
SHH.CameraService/Core/Configs/PushTargetConfig.cs
Normal file
24
SHH.CameraService/Core/Configs/PushTargetConfig.cs
Normal 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;
|
||||
}
|
||||
30
SHH.CameraService/Core/Configs/StreamTarget.cs
Normal file
30
SHH.CameraService/Core/Configs/StreamTarget.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
52
SHH.CameraService/Core/NetSenders/CameraEngineWorker.cs
Normal file
52
SHH.CameraService/Core/NetSenders/CameraEngineWorker.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
73
SHH.CameraService/Core/NetSenders/NetMQProtocolExtensions.cs
Normal file
73
SHH.CameraService/Core/NetSenders/NetMQProtocolExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
62
SHH.CameraService/Core/NetSenders/NetMqSenderWorker.cs
Normal file
62
SHH.CameraService/Core/NetSenders/NetMqSenderWorker.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
126
SHH.CameraService/Core/NetSenders/NetworkStreamingWorker.cs
Normal file
126
SHH.CameraService/Core/NetSenders/NetworkStreamingWorker.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
83
SHH.CameraService/Core/NetSenders/PipelineConfigurator.cs
Normal file
83
SHH.CameraService/Core/NetSenders/PipelineConfigurator.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using SHH.CameraSdk;
|
||||
|
||||
namespace SHH.CameraService;
|
||||
|
||||
/// <summary>
|
||||
/// 图像处理管道配置服务(基于责任链模式)
|
||||
/// <para>核心职责:</para>
|
||||
/// <para>1. 组装图像处理集群的执行顺序,形成 "缩放 → 增强" 的固定流程</para>
|
||||
/// <para>2. 将组装好的管道挂载到全局路由,统一接收驱动层输出的帧数据</para>
|
||||
/// <para>设计说明:</para>
|
||||
/// <para>- 采用责任链模式,支持动态扩展处理节点(如后续新增滤镜、裁剪等功能)</para>
|
||||
/// <para>- 依赖 IHostedService 生命周期,确保在应用启动时完成管道初始化</para>
|
||||
/// <para>- 与 GlobalPipelineRouter 强关联,是帧数据进入处理流程的唯一入口</para>
|
||||
public class PipelineConfigurator : IHostedService
|
||||
{
|
||||
#region --- 依赖注入字段 ---
|
||||
|
||||
/// <summary>
|
||||
/// 图像缩放集群实例(责任链第一节点)
|
||||
/// 功能:根据配置缩放帧分辨率、控制图像放大/缩小开关
|
||||
/// </summary>
|
||||
private readonly ImageScaleCluster _scale;
|
||||
|
||||
/// <summary>
|
||||
/// 图像增强集群实例(责任链第二节点)
|
||||
/// 功能:调整图像亮度、对比度等增强效果(基于 ProcessingConfigManager 配置)
|
||||
/// </summary>
|
||||
private readonly ImageEnhanceCluster _enhance;
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 构造函数 ---
|
||||
|
||||
/// <summary>
|
||||
/// 初始化管道配置服务实例
|
||||
/// </summary>
|
||||
/// <param name="scale">图像缩放集群(通过 DI 注入,已预设并行度和配置管理器)</param>
|
||||
/// <param name="enhance">图像增强集群(通过 DI 注入,已预设并行度和配置管理器)</param>
|
||||
|
||||
public PipelineConfigurator(ImageScaleCluster scale, ImageEnhanceCluster enhance)
|
||||
{
|
||||
_scale = scale;
|
||||
_enhance = enhance;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- IHostedService 实现 ---
|
||||
|
||||
/// <summary>
|
||||
/// 启动服务:组装责任链并挂载到全局路由
|
||||
/// <para>执行时机:应用启动时,在所有 Singleton 服务初始化完成后触发</para>
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">服务停止令牌(用于响应应用关闭信号)</param>
|
||||
/// <returns>异步任务(无返回值)</returns>
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 建立责任链关系:缩放集群处理完成后,将帧数据传递给增强集群
|
||||
// 设计逻辑:Scale 是入口节点,Enhance 是后续节点,可按需求插入更多处理节点
|
||||
_scale.SetNext(_enhance);
|
||||
|
||||
// 2. 将责任链入口挂载到全局路由:驱动层输出的所有帧数据都会进入该管道
|
||||
// 关键作用:统一帧数据处理入口,屏蔽驱动层与处理层的直接依赖
|
||||
GlobalPipelineRouter.SetProcessor(_scale);
|
||||
|
||||
// 启动日志:打印管道组装结果,便于运维排查
|
||||
Console.WriteLine("[Pipeline] 图像处理链组装完成: ImageScaleCluster -> ImageEnhanceCluster");
|
||||
Console.WriteLine("[Pipeline] 提示:帧数据将按 '缩放 → 增强' 顺序处理,可通过 GlobalPipelineRouter 调整流程");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止服务:空实现(无资源需要释放)
|
||||
/// <para>说明:图像处理集群的资源释放由各自的 Dispose 方法管理,此处无需额外操作</para>
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">服务停止令牌</param>
|
||||
/// <returns>空异步任务</returns>
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
#endregion
|
||||
}
|
||||
40
SHH.CameraService/Core/NetSenders/VideoDataChannel.cs
Normal file
40
SHH.CameraService/Core/NetSenders/VideoDataChannel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
79
SHH.CameraService/Core/ParentProcessSentinel.cs
Normal file
79
SHH.CameraService/Core/ParentProcessSentinel.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SHH.CameraSdk;
|
||||
|
||||
namespace SHH.CameraService;
|
||||
|
||||
public class ParentProcessSentinel : BackgroundService
|
||||
{
|
||||
private readonly ServiceConfig _config;
|
||||
private readonly IHostApplicationLifetime _lifetime;
|
||||
private readonly ILogger<ParentProcessSentinel> _logger;
|
||||
|
||||
public ParentProcessSentinel(
|
||||
ServiceConfig config,
|
||||
IHostApplicationLifetime lifetime,
|
||||
ILogger<ParentProcessSentinel> logger)
|
||||
{
|
||||
_config = config;
|
||||
_lifetime = lifetime;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
int pid = _config.ParentPid;
|
||||
|
||||
// 如果 PID 为 0 或负数,说明不需要守护(可能是手动启动调试)
|
||||
if (pid <= 0)
|
||||
{
|
||||
_logger.LogInformation("未指定有效的父进程 PID,守护模式已禁用。");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation($"父进程守护已启动,正在监控 PID: {pid}");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (!IsParentRunning(pid))
|
||||
{
|
||||
_logger.LogWarning($"[ALERT] 检测到父进程 (PID:{pid}) 已退出!正在终止当前服务...");
|
||||
|
||||
// 触发程序优雅退出
|
||||
_lifetime.StopApplication();
|
||||
|
||||
// 强制跳出循环
|
||||
break;
|
||||
}
|
||||
|
||||
// 每 2 秒检查一次,避免 CPU 浪费
|
||||
await Task.Delay(2000, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsParentRunning(int pid)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 尝试获取进程对象
|
||||
var process = Process.GetProcessById(pid);
|
||||
|
||||
// 检查是否已退出
|
||||
if (process.HasExited) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// GetProcessById 在找不到 PID 时会抛出 ArgumentException
|
||||
// 说明进程已经不存在了
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "检查父进程状态时发生未知错误,默认为存活");
|
||||
return true; // 发生未知错误时,保守起见认为它还活着
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user