新通讯图像协议对接成功
This commit is contained in:
@@ -1,194 +0,0 @@
|
||||
//using System.Text;
|
||||
//using MessagePack;
|
||||
//using Microsoft.Extensions.Hosting;
|
||||
//using NetMQ;
|
||||
//using NetMQ.Monitoring; // ★ 1. 必须引用 Monitoring 命名空间
|
||||
//using NetMQ.Sockets;
|
||||
//using SHH.CameraSdk;
|
||||
//using SHH.Contracts;
|
||||
|
||||
//namespace SHH.CameraService;
|
||||
|
||||
//public class CommandClientWorker : BackgroundService
|
||||
//{
|
||||
// private readonly ServiceConfig _config;
|
||||
// private readonly CommandDispatcher _dispatcher;
|
||||
// private readonly InterceptorPipeline _pipeline;
|
||||
|
||||
// // 管理多个 Socket
|
||||
// private readonly List<DealerSocket> _sockets = new();
|
||||
|
||||
// // ★ 2. 新增:保存 Monitor 列表,防止被 GC 回收
|
||||
// private readonly List<NetMQMonitor> _monitors = new();
|
||||
|
||||
// private NetMQPoller? _poller;
|
||||
|
||||
// public CommandClientWorker(
|
||||
// ServiceConfig config,
|
||||
// CommandDispatcher dispatcher,
|
||||
// InterceptorPipeline pipeline)
|
||||
// {
|
||||
// _config = config;
|
||||
// _dispatcher = dispatcher;
|
||||
// _pipeline = pipeline;
|
||||
// }
|
||||
|
||||
// protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
// {
|
||||
// await Task.Yield();
|
||||
|
||||
// if (!_config.ShouldConnect || _config.CommandEndpoints.Count == 0) return;
|
||||
|
||||
// _poller = new NetMQPoller();
|
||||
|
||||
// // -------------------------------------------------------------
|
||||
// // 核心修改区:建立连接并挂载监控器
|
||||
// // -------------------------------------------------------------
|
||||
// foreach (var ep in _config.CommandEndpoints)
|
||||
// {
|
||||
// try
|
||||
// {
|
||||
// var socket = new DealerSocket();
|
||||
// socket.Options.Identity = Encoding.UTF8.GetBytes(_config.AppId);
|
||||
|
||||
// var monitorUrl = $"inproc://monitor_{Guid.NewGuid():N}";
|
||||
// var monitor = new NetMQMonitor(socket, monitorUrl, SocketEvents.Connected);
|
||||
|
||||
// monitor.Connected += async (s, args) =>
|
||||
// {
|
||||
// Console.WriteLine($"[指令] 网络连接建立: {ep.Uri} -> 正在补发注册包...");
|
||||
// await SendRegisterAsync(socket);
|
||||
// };
|
||||
|
||||
// // ★★★ 修正点:使用 AttachToPoller 代替 Add ★★★
|
||||
// // 错误写法: _poller.Add(monitor);
|
||||
// monitor.AttachToPoller(_poller);
|
||||
|
||||
// // 依然需要保存引用,防止被 GC 回收
|
||||
// _monitors.Add(monitor);
|
||||
|
||||
// socket.Connect(ep.Uri);
|
||||
// socket.ReceiveReady += OnSocketReceiveReady;
|
||||
|
||||
// _sockets.Add(socket);
|
||||
// _poller.Add(socket);
|
||||
|
||||
// Console.WriteLine($"[指令] 通道初始化完成: {ep.Uri} (带自动重连监控)");
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// Console.WriteLine($"[指令] 连接初始化异常: {ex.Message}");
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (_sockets.Count == 0) return;
|
||||
|
||||
// // =================================================================
|
||||
// // 6. 绑定 ACK 逻辑 (保持不变)
|
||||
// // =================================================================
|
||||
// _dispatcher.OnResponseReady += async (result) =>
|
||||
// {
|
||||
// try
|
||||
// {
|
||||
// byte[] resultBytes = MessagePackSerializer.Serialize(result);
|
||||
// var ctx = await _pipeline.ExecuteSendAsync(ProtocolHeaders.CommandResult, resultBytes);
|
||||
|
||||
// if (ctx != null)
|
||||
// {
|
||||
// foreach (var socket in _sockets)
|
||||
// {
|
||||
// socket.SendMoreFrame(ctx.Protocol).SendFrame(ctx.Data);
|
||||
// }
|
||||
// Console.WriteLine($"[指令] ACK 已广播 (ID: {result.RequestId})");
|
||||
// }
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// Console.WriteLine($"[ACK] 发送失败: {ex.Message}");
|
||||
// }
|
||||
// };
|
||||
|
||||
// // =================================================================
|
||||
// // 7. 启动 Poller
|
||||
// // =================================================================
|
||||
// // 注意:我们不需要手动发第一次注册包了,
|
||||
// // 因为 Poller 启动后,底层 TCP 会建立连接,从而触发 monitor.Connected 事件,
|
||||
// // 事件里会自动发送注册包。这就是“自动档”的好处。
|
||||
// _poller.RunAsync();
|
||||
|
||||
// // 阻塞直到取消
|
||||
// while (!stoppingToken.IsCancellationRequested)
|
||||
// {
|
||||
// await Task.Delay(1000, stoppingToken);
|
||||
// }
|
||||
|
||||
// // 清理
|
||||
// _poller.Stop();
|
||||
// _poller.Dispose();
|
||||
// foreach (var m in _monitors) m.Dispose(); // 释放监控器
|
||||
// foreach (var s in _sockets) s.Dispose();
|
||||
// }
|
||||
|
||||
// // =================================================================
|
||||
// // ★ 8. 抽离出的注册包发送逻辑 (供 Monitor 调用)
|
||||
// // =================================================================
|
||||
// private async Task SendRegisterAsync(DealerSocket targetSocket)
|
||||
// {
|
||||
// try
|
||||
// {
|
||||
// var registerPayload = new RegisterPayload
|
||||
// {
|
||||
// Protocol = ProtocolHeaders.ServerRegister,
|
||||
// InstanceId = _config.AppId,
|
||||
// ProcessId = Environment.ProcessId,
|
||||
// Version = "1.0.0",
|
||||
// ServerIp = "127.0.0.1", // 建议优化:获取本机真实IP
|
||||
// WebApiPort = _config.BasePort,
|
||||
// StartTime = DateTime.Now
|
||||
// };
|
||||
|
||||
// byte[] regData = MessagePackSerializer.Serialize(registerPayload);
|
||||
|
||||
// // 执行拦截器
|
||||
// var ctx = await _pipeline.ExecuteSendAsync(ProtocolHeaders.ServerRegister, regData);
|
||||
|
||||
// if (ctx != null)
|
||||
// {
|
||||
// // 直接向触发事件的那个 Socket 发送
|
||||
// // DealerSocket 允许在连接未完全就绪时 Send,它会缓存直到网络通畅
|
||||
// targetSocket.SendMoreFrame(ctx.Protocol).SendFrame(ctx.Data);
|
||||
// // Console.WriteLine($"[指令] 身份注册包已推入队列: {targetSocket.Options.Identity}");
|
||||
// }
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// Console.WriteLine($"[指令] 注册包发送失败: {ex.Message}");
|
||||
// }
|
||||
// }
|
||||
|
||||
// private async void OnSocketReceiveReady(object? sender, NetMQSocketEventArgs e)
|
||||
// {
|
||||
// NetMQMessage incomingMsg = new NetMQMessage();
|
||||
// if (e.Socket.TryReceiveMultipartMessage(ref incomingMsg))
|
||||
// {
|
||||
// if (incomingMsg.FrameCount >= 2)
|
||||
// {
|
||||
// try
|
||||
// {
|
||||
// string rawProtocol = incomingMsg[0].ConvertToString();
|
||||
// byte[] rawData = incomingMsg[1].ToByteArray();
|
||||
|
||||
// var ctx = await _pipeline.ExecuteReceiveAsync(rawProtocol, rawData);
|
||||
// if (ctx != null)
|
||||
// {
|
||||
// await _dispatcher.DispatchAsync(ctx.Protocol, ctx.Data);
|
||||
// }
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// Console.WriteLine($"[指令] 处理异常: {ex.Message}");
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -1,91 +0,0 @@
|
||||
// 文件: Core\CmdClients\CommandDispatcher.cs
|
||||
|
||||
using MessagePack;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SHH.Contracts;
|
||||
using System.Text;
|
||||
|
||||
namespace SHH.CameraService;
|
||||
|
||||
public class CommandDispatcher
|
||||
{
|
||||
// 1. 注入路由表
|
||||
private readonly Dictionary<string, ICommandHandler> _handlers;
|
||||
|
||||
// 2. 定义回执事件 (ACK闭环的核心)
|
||||
public event Action<CommandResult>? OnResponseReady;
|
||||
|
||||
// 3. 构造函数:注入所有 Handler
|
||||
public CommandDispatcher(IEnumerable<ICommandHandler> handlers)
|
||||
{
|
||||
// 将注入的 Handler 转换为字典,Key = ActionName (e.g. "SyncCamera")
|
||||
_handlers = handlers.ToDictionary(h => h.ActionName, h => h, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public async Task DispatchAsync(string protocol, byte[] data)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 只处理 COMMAND 协议
|
||||
if (protocol != ProtocolHeaders.Command) return;
|
||||
|
||||
// 反序列化信封
|
||||
var envelope = MessagePackSerializer.Deserialize<CommandPayload>(data);
|
||||
if (envelope == null) return;
|
||||
|
||||
string cmdCode = envelope.CmdCode; // e.g. "SyncCamera"
|
||||
Console.WriteLine($"[分发] 收到指令: {cmdCode} (ID: {envelope.RequestId})");
|
||||
|
||||
bool isSuccess = true;
|
||||
string message = "OK";
|
||||
|
||||
// --- 路由匹配逻辑 ---
|
||||
if (_handlers.TryGetValue(cmdCode, out var handler))
|
||||
{
|
||||
try
|
||||
{
|
||||
// 数据适配:你的 Handler 需要 JToken
|
||||
// 如果 envelope.JsonParams 是空的,传个空对象防止报错
|
||||
var jsonStr = string.IsNullOrEmpty(envelope.JsonParams) ? "{}" : envelope.JsonParams;
|
||||
var token = JToken.Parse(jsonStr);
|
||||
|
||||
// ★★★ 核心:调用 SyncCameraHandler.ExecuteAsync ★★★
|
||||
await handler.ExecuteAsync(token);
|
||||
|
||||
message = $"Executed {cmdCode}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
isSuccess = false;
|
||||
message = $"Handler Error: {ex.Message}";
|
||||
Console.WriteLine($"[业务异常] {message}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
isSuccess = false;
|
||||
message = $"No handler found for {cmdCode}";
|
||||
Console.WriteLine($"[警告] {message}");
|
||||
}
|
||||
|
||||
// --- ACK 闭环逻辑 ---
|
||||
if (envelope.RequireAck)
|
||||
{
|
||||
var result = new CommandResult
|
||||
{
|
||||
Protocol = ProtocolHeaders.CommandResult,
|
||||
RequestId = envelope.RequestId, // 必须带回 ID
|
||||
Success = isSuccess,
|
||||
Message = message,
|
||||
Timestamp = DateTime.Now.Ticks
|
||||
};
|
||||
// 触发事件
|
||||
OnResponseReady?.Invoke(result);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Dispatcher] 致命错误: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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}";
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SHH.CameraSdk;
|
||||
using SHH.Contracts;
|
||||
|
||||
namespace SHH.CameraService
|
||||
{
|
||||
/// <summary>
|
||||
/// 移除设备指令处理器
|
||||
/// </summary>
|
||||
public class RemoveCameraHandler : ICommandHandler
|
||||
{
|
||||
private readonly CameraManager _cameraManager;
|
||||
|
||||
/// <summary>
|
||||
/// 指令名称
|
||||
/// </summary>
|
||||
public string ActionName => ProtocolHeaders.Remove_Camera;
|
||||
|
||||
/// <summary>
|
||||
/// 构造函数
|
||||
/// </summary>
|
||||
/// <param name="cameraManager"></param>
|
||||
public RemoveCameraHandler(CameraManager cameraManager)
|
||||
{
|
||||
_cameraManager = cameraManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理指令
|
||||
/// </summary>
|
||||
/// <param name="payload"></param>
|
||||
public async Task ExecuteAsync(JToken payload)
|
||||
{
|
||||
long deviceId = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// 1. 增强型 ID 解析
|
||||
if (payload.Type == JTokenType.Object)
|
||||
{
|
||||
// 兼容大小写不敏感的解析
|
||||
var idToken = payload["Id"] ?? payload["id"];
|
||||
if (idToken != null) deviceId = idToken.Value<long>();
|
||||
}
|
||||
else if (payload.Type == JTokenType.Integer || payload.Type == JTokenType.String)
|
||||
{
|
||||
// 兼容字符串形式的 ID
|
||||
long.TryParse(payload.ToString(), out deviceId);
|
||||
}
|
||||
|
||||
if (deviceId <= 0)
|
||||
{
|
||||
Console.WriteLine($"[{ActionName}] 收到无效指令: ID解析失败 ({payload})");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 预检查
|
||||
var device = _cameraManager.GetDevice(deviceId);
|
||||
if (device == null)
|
||||
{
|
||||
Console.WriteLine($"[{ActionName}] 设备 {deviceId} 已经不在管理池中,无需操作。");
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 安全移除
|
||||
// 这里建议增加审计日志,记录谁触发了删除(如果协议里有用户信息的话)
|
||||
device.AddAuditLog("收到远程指令:彻底移除设备");
|
||||
Console.WriteLine($"[{ActionName}] 正在安全移除设备: {deviceId} ({device.Config.Name})");
|
||||
|
||||
// CameraManager 内部会:StopAsync -> DisposeAsync -> TryRemove -> SaveChanges
|
||||
await _cameraManager.RemoveDeviceAsync(deviceId);
|
||||
|
||||
Console.WriteLine($"[{ActionName}] 设备 {deviceId} 已彻底清理并从持久化库中移除。");
|
||||
|
||||
// 4. (可选) 此处可以调用 CommandDispatcher 发送 Success ACK
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 捕获异常,防止影响全局 Socket 轮询
|
||||
Console.WriteLine($"[{ActionName}] 移除设备 {deviceId} 过程中发生致命错误: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SHH.CameraSdk;
|
||||
using SHH.Contracts;
|
||||
|
||||
namespace SHH.CameraService;
|
||||
|
||||
/// <summary>
|
||||
/// 同步设备配置处理器
|
||||
/// </summary>
|
||||
public class SyncCameraHandler : ICommandHandler
|
||||
{
|
||||
private readonly CameraManager _cameraManager;
|
||||
|
||||
/// <summary>
|
||||
/// 命令名称
|
||||
/// </summary>
|
||||
public string ActionName => ProtocolHeaders.Sync_Camera;
|
||||
|
||||
/// <summary>
|
||||
/// 构造函数
|
||||
/// </summary>
|
||||
/// <param name="cameraManager"></param>
|
||||
public SyncCameraHandler(CameraManager cameraManager)
|
||||
{
|
||||
_cameraManager = cameraManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行处理
|
||||
/// </summary>
|
||||
/// <param name="payload"></param>
|
||||
/// <returns></returns>
|
||||
public async Task ExecuteAsync(JToken payload)
|
||||
{
|
||||
// 1. 反序列化配置 DTO
|
||||
var dto = payload.ToObject<CameraConfigDto>();
|
||||
if (dto == null) return;
|
||||
|
||||
// 2. 尝试获取现有设备
|
||||
var device = _cameraManager.GetDevice(dto.Id);
|
||||
|
||||
if (device != null)
|
||||
{
|
||||
// =========================================================
|
||||
// 场景 A: 设备已存在 -> 执行智能更新 (Smart Update)
|
||||
// =========================================================
|
||||
Console.WriteLine($"[Sync] 更新设备配置: {dto.Id} ({dto.Name})");
|
||||
|
||||
// 将全量配置映射为部分更新 DTO
|
||||
var updateDto = new DeviceUpdateDto
|
||||
{
|
||||
// --- 冷更新参数 (变更会触发重启) ---
|
||||
IpAddress = dto.IpAddress,
|
||||
Port = dto.Port,
|
||||
Username = dto.Username,
|
||||
Password = dto.Password,
|
||||
ChannelIndex = dto.ChannelIndex,
|
||||
Brand = dto.Brand,
|
||||
RtspPath = dto.RtspPath,
|
||||
RenderHandle = dto.RenderHandle, // long 类型直接赋值
|
||||
|
||||
// --- 热更新参数 (变更立即生效) ---
|
||||
Name = dto.Name,
|
||||
Location = dto.Location,
|
||||
StreamType = dto.StreamType,
|
||||
|
||||
MainboardIp = dto.MainboardIp,
|
||||
MainboardPort = dto.MainboardPort,
|
||||
|
||||
// --- 图像处理参数 (热更新) ---
|
||||
AllowCompress = dto.AllowCompress,
|
||||
AllowExpand = dto.AllowExpand,
|
||||
TargetResolution = dto.TargetResolution,
|
||||
EnhanceImage = dto.EnhanceImage,
|
||||
UseGrayscale = dto.UseGrayscale
|
||||
};
|
||||
|
||||
// 调用 Manager 的核心更新逻辑 (它会自动判断是 Stop->Start 还是直接应用)
|
||||
await _cameraManager.UpdateDeviceConfigAsync(dto.Id, updateDto);
|
||||
}
|
||||
else
|
||||
{
|
||||
// =========================================================
|
||||
// 场景 B: 设备不存在 -> 执行新增 (Add New)
|
||||
// =========================================================
|
||||
Console.WriteLine($"[Sync] 新增设备: {dto.Id} ({dto.Name})");
|
||||
|
||||
// 构造全新的设备配置
|
||||
var newConfig = new VideoSourceConfig
|
||||
{
|
||||
Id = dto.Id,
|
||||
Name = dto.Name,
|
||||
Brand = (DeviceBrand)dto.Brand, // int -> Enum 强转
|
||||
IpAddress = dto.IpAddress,
|
||||
Port = dto.Port,
|
||||
Username = dto.Username,
|
||||
Password = dto.Password,
|
||||
ChannelIndex = dto.ChannelIndex,
|
||||
StreamType = dto.StreamType,
|
||||
RtspPath = dto.RtspPath,
|
||||
MainboardIp = dto.MainboardIp,
|
||||
MainboardPort = dto.MainboardPort,
|
||||
RenderHandle = (IntPtr)dto.RenderHandle, // long -> IntPtr 转换
|
||||
ConnectionTimeoutMs = 5000 // 默认超时
|
||||
};
|
||||
|
||||
// 添加到管理器池
|
||||
_cameraManager.AddDevice(newConfig);
|
||||
|
||||
// 重新获取引用以进行后续操作
|
||||
device = _cameraManager.GetDevice(dto.Id);
|
||||
|
||||
}
|
||||
|
||||
// ★★★ 核心修复:统一处理“运行意图” ★★★
|
||||
if (device != null)
|
||||
{
|
||||
// 将 DTO 的立即执行标志直接同步给设备的运行意图
|
||||
device.IsRunning = dto.ImmediateExecution;
|
||||
|
||||
if (dto.ImmediateExecution)
|
||||
{
|
||||
// 情况 1: 收到“启动”指令
|
||||
if (!device.IsOnline) // 只有没在线时才点火
|
||||
{
|
||||
Console.WriteLine($"[Sync] 指令:立即启动设备 {dto.Id}");
|
||||
_ = device.StartAsync();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 情况 2: 收到“停止”指令 (即 ImmediateExecution = false)
|
||||
if (device.IsOnline) // 只有在线时才熄火
|
||||
{
|
||||
Console.WriteLine($"[Sync] 指令:立即停止设备 {dto.Id}");
|
||||
_ = device.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// 3. 处理自动订阅策略 (Auto Subscriptions)
|
||||
// =========================================================
|
||||
// 无论新增还是更新,都确保订阅策略是最新的
|
||||
if (device != null && dto.AutoSubscriptions != null)
|
||||
{
|
||||
var controller = device.Controller;
|
||||
if (controller != null)
|
||||
{
|
||||
foreach (var sub in dto.AutoSubscriptions)
|
||||
{
|
||||
// 如果没有 AppId,生成一个临时的(通常 Dashboard 会下发固定的 AppId)
|
||||
string appId = string.IsNullOrWhiteSpace(sub.AppId)
|
||||
? $"AUTO_{Guid.NewGuid().ToString("N")[..8]}"
|
||||
: sub.AppId;
|
||||
|
||||
// 构造流控需求
|
||||
var req = new FrameRequirement
|
||||
{
|
||||
AppId = appId,
|
||||
TargetFps = sub.TargetFps,
|
||||
Type = (SubscriptionType)sub.Type, // int -> Enum
|
||||
Memo = sub.Memo ?? "Sync Auto",
|
||||
|
||||
// 自动订阅通常不包含具体的 Handle 或 SavePath,除非协议里带了
|
||||
// 如果需要支持网络转发,这里可以扩展映射 sub.TargetIp 等
|
||||
Handle = "",
|
||||
SavePath = ""
|
||||
};
|
||||
|
||||
// 注册到帧控制器
|
||||
controller.Register(req);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
66
SHH.CameraService/Core/CommandDispatcher.cs
Normal file
66
SHH.CameraService/Core/CommandDispatcher.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SHH.Contracts.Grpc;
|
||||
|
||||
namespace SHH.CameraService;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC 指令分发器
|
||||
/// 职责:接收从 GrpcCommandReceiverWorker 传入的 Proto 消息,解析参数并路由至具体的 Handler。
|
||||
/// </summary>
|
||||
public class CommandDispatcher
|
||||
{
|
||||
private readonly Dictionary<string, ICommandHandler> _handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 构造函数:通过 DI 注入所有已注册的处理器 (SyncCameraHandler, RemoveCameraHandler 等)
|
||||
/// </summary>
|
||||
public CommandDispatcher(IEnumerable<ICommandHandler> handlers)
|
||||
{
|
||||
// 将处理器列表转换为字典,方便 O(1) 查询
|
||||
_handlers = handlers.ToDictionary(
|
||||
h => h.ActionName,
|
||||
h => h,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行指令分发
|
||||
/// </summary>
|
||||
/// <param name="protoMsg">从 gRPC Server Streaming 接收到的原始 Proto 指令对象</param>
|
||||
public async Task DispatchAsync(CommandPayloadProto protoMsg)
|
||||
{
|
||||
if (protoMsg == null) return;
|
||||
|
||||
string cmdCode = protoMsg.CmdCode; // 例如 "Sync_Camera"
|
||||
Console.WriteLine($"[Dispatcher] 收到远程指令: {cmdCode}, 请求ID: {protoMsg.RequestId}");
|
||||
|
||||
try
|
||||
{
|
||||
// 1. 查找对应的处理器
|
||||
if (_handlers.TryGetValue(cmdCode, out var handler))
|
||||
{
|
||||
// 2. 参数转换:将 Proto 里的 JSON 字符串转换为原有 Handler 需要的 JToken
|
||||
// 这样你之前的 SyncCameraHandler 代码不需要做任何逻辑改动即可直接复用
|
||||
var jsonStr = string.IsNullOrWhiteSpace(protoMsg.JsonParams) ? "{}" : protoMsg.JsonParams;
|
||||
var token = JToken.Parse(jsonStr);
|
||||
|
||||
// 3. 调用具体业务执行
|
||||
await handler.ExecuteAsync(token);
|
||||
|
||||
Console.WriteLine($"[Dispatcher] 指令 {cmdCode} 执行成功。");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[Dispatcher Warning] 未找到指令处理器: {cmdCode}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Dispatcher Error] 执行指令 {cmdCode} 异常: {ex.Message}");
|
||||
}
|
||||
|
||||
// 注意:关于 ACK (require_ack)
|
||||
// 在 NetMQ 时代需要手动回发结果,在 gRPC Server Streaming 模式下,
|
||||
// 建议通过 Unary RPC (例如另设一个 ReportCommandResult 方法) 异步上报执行结果。
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SHH.CameraSdk;
|
||||
using SHH.Contracts;
|
||||
using SHH.Contracts.Grpc;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace SHH.CameraService;
|
||||
|
||||
/// <summary>
|
||||
/// 设备状态监控工作者 (gRPC 版)
|
||||
/// 职责:监控相机状态并在状态变更或心跳周期内,通过 gRPC 批量上报至所有配置的端点
|
||||
/// </summary>
|
||||
public class DeviceStateMonitorWorker : BackgroundService
|
||||
{
|
||||
private readonly CameraManager _manager;
|
||||
private readonly ServiceConfig _config;
|
||||
private readonly ILogger<DeviceStateMonitorWorker> _logger;
|
||||
|
||||
// 状态存储:CameraId -> 状态载荷
|
||||
private readonly ConcurrentDictionary<string, StatusEventPayload> _stateStore = new();
|
||||
|
||||
private volatile bool _isDirty = false;
|
||||
private long _lastSendTick = 0;
|
||||
|
||||
public DeviceStateMonitorWorker(
|
||||
CameraManager manager,
|
||||
ServiceConfig config,
|
||||
ILogger<DeviceStateMonitorWorker> logger)
|
||||
{
|
||||
_manager = manager;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// 1. 初始化本地状态缓存
|
||||
foreach (var dev in _manager.GetAllDevices())
|
||||
{
|
||||
UpdateLocalState(dev.Id, false, "Service Init");
|
||||
}
|
||||
|
||||
// 2. 订阅 SDK 状态变更事件
|
||||
_manager.OnDeviceStatusChanged += OnSdkStatusChanged;
|
||||
|
||||
_logger.LogInformation("[StatusWorker] gRPC 状态上报已启动,配置节点数: {Count}", _config.CommandEndpoints.Count);
|
||||
|
||||
// 3. 定时循环 (1秒1次检查)
|
||||
var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
|
||||
try
|
||||
{
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
await CheckAndBroadcastAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { /* 正常退出 */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[StatusWorker] 运行异常");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_manager.OnDeviceStatusChanged -= OnSdkStatusChanged;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SDK 状态变更回调
|
||||
/// </summary>
|
||||
private void OnSdkStatusChanged(long deviceId, bool isOnline, string reason)
|
||||
{
|
||||
UpdateLocalState(deviceId, isOnline, reason);
|
||||
_isDirty = true;
|
||||
}
|
||||
|
||||
private void UpdateLocalState(long deviceId, bool isOnline, string reason)
|
||||
{
|
||||
var evt = new StatusEventPayload
|
||||
{
|
||||
CameraId = deviceId.ToString(),
|
||||
IsOnline = isOnline,
|
||||
Reason = reason,
|
||||
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
||||
};
|
||||
_stateStore[deviceId.ToString()] = evt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行广播逻辑
|
||||
/// </summary>
|
||||
private async Task CheckAndBroadcastAsync(CancellationToken ct)
|
||||
{
|
||||
long now = Environment.TickCount64;
|
||||
|
||||
// 策略: 有变更(Dirty) 或 超过5秒(强制心跳)
|
||||
bool shouldSend = _isDirty || (now - _lastSendTick > 5000);
|
||||
|
||||
if (shouldSend && _config.CommandEndpoints.Any())
|
||||
{
|
||||
// 1. 构建 gRPC 请求包
|
||||
var request = new StatusBatchRequest
|
||||
{
|
||||
Protocol = "GRPC",
|
||||
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
||||
};
|
||||
|
||||
// 转换内存中的状态快照为 Protobuf 列表
|
||||
foreach (var item in _stateStore.Values)
|
||||
{
|
||||
request.Items.Add(new StatusEventItem
|
||||
{
|
||||
CameraId = item.CameraId,
|
||||
IsOnline = item.IsOnline,
|
||||
Reason = item.Reason,
|
||||
Timestamp = item.Timestamp
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 遍历所有端点进行发送
|
||||
foreach (var endpoint in _config.CommandEndpoints)
|
||||
{
|
||||
try
|
||||
{
|
||||
string grpcUrl = endpoint.Uri.Replace("tcp://", "http://").Trim();
|
||||
|
||||
// --- 增加以下诊断代码 ---
|
||||
using var channel = GrpcChannel.ForAddress(grpcUrl);
|
||||
var client = new GatewayProvider.GatewayProviderClient(channel);
|
||||
|
||||
// 获取 gRPC 内部生成的服务全称
|
||||
// 这就是客户端尝试调用的真实路径:/包名.服务名/方法名
|
||||
var methodName = "ReportStatusBatch";
|
||||
var serviceName = client.GetType().DeclaringType?.Name ?? "Unknown";
|
||||
|
||||
_logger.LogInformation("[gRPC Debug] 准备调用端点: {Url}", grpcUrl);
|
||||
_logger.LogInformation("[gRPC Debug] 客户端契约服务名: {Service}", serviceName);
|
||||
|
||||
// 执行调用
|
||||
var response = await client.ReportStatusBatchAsync(request,
|
||||
deadline: DateTime.UtcNow.AddSeconds(2), cancellationToken: ct);
|
||||
|
||||
if (response.Success)
|
||||
{
|
||||
_logger.LogInformation("[gRPC Success] 上报成功");
|
||||
_isDirty = false;
|
||||
_lastSendTick = Environment.TickCount64;
|
||||
}
|
||||
}
|
||||
catch (RpcException ex)
|
||||
{
|
||||
// 这里是关键:打印 RpcException 的详细状态
|
||||
_logger.LogError("[gRPC Error] StatusCode: {Code}, Detail: {Detail}", ex.StatusCode, ex.Status.Detail);
|
||||
|
||||
// 如果是 Unimplemented,通常意味着路径不对
|
||||
if (ex.StatusCode == StatusCode.Unimplemented)
|
||||
{
|
||||
_logger.LogError("[gRPC Fix] 请检查服务端是否注册了名为 'GatewayProvider' 的服务,且其 package 声明与客户端一致。");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("[gRPC Fatal] 非 RPC 异常: {Msg}", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
//using MessagePack;
|
||||
//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());
|
||||
|
||||
// // ★★★ 修复点:在序列化之前,手动更新 Payload 的标志位 ★★★
|
||||
// payload.HasOriginalImage = (payload.OriginalImageBytes != null && payload.OriginalImageBytes.Length > 0);
|
||||
// payload.HasTargetImage = (payload.TargetImageBytes != null && payload.TargetImageBytes.Length > 0);
|
||||
|
||||
// // Frame 1: Metadata (MessagePack)
|
||||
// byte[] metaBytes = MessagePackSerializer.Serialize(payload);
|
||||
// msg.Append(metaBytes);
|
||||
|
||||
// // 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 < 2) 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);
|
||||
|
||||
// // [新代码] 直接从二进制还原
|
||||
// // ToByteArray() 虽然会产生一次拷贝,但对于 Metadata 这种小数据影响微乎其微
|
||||
// // 相比 JSON 解析 String 的开销,这已经非常快了
|
||||
// var payload = MessagePackSerializer.Deserialize<VideoPayload>(msg[1].ToByteArray());
|
||||
// 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;
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -1,93 +0,0 @@
|
||||
using Google.Protobuf;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SHH.Contracts.Grpc;
|
||||
|
||||
namespace SHH.CameraService;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC 视频流发送工作者
|
||||
/// 职责:监听特定的 StreamTarget 队列,建立 gRPC 客户端流并持续推送图片
|
||||
/// </summary>
|
||||
public class GrpcSenderWorker : BackgroundService
|
||||
{
|
||||
private readonly StreamTarget _target;
|
||||
private readonly ILogger<GrpcSenderWorker> _logger;
|
||||
private readonly string _grpcUrl;
|
||||
|
||||
public GrpcSenderWorker(StreamTarget target, ILogger<GrpcSenderWorker> logger)
|
||||
{
|
||||
_target = target;
|
||||
_logger = logger;
|
||||
|
||||
// 自动适配地址:将配置的 tcp://localhost:9001 转换为 http://localhost:9001
|
||||
// 并且严格使用你验证成功的 localhost
|
||||
_grpcUrl = _target.Config.Endpoint.Replace("tcp://", "http://");
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation($"[gRPC Worker] 启动。目标: {_target.Config.Name}, 地址: {_grpcUrl}");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. 建立通道
|
||||
using var channel = GrpcChannel.ForAddress(_grpcUrl);
|
||||
var client = new GatewayProvider.GatewayProviderClient(channel);
|
||||
|
||||
// 2. 开启客户端流 (UploadVideoStream 是在 proto 中定义的)
|
||||
using var call = client.UploadVideoStream(cancellationToken: stoppingToken);
|
||||
|
||||
_logger.LogInformation($"[gRPC Worker] 已开启视频推送流: {_target.Config.Name}");
|
||||
|
||||
// 3. 核心搬运循环:从内存队列 (Channel) 读取数据
|
||||
await foreach (var payload in _target.Channel.Reader.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
// 将业务 DTO 转换为 gRPC 原生 Request
|
||||
var request = new VideoFrameRequest
|
||||
{
|
||||
CameraId = payload.CameraId ?? "Unknown",
|
||||
CaptureTimestamp = payload.CaptureTimestamp,
|
||||
OriginalWidth = payload.OriginalWidth,
|
||||
OriginalHeight = payload.OriginalHeight,
|
||||
HasOriginalImage = payload.HasOriginalImage,
|
||||
HasTargetImage = payload.HasTargetImage,
|
||||
|
||||
// ★ 核心:将 byte[] 转换为 gRPC 的 ByteString (高性能)
|
||||
OriginalImageBytes = payload.OriginalImageBytes != null
|
||||
? ByteString.CopyFrom(payload.OriginalImageBytes)
|
||||
: ByteString.Empty,
|
||||
|
||||
TargetImageBytes = payload.TargetImageBytes != null
|
||||
? ByteString.CopyFrom(payload.TargetImageBytes)
|
||||
: ByteString.Empty
|
||||
};
|
||||
|
||||
// 处理诊断信息 map<string, string>
|
||||
if (payload.Diagnostics != null)
|
||||
{
|
||||
foreach (var kv in payload.Diagnostics)
|
||||
{
|
||||
request.Diagnostics.Add(kv.Key, kv.Value?.ToString() ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 发送至 AiVideo
|
||||
await call.RequestStream.WriteAsync(request);
|
||||
}
|
||||
|
||||
// 正常结束流
|
||||
await call.RequestStream.CompleteAsync();
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError($"[gRPC Worker] 推送链路异常,5秒后重连: {ex.Message}");
|
||||
await Task.Delay(5000, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
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()
|
||||
};
|
||||
|
||||
// 添加订阅者
|
||||
payload.SubscriberIds.AddRange(frame.SubscriberIds);
|
||||
|
||||
// 计算转码耗时(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)
|
||||
{
|
||||
bool ok = target.Channel.WriteLog(payload);
|
||||
if (!ok)
|
||||
{
|
||||
// 如果这里打印,说明管道由于某种原因被关闭了(通常是程序正在退出)
|
||||
Console.WriteLine($"[DEBUG] 管道写入失败,目标: {target.Config.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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 bool WriteLog(VideoPayload payload) // 改为返回 bool
|
||||
{
|
||||
// TryWrite 在 DropOldest 模式下虽然几乎总是返回 true,
|
||||
// 但如果 Channel 被 Complete (关闭) 了,它会返回 false。
|
||||
return _channel.Writer.TryWrite(payload);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [消费者] 读取器
|
||||
/// </summary>
|
||||
public ChannelReader<VideoPayload> Reader => _channel.Reader;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user