阶段性批量提交
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
using System.Collections.ObjectModel;
|
using SHH.CameraDashboard.Services;
|
||||||
|
using SHH.ProcessLaunchers;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
|
||||||
namespace SHH.CameraDashboard
|
namespace SHH.CameraDashboard
|
||||||
@@ -8,10 +10,30 @@ namespace SHH.CameraDashboard
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
|
#region 定义全局单例
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 进程管理器
|
||||||
|
/// </summary>
|
||||||
|
public static ProcessManager ProcManager { get; private set; }
|
||||||
|
= new ProcessManager(new ProcessDashboardLogger());
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
|
||||||
protected override async void OnStartup(StartupEventArgs e)
|
protected override async void OnStartup(StartupEventArgs e)
|
||||||
{
|
{
|
||||||
base.OnStartup(e);
|
base.OnStartup(e);
|
||||||
|
|
||||||
|
// 启动视频流接收 (Port 6002)
|
||||||
|
StreamReceiverService.Instance.Start(6002);
|
||||||
|
|
||||||
|
// 启动指令服务 (Port 6001)
|
||||||
|
CommandServer.Instance.Start(6001);
|
||||||
|
|
||||||
|
|
||||||
|
// 现在我们来配置启动
|
||||||
|
|
||||||
// 1. 【核心代码】程序启动时,异步读取配置文件
|
// 1. 【核心代码】程序启动时,异步读取配置文件
|
||||||
var savedNodes = await LocalStorageService.LoadAsync<ObservableCollection<ServiceNodeModel>>(AppPaths.ServiceNodesConfig);
|
var savedNodes = await LocalStorageService.LoadAsync<ObservableCollection<ServiceNodeModel>>(AppPaths.ServiceNodesConfig);
|
||||||
if (savedNodes != null)
|
if (savedNodes != null)
|
||||||
@@ -20,6 +42,43 @@ namespace SHH.CameraDashboard
|
|||||||
AppGlobal.ServiceNodes.Add(node);
|
AppGlobal.ServiceNodes.Add(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// 3. 构建启动参数 & 注册进程
|
||||||
|
// =========================================================
|
||||||
|
|
||||||
|
// A. 获取当前 Dashboard 的 PID (用于父进程守护)
|
||||||
|
int myPid = System.Environment.ProcessId;
|
||||||
|
|
||||||
|
// B. 构建参数字符串
|
||||||
|
// --pid: 让 Service 知道谁是父进程
|
||||||
|
// --uris: 告诉 Service 反向连接的目标 (注意顺序:视频端口, 指令端口)
|
||||||
|
// --mode: 1 (主动连接模式)
|
||||||
|
// --ports: Service 自身的 WebAPI 端口 (5005)
|
||||||
|
string serviceArgs = $"" +
|
||||||
|
$"--pid {myPid} " +
|
||||||
|
$"--appid \"CameraApp_01\" " +
|
||||||
|
$"--uris \"127.0.0.1,6002&6001;\" " +
|
||||||
|
$"--mode 1 " +
|
||||||
|
$"--ports \"5000,100\"";
|
||||||
|
|
||||||
|
// C. 注册进程配置 (复用 ProcManager)
|
||||||
|
ProcManager.Register(new ProcessConfig
|
||||||
|
{
|
||||||
|
Id = "CameraService", // 内部标识
|
||||||
|
DisplayName = "视频接入服务", // UI显示名称
|
||||||
|
// 请确保路径正确,建议用相对路径 AppDomain.CurrentDomain.BaseDirectory + "SHH.CameraService.exe"
|
||||||
|
ExePath = @"D:\Codes\Ayay\SHH.CameraService\bin\Debug\net8.0\SHH.CameraService.exe",
|
||||||
|
Arguments = serviceArgs, // ★★★ 核心:注入参数 ★★★
|
||||||
|
StartupOrder = 1, // 优先级
|
||||||
|
RestartDelayMs = 2000, // 崩溃后2秒重启
|
||||||
|
Visible = false // 不显示黑框
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// 4. 发射!启动所有注册的进程
|
||||||
|
// =========================================================
|
||||||
|
_ = ProcManager.StartAllAsync();
|
||||||
|
|
||||||
// 3. 启动主窗口
|
// 3. 启动主窗口
|
||||||
// 注意:如果 LoadAsync 耗时较长,这里可能会导致启动画面停留,
|
// 注意:如果 LoadAsync 耗时较长,这里可能会导致启动画面停留,
|
||||||
// 实际项目中可以搞一个 Splash Screen (启动屏) 来做这件事。
|
// 实际项目中可以搞一个 Splash Screen (启动屏) 来做这件事。
|
||||||
@@ -35,7 +94,13 @@ namespace SHH.CameraDashboard
|
|||||||
// 1. 这里可以处理统一的资源清理逻辑 (如停止摄像头推流、关闭数据库连接)
|
// 1. 这里可以处理统一的资源清理逻辑 (如停止摄像头推流、关闭数据库连接)
|
||||||
// 2. 保存用户配置
|
// 2. 保存用户配置
|
||||||
// 3. 彻底退出
|
// 3. 彻底退出
|
||||||
|
StreamReceiverService.Instance.Dispose();
|
||||||
|
CommandServer.Instance.Dispose();
|
||||||
|
// 停止所有子进程
|
||||||
|
ProcManager.StopAll();
|
||||||
|
|
||||||
Current.Shutdown();
|
Current.Shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
45
SHH.CameraDashboard/Invokes/BitmapHelper.cs
Normal file
45
SHH.CameraDashboard/Invokes/BitmapHelper.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
|
||||||
|
namespace SHH.CameraDashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// [UI层] 图像数据转换助手
|
||||||
|
/// 职责:将内存中的二进制 JPEG 数据高效转换为 WPF 可用的 BitmapImage
|
||||||
|
/// 优化:使用 OnLoad 缓存策略和 Freeze 冻结对象,支持跨线程访问,防止内存泄漏
|
||||||
|
/// </summary>
|
||||||
|
public static class BitmapHelper
|
||||||
|
{
|
||||||
|
public static BitmapImage? ToBitmapImage(byte[] blob)
|
||||||
|
{
|
||||||
|
if (blob == null || blob.Length == 0) return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var stream = new MemoryStream(blob))
|
||||||
|
{
|
||||||
|
var bitmap = new BitmapImage();
|
||||||
|
bitmap.BeginInit();
|
||||||
|
|
||||||
|
// 关键优化 1: 立即加载流到内存,允许 stream 在方法结束后被释放
|
||||||
|
bitmap.CacheOption = BitmapCacheOption.OnLoad;
|
||||||
|
bitmap.StreamSource = stream;
|
||||||
|
|
||||||
|
// 关键优化 2: 忽略内嵌的色彩配置和缩略图,提升解码速度
|
||||||
|
bitmap.CreateOptions = BitmapCreateOptions.IgnoreColorProfile | BitmapCreateOptions.IgnoreImageCache;
|
||||||
|
|
||||||
|
bitmap.EndInit();
|
||||||
|
|
||||||
|
// 关键优化 3: 冻结对象,使其变得线程安全(可以跨线程传递给 UI)
|
||||||
|
bitmap.Freeze();
|
||||||
|
|
||||||
|
return bitmap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 解码失败(可能是坏帧),返回 null 忽略该帧
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
167
SHH.CameraDashboard/Invokes/CommandServer.cs
Normal file
167
SHH.CameraDashboard/Invokes/CommandServer.cs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
using NetMQ;
|
||||||
|
using NetMQ.Sockets;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace SHH.CameraDashboard.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// [Dashboard端] 指令控制服务
|
||||||
|
/// 职责:双向通信通道。接收 Service 心跳/响应,向 Service 发送控制指令。
|
||||||
|
/// 核心模式:ROUTER (Dashboard) <--> DEALER (Service)
|
||||||
|
/// </summary>
|
||||||
|
public class CommandServer : IDisposable
|
||||||
|
{
|
||||||
|
// 单例模式
|
||||||
|
public static CommandServer Instance { get; } = new CommandServer();
|
||||||
|
|
||||||
|
// 事件:收到消息时触发 (ServiceId, MessageContent)
|
||||||
|
public event Action<string, string>? OnMessageReceived;
|
||||||
|
|
||||||
|
private RouterSocket? _routerSocket;
|
||||||
|
private NetMQPoller? _poller;
|
||||||
|
|
||||||
|
// 【关键新增】发送队列:用于解决跨线程发送的安全问题
|
||||||
|
// UI线程 -> Enqueue -> Poller线程 -> Socket.Send
|
||||||
|
private NetMQQueue<CommandPacket>? _sendQueue;
|
||||||
|
|
||||||
|
public int ListenPort { get; private set; }
|
||||||
|
public bool IsRunning => _poller != null && _poller.IsRunning;
|
||||||
|
|
||||||
|
// 在线设备表 (可选,用于记录谁在线)
|
||||||
|
// Key: ServiceId (Identity字符串)
|
||||||
|
private readonly ConcurrentDictionary<string, DateTime> _onlineClients = new();
|
||||||
|
|
||||||
|
private CommandServer() { }
|
||||||
|
|
||||||
|
public void Start(int port)
|
||||||
|
{
|
||||||
|
ListenPort = port;
|
||||||
|
if (IsRunning) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 1. 初始化 Router Socket
|
||||||
|
_routerSocket = new RouterSocket();
|
||||||
|
_routerSocket.Bind($"tcp://*:{ListenPort}");
|
||||||
|
_routerSocket.ReceiveReady += OnSocketReady;
|
||||||
|
|
||||||
|
// 2. 初始化发送队列
|
||||||
|
_sendQueue = new NetMQQueue<CommandPacket>();
|
||||||
|
_sendQueue.ReceiveReady += OnQueueReady;
|
||||||
|
|
||||||
|
// 3. 启动 Poller (同时监听 Socket 接收 和 队列发送)
|
||||||
|
_poller = new NetMQPoller { _routerSocket, _sendQueue };
|
||||||
|
|
||||||
|
// RunAsync 会自动开启后台线程
|
||||||
|
_poller.RunAsync();
|
||||||
|
|
||||||
|
Console.WriteLine($"[Dashboard] 指令服务启动,监听: tcp://*:{ListenPort}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Dashboard] 指令端口绑定失败: {ex.Message}");
|
||||||
|
throw; // 必须抛出,让 App 感知
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 处理来自 Service 的网络消息 (运行在 Poller 线程)
|
||||||
|
/// </summary>
|
||||||
|
private void OnSocketReady(object? sender, NetMQSocketEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 1. 读取身份帧 (Identity)
|
||||||
|
// 只要 Service 端 DealerSocket 设置了 Identity,这里收到就是那个 ID
|
||||||
|
var identityBytes = e.Socket.ReceiveFrameBytes();
|
||||||
|
string serviceId = Encoding.UTF8.GetString(identityBytes);
|
||||||
|
|
||||||
|
// 2. 读取内容帧 (假设 Dealer 直接发内容,中间无空帧)
|
||||||
|
// 如果你使用了 REQ/REP 模式,中间可能会有空帧,需注意兼容
|
||||||
|
string message = e.Socket.ReceiveFrameString();
|
||||||
|
|
||||||
|
// 3. 简单的心跳保活逻辑
|
||||||
|
_onlineClients[serviceId] = DateTime.Now;
|
||||||
|
|
||||||
|
// 4. 触发业务事件
|
||||||
|
// 注意:这依然在 Poller 线程,UI 处理时需 Invoke
|
||||||
|
Console.WriteLine($"[指令] From {serviceId}: {message}");
|
||||||
|
OnMessageReceived?.Invoke(serviceId, message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[Command Receive Error] {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 处理发送队列 (运行在 Poller 线程)
|
||||||
|
/// </summary>
|
||||||
|
private void OnQueueReady(object? sender, NetMQQueueEventArgs<CommandPacket> e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_routerSocket == null) return;
|
||||||
|
|
||||||
|
// 从队列取出一个包
|
||||||
|
if (e.Queue.TryDequeue(out var packet, TimeSpan.Zero))
|
||||||
|
{
|
||||||
|
// Router 发送标准三步走:
|
||||||
|
// 1. 发送目标 Identity (More = true)
|
||||||
|
// 2. 发送空帧 (可选,取决于协议约定,Router-Dealer 直连通常不需要空帧)
|
||||||
|
// 3. 发送数据 (More = false)
|
||||||
|
|
||||||
|
// 这里我们采用最简协议:[Identity][Data]
|
||||||
|
_routerSocket.SendMoreFrame(packet.TargetId)
|
||||||
|
.SendFrame(packet.JsonData);
|
||||||
|
|
||||||
|
Console.WriteLine($"[指令] To {packet.TargetId}: {packet.JsonData}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[Command Send Error] {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 发送指令 (线程安全,可由 UI 线程调用)
|
||||||
|
/// </summary>
|
||||||
|
public void SendCommand(string targetServiceId, object commandData)
|
||||||
|
{
|
||||||
|
if (_sendQueue == null) return;
|
||||||
|
|
||||||
|
var json = JsonConvert.SerializeObject(commandData);
|
||||||
|
|
||||||
|
// ★★★ 核心修复:不直接操作 Socket,而是入队 ★★★
|
||||||
|
_sendQueue.Enqueue(new CommandPacket
|
||||||
|
{
|
||||||
|
TargetId = targetServiceId,
|
||||||
|
JsonData = json
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_poller?.Stop();
|
||||||
|
_poller?.Dispose();
|
||||||
|
_routerSocket?.Dispose();
|
||||||
|
_sendQueue?.Dispose();
|
||||||
|
|
||||||
|
_poller = null;
|
||||||
|
_routerSocket = null;
|
||||||
|
_sendQueue = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内部数据包结构
|
||||||
|
private class CommandPacket
|
||||||
|
{
|
||||||
|
public string TargetId { get; set; } = "";
|
||||||
|
public string JsonData { get; set; } = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
37
SHH.CameraDashboard/Invokes/ServiceLaunchOptions.cs
Normal file
37
SHH.CameraDashboard/Invokes/ServiceLaunchOptions.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace SHH.CameraDashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// [Dashboard端] Service 启动参数构建器
|
||||||
|
/// 职责:生成标准化的命令行参数字符串,告诉 Service 如何反向连接
|
||||||
|
/// </summary>
|
||||||
|
public static class ServiceLaunchOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 生成启动参数
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serviceId">给子服务起的唯一ID (如 "CamService_01")</param>
|
||||||
|
/// <param name="dashboardIp">Dashboard 的 IP (通常是 127.0.0.1)</param>
|
||||||
|
/// <param name="streamPort">Dashboard 监听视频的端口 (如 6000)</param>
|
||||||
|
/// <param name="serviceApiPort">指定子服务 WebAPI 监听的端口 (如 5005)</param>
|
||||||
|
/// <returns>命令行参数字符串</returns>
|
||||||
|
public static string BuildArguments(string serviceId, string dashboardIp, int streamPort, int serviceApiPort)
|
||||||
|
{
|
||||||
|
// 获取当前 Dashboard 进程 ID,传给子进程做“父进程守护”
|
||||||
|
int parentPid = Environment.ProcessId;
|
||||||
|
|
||||||
|
// 拼接参数:
|
||||||
|
// --pid: 父进程ID
|
||||||
|
// --uris: 反向连接的目标地址 (Dashboard 的地址)
|
||||||
|
// --mode: 1 (Active模式,代表 Service 主动连接 Dashboard)
|
||||||
|
// --ports: Service 自身的 WebAPI 端口 (防止与 Dashboard 冲突)
|
||||||
|
|
||||||
|
return $"" +
|
||||||
|
$"--pid {parentPid} " +
|
||||||
|
$"--id \"{serviceId}\" " +
|
||||||
|
$"--uris \"{dashboardIp},{streamPort}\" " +
|
||||||
|
$"--mode 1 " +
|
||||||
|
$"--ports \"{serviceApiPort},100\""; // 100 是保留位,暂不用
|
||||||
|
}
|
||||||
|
}
|
||||||
133
SHH.CameraDashboard/Invokes/StreamReceiverService.cs
Normal file
133
SHH.CameraDashboard/Invokes/StreamReceiverService.cs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
using NetMQ;
|
||||||
|
using NetMQ.Sockets;
|
||||||
|
using System.Diagnostics; // 用于 Debug 输出
|
||||||
|
|
||||||
|
namespace SHH.CameraDashboard.Services;
|
||||||
|
|
||||||
|
public class StreamReceiverService : IDisposable
|
||||||
|
{
|
||||||
|
// 单例模式
|
||||||
|
public static StreamReceiverService Instance { get; } = new StreamReceiverService();
|
||||||
|
|
||||||
|
public event Action<string, byte[]>? OnFrameReceived;
|
||||||
|
|
||||||
|
private SubscriberSocket? _subSocket;
|
||||||
|
private Task? _receiveTask;
|
||||||
|
|
||||||
|
// 【修复1】不要在这里初始化,改为在 Start 中初始化
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
|
public int ListenPort { get; private set; }
|
||||||
|
|
||||||
|
// 增加运行状态标记
|
||||||
|
public bool IsRunning => _receiveTask != null && !_receiveTask.IsCompleted;
|
||||||
|
|
||||||
|
private StreamReceiverService() { }
|
||||||
|
|
||||||
|
public void Start(int port = 6000)
|
||||||
|
{
|
||||||
|
// 1. 防止重复启动
|
||||||
|
if (IsRunning) return;
|
||||||
|
|
||||||
|
ListenPort = port;
|
||||||
|
|
||||||
|
// 【修复1】每次启动时创建新的 TokenSource
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 2. 初始化 Socket
|
||||||
|
_subSocket = new SubscriberSocket();
|
||||||
|
|
||||||
|
// 【优化】设置高水位限制 (HWM)
|
||||||
|
// 如果 UI 处理不过来,积压超过 1000 帧直接丢弃,防止内存爆炸
|
||||||
|
_subSocket.Options.ReceiveHighWatermark = 1000;
|
||||||
|
|
||||||
|
string bindAddr = $"tcp://*:{ListenPort}";
|
||||||
|
_subSocket.Bind(bindAddr);
|
||||||
|
_subSocket.Subscribe("");
|
||||||
|
|
||||||
|
Console.WriteLine($"[Dashboard] 视频流接收服务启动: {bindAddr}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Dashboard] 致命错误 - 端口绑定失败: {ex.Message}");
|
||||||
|
|
||||||
|
// 清理资源
|
||||||
|
_subSocket?.Dispose();
|
||||||
|
_subSocket = null;
|
||||||
|
|
||||||
|
// 【修复4】抛出异常让上层知道启动失败了
|
||||||
|
throw new Exception($"端口 {port} 绑定失败,可能被占用。", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 启动任务
|
||||||
|
_receiveTask = Task.Run(ReceiveLoop, _cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReceiveLoop()
|
||||||
|
{
|
||||||
|
var token = _cts?.Token ?? CancellationToken.None;
|
||||||
|
|
||||||
|
while (!token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 【修复2】线程安全检查
|
||||||
|
if (_subSocket == null) break;
|
||||||
|
|
||||||
|
// 接收 Topic
|
||||||
|
if (!_subSocket.TryReceiveFrameString(TimeSpan.FromMilliseconds(500), out string cameraId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// 接收 Payload
|
||||||
|
if (!_subSocket.TryReceiveFrameBytes(TimeSpan.FromMilliseconds(100), out byte[] jpgBytes))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// 触发事件
|
||||||
|
OnFrameReceived?.Invoke(cameraId, jpgBytes);
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException)
|
||||||
|
{
|
||||||
|
// 【修复2】这是正常的退出流程(Socket被Dispose了),优雅退出循环
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// 记录日志,但不崩溃
|
||||||
|
Debug.WriteLine($"[ReceiverLoop Error] {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Console.WriteLine("[Dashboard] 接收循环已停止");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 停止服务(支持停止后重新 Start)
|
||||||
|
/// </summary>
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
// 1. 发出取消信号
|
||||||
|
if (_cts != null && !_cts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_cts.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 销毁 Socket (这会触发 ReceiveLoop 中的 ObjectDisposedException 从而退出循环)
|
||||||
|
if (_subSocket != null)
|
||||||
|
{
|
||||||
|
try { _subSocket.Dispose(); } catch { }
|
||||||
|
_subSocket = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 清理 Token
|
||||||
|
_cts?.Dispose();
|
||||||
|
_cts = null;
|
||||||
|
|
||||||
|
_receiveTask = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ namespace SHH.CameraDashboard
|
|||||||
/// 它封装了主窗口的所有业务逻辑、状态和用户交互命令。
|
/// 它封装了主窗口的所有业务逻辑、状态和用户交互命令。
|
||||||
/// 它还作为一个中心协调者,响应全局事件并管理子视图(如右侧编辑面板和蒙板)。
|
/// 它还作为一个中心协调者,响应全局事件并管理子视图(如右侧编辑面板和蒙板)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class MainWindowViewModel : INotifyPropertyChanged
|
public class MainWindowViewModel : INotifyPropertyChanged, IDisposable
|
||||||
{
|
{
|
||||||
#region --- 构造函数 ---
|
#region --- 构造函数 ---
|
||||||
|
|
||||||
@@ -520,5 +521,9 @@ namespace SHH.CameraDashboard
|
|||||||
get => _mainContent;
|
get => _mainContent;
|
||||||
set { _mainContent = value; OnPropertyChanged(); }
|
set { _mainContent = value; OnPropertyChanged(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,87 +1,76 @@
|
|||||||
using SHH.Contracts;
|
using System.Windows;
|
||||||
using System.IO;
|
|
||||||
using System.Windows;
|
|
||||||
using System.Windows.Media;
|
|
||||||
using System.Windows.Media.Imaging;
|
using System.Windows.Media.Imaging;
|
||||||
|
using SHH.CameraDashboard.Services; // 引用服务命名空间
|
||||||
|
|
||||||
namespace SHH.CameraDashboard
|
namespace SHH.CameraDashboard;
|
||||||
|
|
||||||
|
public class VideoTileViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
public class VideoTileViewModel : ViewModelBase, IDisposable
|
private readonly string _boundCameraId;
|
||||||
{
|
|
||||||
// --- 绑定属性 ---
|
|
||||||
|
|
||||||
private ImageSource _displayImage;
|
|
||||||
public ImageSource DisplayImage
|
|
||||||
{
|
|
||||||
get => _displayImage;
|
|
||||||
set => SetProperty(ref _displayImage, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// --- 属性定义 ---
|
||||||
private string _cameraName;
|
private string _cameraName;
|
||||||
public string CameraName
|
public string CameraName
|
||||||
{
|
{
|
||||||
get => _cameraName;
|
get => _cameraName;
|
||||||
set => SetProperty(ref _cameraName, value);
|
set { _cameraName = value; OnPropertyChanged(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private string _statusInfo;
|
private string _statusInfo;
|
||||||
public string StatusInfo
|
public string StatusInfo
|
||||||
{
|
{
|
||||||
get => _statusInfo;
|
get => _statusInfo;
|
||||||
set => SetProperty(ref _statusInfo, value);
|
set { _statusInfo = value; OnPropertyChanged(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool _isConnected;
|
private BitmapImage _videoSource;
|
||||||
public bool IsConnected
|
public BitmapImage VideoSource
|
||||||
{
|
{
|
||||||
get => _isConnected;
|
get => _videoSource;
|
||||||
set => SetProperty(ref _isConnected, value);
|
set { _videoSource = value; OnPropertyChanged(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 构造函数 ---
|
// --- 构造函数 ---
|
||||||
public VideoTileViewModel(string ip, int port, string name)
|
public VideoTileViewModel(string cameraId, string name)
|
||||||
{
|
{
|
||||||
|
_boundCameraId = cameraId;
|
||||||
CameraName = name;
|
CameraName = name;
|
||||||
StatusInfo = "连接中...";
|
StatusInfo = "等待信号...";
|
||||||
|
|
||||||
IsConnected = true;
|
// 【修正 1】直接订阅单例服务
|
||||||
|
// 不需要判断 null,因为 Instance 是静态初始化的,永远存在
|
||||||
|
StreamReceiverService.Instance.OnFrameReceived += OnGlobalFrameReceived;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleNewFrame(VideoPayload payload)
|
// --- 事件回调 (后台线程) ---
|
||||||
|
private void OnGlobalFrameReceived(string cameraId, byte[] jpgData)
|
||||||
{
|
{
|
||||||
// 必须回到 UI 线程更新 ImageSource
|
// 1. 过滤:不是我的画面,直接忽略
|
||||||
Application.Current.Dispatcher.Invoke(() =>
|
if (cameraId != _boundCameraId) return;
|
||||||
{
|
|
||||||
// 1. 更新图片
|
|
||||||
byte[] data = payload.TargetImageBytes ?? payload.OriginalImageBytes;
|
|
||||||
if (data != null && data.Length > 0)
|
|
||||||
{
|
|
||||||
DisplayImage = ByteToBitmap(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 更新状态文字
|
// 2. 解码:耗时操作在后台完成
|
||||||
StatusInfo = $"{payload.CaptureTime:HH:mm:ss} | {data?.Length / 1024} KB";
|
var bitmap = BitmapHelper.ToBitmapImage(jpgData);
|
||||||
|
if (bitmap == null) return;
|
||||||
|
|
||||||
|
// 3. 【修正 2】恢复 UI 更新逻辑
|
||||||
|
// 必须使用 Dispatcher,因为 VideoSource 绑定在界面上,只能在主线程修改
|
||||||
|
Application.Current.Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
VideoSource = bitmap;
|
||||||
|
|
||||||
|
// 更新状态信息 (例如显示当前时间和数据大小)
|
||||||
|
StatusInfo = $"{DateTime.Now:HH:mm:ss} | {jpgData.Length / 1024} KB";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 简单的 Bytes 转 BitmapImage (生产环境建议优化为 WriteableBitmap)
|
// --- 资源清理 ---
|
||||||
private BitmapImage ByteToBitmap(byte[] bytes)
|
public void Unload()
|
||||||
{
|
{
|
||||||
var bitmap = new BitmapImage();
|
// 【修正 3】从单例服务取消订阅
|
||||||
using (var stream = new MemoryStream(bytes))
|
// 这一步至关重要,否则切换页面时会内存泄漏
|
||||||
{
|
StreamReceiverService.Instance.OnFrameReceived -= OnGlobalFrameReceived;
|
||||||
bitmap.BeginInit();
|
|
||||||
bitmap.CacheOption = BitmapCacheOption.OnLoad;
|
|
||||||
bitmap.StreamSource = stream;
|
|
||||||
bitmap.EndInit();
|
|
||||||
}
|
|
||||||
bitmap.Freeze(); // 必须冻结才能跨线程
|
|
||||||
return bitmap;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
// 清空图片引用,帮助 GC 回收内存
|
||||||
{
|
VideoSource = null;
|
||||||
IsConnected = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,12 +63,6 @@ namespace SHH.CameraDashboard
|
|||||||
//VideoTiles.Add(new VideoTileViewModel("1004", "仓库通道"));
|
//VideoTiles.Add(new VideoTileViewModel("1004", "仓库通道"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void AddCamera(string ip, int port, string name)
|
|
||||||
{
|
|
||||||
var tile = new VideoTileViewModel(ip, port, name);
|
|
||||||
VideoTiles.Add(tile);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ExecuteSetLayout(string layoutType)
|
private void ExecuteSetLayout(string layoutType)
|
||||||
{
|
{
|
||||||
switch (layoutType)
|
switch (layoutType)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\SHH.Contracts\SHH.Contracts.csproj" />
|
<ProjectReference Include="..\SHH.Contracts\SHH.Contracts.csproj" />
|
||||||
<ProjectReference Include="..\SHH.NetMQ\SHH.NetMQ.csproj" />
|
<ProjectReference Include="..\SHH.NetMQ\SHH.NetMQ.csproj" />
|
||||||
|
<ProjectReference Include="..\SHH.ProcessLaunchers\SHH.ProcessLaunchers.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
37
SHH.CameraDashboard/Services/ProcessDashboardLogger.cs
Normal file
37
SHH.CameraDashboard/Services/ProcessDashboardLogger.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
using SHH.ProcessLaunchers;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace SHH.CameraDashboard
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 启动器日志适配器
|
||||||
|
/// <para>将底层 ProcessManager 的日志桥接到 System.Diagnostics.Debug 和 MessageBox</para>
|
||||||
|
/// </summary>
|
||||||
|
public class ProcessDashboardLogger : ILauncherLogger
|
||||||
|
{
|
||||||
|
public void LogConsole(string processId, string message, bool isError)
|
||||||
|
{
|
||||||
|
// 将子进程的控制台输出转发到 VS 的输出窗口,方便调试
|
||||||
|
string prefix = isError ? "[STDERR]" : "[STDOUT]";
|
||||||
|
Debug.WriteLine($"{prefix} <{processId}>: {message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void LogLifecycle(string processId, LogAction action, LogTrigger trigger, string reason, object payload = null)
|
||||||
|
{
|
||||||
|
string msg = $"[ProcessManager] {processId} - {action}: {reason}";
|
||||||
|
Debug.WriteLine(msg);
|
||||||
|
|
||||||
|
// 如果是严重错误(如资源超限被杀),弹窗提醒
|
||||||
|
if (trigger == LogTrigger.ResourceGuard && action == LogAction.Restart)
|
||||||
|
{
|
||||||
|
// 注意:确保在 UI 线程弹窗
|
||||||
|
Application.Current.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
MessageBox.Show($"进程 [{processId}] 资源异常!\n原因:{reason}\n系统已执行自动重启。",
|
||||||
|
"资源管控警报", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
SHH.CameraSdk/Configs/NetworkMode.cs
Normal file
26
SHH.CameraSdk/Configs/NetworkMode.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
namespace SHH.CameraSdk;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 网络连接模式
|
||||||
|
/// </summary>
|
||||||
|
public enum NetworkMode
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// [模式0] 被动模式 (Server)
|
||||||
|
/// <para>只监听本地端口 (Bind),等待别人来连。</para>
|
||||||
|
/// </summary>
|
||||||
|
Passive = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// [模式1] 主动模式 (Client)
|
||||||
|
/// <para>只主动连接远程目标 (Connect),不监听本地。</para>
|
||||||
|
/// </summary>
|
||||||
|
Active = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// [模式2] 混合模式 (Both)
|
||||||
|
/// <para>既监听本地端口,又主动连接远程目标。</para>
|
||||||
|
/// <para>场景:本机有客户端需要看视频,同时需往云端服务器发视频。</para>
|
||||||
|
/// </summary>
|
||||||
|
Hybrid = 2
|
||||||
|
}
|
||||||
227
SHH.CameraSdk/Configs/ServiceConfig.cs
Normal file
227
SHH.CameraSdk/Configs/ServiceConfig.cs
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
namespace SHH.CameraSdk;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 全局服务配置模型 (V3 最终版)
|
||||||
|
/// <para>负责解析命令行参数,构建网络拓扑和身份标识</para>
|
||||||
|
/// </summary>
|
||||||
|
public class ServiceConfig
|
||||||
|
{
|
||||||
|
// ==========================================
|
||||||
|
// 1. 身份与进程属性
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 父进程 PID (用于哨兵守护,--pid)
|
||||||
|
/// </summary>
|
||||||
|
public int ParentPid { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 应用完整标识 (例如 "CameraApp_01", --appid)
|
||||||
|
/// </summary>
|
||||||
|
public string AppId { get; private set; } = "Unknown_01";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 【核心】从 AppId 自动提取的数字编号
|
||||||
|
/// <para>规则:取最后一个下划线后的数字</para>
|
||||||
|
/// <para>示例:"CameraApp_05" -> 5</para>
|
||||||
|
/// </summary>
|
||||||
|
public int NumericId { get; private set; } = 1;
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 2. 网络连接属性 (分流)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 视频流目标地址列表 (对应 & 符号左侧)
|
||||||
|
/// <para>ZeroMQBridgeWorker 使用此列表</para>
|
||||||
|
/// </summary>
|
||||||
|
public List<string> VideoEndpoints { get; private set; } = new List<string>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 指令控制目标地址列表 (对应 & 符号右侧)
|
||||||
|
/// <para>CommandClientWorker 使用此列表</para>
|
||||||
|
/// </summary>
|
||||||
|
public List<string> CommandEndpoints { get; private set; } = new List<string>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WebAPI 基础端口 (--ports 的第一个值)
|
||||||
|
/// </summary>
|
||||||
|
public int BasePort { get; private set; } = 5000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 端口扫描范围 (--ports 的第二个值)
|
||||||
|
/// </summary>
|
||||||
|
public int MaxPortRange { get; private set; } = 100;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 网络模式 (--mode)
|
||||||
|
/// </summary>
|
||||||
|
public NetworkMode Mode { get; private set; } = NetworkMode.Passive;
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 3. 辅助属性
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否需要执行 Connect 操作
|
||||||
|
/// </summary>
|
||||||
|
public bool ShouldConnect => Mode == NetworkMode.Active || Mode == NetworkMode.Hybrid;
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 4. 解析入口 (Factory Method)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
public static ServiceConfig BuildFromArgs(string[] args)
|
||||||
|
{
|
||||||
|
var config = new ServiceConfig();
|
||||||
|
|
||||||
|
for (int i = 0; i < args.Length; i++)
|
||||||
|
{
|
||||||
|
// 1. 预处理 Key
|
||||||
|
var key = args[i].ToLower().Trim();
|
||||||
|
|
||||||
|
// 2. 预取 Value (如果存在且不是下一个 flag)
|
||||||
|
var value = (i + 1 < args.Length) ? args[i + 1] : string.Empty;
|
||||||
|
|
||||||
|
// 简单判断:如果 value 以 -- 开头,说明当前 key 是开关,或者参数值缺失
|
||||||
|
if (value.StartsWith("--")) value = string.Empty;
|
||||||
|
|
||||||
|
bool consumed = false; // 标记是否消耗了下一个参数
|
||||||
|
|
||||||
|
// 3. 匹配参数
|
||||||
|
switch (key)
|
||||||
|
{
|
||||||
|
case "--pid":
|
||||||
|
if (int.TryParse(value, out int pid)) config.ParentPid = pid;
|
||||||
|
consumed = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "--appid":
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
config.AppId = value;
|
||||||
|
// ★★★ 立即解析数字编号 ★★★
|
||||||
|
config.NumericId = ParseIdFromAppId(value);
|
||||||
|
}
|
||||||
|
consumed = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "--uris":
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
// ★★★ 解析复杂 URI 字符串 ★★★
|
||||||
|
ParseUris(config, value);
|
||||||
|
}
|
||||||
|
consumed = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "--mode":
|
||||||
|
if (int.TryParse(value, out int m) && Enum.IsDefined(typeof(NetworkMode), m))
|
||||||
|
{
|
||||||
|
config.Mode = (NetworkMode)m;
|
||||||
|
}
|
||||||
|
consumed = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "--ports":
|
||||||
|
// 格式: "BasePort,Range" -> "6003,100"
|
||||||
|
if (!string.IsNullOrWhiteSpace(value) && value.Contains(","))
|
||||||
|
{
|
||||||
|
var parts = value.Split(',');
|
||||||
|
if (parts.Length >= 1)
|
||||||
|
{
|
||||||
|
if (int.TryParse(parts[0], out int baseP)) config.BasePort = baseP;
|
||||||
|
}
|
||||||
|
if (parts.Length >= 2)
|
||||||
|
{
|
||||||
|
if (int.TryParse(parts[1], out int range)) config.MaxPortRange = range;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
consumed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 如果消耗了 Value,跳过下一个索引
|
||||||
|
if (consumed) i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 5. 核心解析算法实现
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 算法:提取下划线后的数字
|
||||||
|
/// </summary>
|
||||||
|
private static int ParseIdFromAppId(string appId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(appId)) return 1;
|
||||||
|
|
||||||
|
// 查找最后一个下划线
|
||||||
|
int lastIdx = appId.LastIndexOf('_');
|
||||||
|
|
||||||
|
// 确保下划线存在,且后面还有字符
|
||||||
|
if (lastIdx >= 0 && lastIdx < appId.Length - 1)
|
||||||
|
{
|
||||||
|
string numPart = appId.Substring(lastIdx + 1);
|
||||||
|
if (int.TryParse(numPart, out int id))
|
||||||
|
{
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析失败默认返回 1
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 算法:解析 URI 列表并分流
|
||||||
|
/// <para>格式: IP,VideoPort&CommandPort</para>
|
||||||
|
/// <para>空缺处理: "&6001" (仅指令), "6002&" (仅视频)</para>
|
||||||
|
/// </summary>
|
||||||
|
private static void ParseUris(ServiceConfig config, string rawValue)
|
||||||
|
{
|
||||||
|
// 1. 按分号拆分不同主机配置
|
||||||
|
// "127.0.0.1,6002&6001; 192.168.1.5,&6001"
|
||||||
|
var groups = rawValue.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
// 2. 按逗号拆分 IP 和 端口段
|
||||||
|
var hostParts = group.Split(',');
|
||||||
|
if (hostParts.Length < 2) continue; // 格式非法
|
||||||
|
|
||||||
|
string ip = hostParts[0].Trim();
|
||||||
|
string portSection = hostParts[1].Trim(); // "6002&6001"
|
||||||
|
|
||||||
|
// 3. 按 & 拆分端口 (注意:不要 RemoveEmptyEntries,位置很重要)
|
||||||
|
var ports = portSection.Split('&');
|
||||||
|
|
||||||
|
// --- 索引 0: 视频端口 ---
|
||||||
|
if (ports.Length > 0)
|
||||||
|
{
|
||||||
|
string p = ports[0].Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(p) && int.TryParse(p, out int port))
|
||||||
|
{
|
||||||
|
string uri = $"tcp://{ip}:{port}";
|
||||||
|
if (!config.VideoEndpoints.Contains(uri))
|
||||||
|
config.VideoEndpoints.Add(uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 索引 1: 指令端口 ---
|
||||||
|
if (ports.Length > 1)
|
||||||
|
{
|
||||||
|
string p = ports[1].Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(p) && int.TryParse(p, out int port))
|
||||||
|
{
|
||||||
|
string uri = $"tcp://{ip}:{port}";
|
||||||
|
if (!config.CommandEndpoints.Contains(uri))
|
||||||
|
config.CommandEndpoints.Add(uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
SHH.CameraSdk/Core/ServiceExtensions.cs
Normal file
82
SHH.CameraSdk/Core/ServiceExtensions.cs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace SHH.CameraSdk
|
||||||
|
{
|
||||||
|
public static class ServiceExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 注入 CameraSdk 的核心服务
|
||||||
|
/// <para>包含:内存缓存、配置管理、图像流水线、存储服务、相机管理、窗口管理等</para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">DI 容器</param>
|
||||||
|
/// <param name="processId">进程ID (用于确定存储路径)</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static IServiceCollection AddCameraSdk(this IServiceCollection services, int processId)
|
||||||
|
{
|
||||||
|
// =============================================================
|
||||||
|
// 1. 基础组件注册 (修复你之前的报错)
|
||||||
|
// =============================================================
|
||||||
|
services.AddMemoryCache(); // ★ 核心修复:添加内存缓存
|
||||||
|
|
||||||
|
// 注册配置管理器(指挥部)
|
||||||
|
services.AddSingleton<ProcessingConfigManager>();
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 2. 图像处理流水线编排 (Pipeline)
|
||||||
|
// =============================================================
|
||||||
|
// 这里我们利用 Factory 模式在注册时完成链条组装,保持了你原有的逻辑
|
||||||
|
services.AddSingleton<ImageScaleCluster>(sp =>
|
||||||
|
{
|
||||||
|
var configMgr = sp.GetRequiredService<ProcessingConfigManager>();
|
||||||
|
|
||||||
|
// 手动创建实例
|
||||||
|
var scale = new ImageScaleCluster(4, configMgr);
|
||||||
|
var enhance = new ImageEnhanceCluster(4, configMgr);
|
||||||
|
|
||||||
|
// ★ 编排流水线:缩放 -> 增亮
|
||||||
|
scale.SetNext(enhance);
|
||||||
|
|
||||||
|
// ★ 全局路由挂载 (兼容旧驱动层)
|
||||||
|
GlobalPipelineRouter.SetProcessor(scale);
|
||||||
|
|
||||||
|
return scale;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注册 EnhanceCluster,以防 Controller 单独请求它
|
||||||
|
// 注意:这里我们通过从 Scale 中获取 Next 来保证是同一个实例链条
|
||||||
|
services.AddSingleton<ImageEnhanceCluster>(sp =>
|
||||||
|
{
|
||||||
|
var scale = sp.GetRequiredService<ImageScaleCluster>();
|
||||||
|
// 这里假设链条没变,或者你可以重新 new 一个,但为了保持引用一致性,
|
||||||
|
// 建议尽量通过主入口访问,或者在这里重新创建独立的(取决于业务需求)。
|
||||||
|
// 按照你之前的逻辑,这里为了简单,我们重新注册一个新的或沿用上一个逻辑。
|
||||||
|
// *最佳实践*:如果 enhancing 是依附于 scaling 的,通常只注册 Head。
|
||||||
|
// 但为了兼容你原代码的 DI 注册:
|
||||||
|
return new ImageEnhanceCluster(4, sp.GetRequiredService<ProcessingConfigManager>());
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 3. 核心业务服务
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
// 文件存储服务 (依赖 processId)
|
||||||
|
services.AddSingleton<IStorageService>(sp => new FileStorageService(processId));
|
||||||
|
|
||||||
|
// 核心设备管理器 (自动注入 IStorageService)
|
||||||
|
services.AddSingleton<CameraManager>();
|
||||||
|
|
||||||
|
// 动态窗口管理器 (自动注入 CameraManager)
|
||||||
|
services.AddSingleton<DisplayWindowManager>();
|
||||||
|
|
||||||
|
// 网络哨兵 (建议注册为单例,方便后续获取状态)
|
||||||
|
services.AddSingleton<ConnectivitySentinel>();
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 4. Web 过滤器
|
||||||
|
// =============================================================
|
||||||
|
services.AddScoped<UserActionFilter>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using OpenCvSharp;
|
using OpenCvSharp;
|
||||||
using SHH.CameraSdk.HikFeatures;
|
using SHH.CameraSdk.HikFeatures;
|
||||||
using System;
|
|
||||||
|
|
||||||
namespace SHH.CameraSdk;
|
namespace SHH.CameraSdk;
|
||||||
|
|
||||||
@@ -380,11 +379,9 @@ public class HikVideoSource : BaseVideoSource,
|
|||||||
smartFrame.SubscriberIds.AddRange(decision.TargetAppIds);
|
smartFrame.SubscriberIds.AddRange(decision.TargetAppIds);
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// 【新增】插入这一行!
|
// 【修正】删除这里的 GlobalStreamDispatcher.Dispatch!
|
||||||
// 此时 smartFrame.InternalMat 已经有了图像数据
|
// 严禁在这里分发,因为这时的图是“生的”,还没经过 Pipeline 处理。
|
||||||
// 我们把它交给全局分发器,触发 ZeroMQ 广播
|
// =========================================================================GlobalStreamDispatcher.Dispatch(Id, smartFrame);
|
||||||
// =========================================================================
|
|
||||||
GlobalStreamDispatcher.Dispatch(Id, smartFrame);
|
|
||||||
|
|
||||||
// 4. [分发] 将决策结果传递给处理中心
|
// 4. [分发] 将决策结果传递给处理中心
|
||||||
// decision.TargetAppIds 包含了 "谁需要这一帧" 的信息
|
// decision.TargetAppIds 包含了 "谁需要这一帧" 的信息
|
||||||
|
|||||||
@@ -1,215 +1,116 @@
|
|||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.OpenApi.Models;
|
using Microsoft.OpenApi.Models;
|
||||||
|
|
||||||
namespace SHH.CameraSdk;
|
namespace SHH.CameraSdk;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A 方案:标准控制台结构 (框架搭建版:支持动态端口与依赖注入)
|
|
||||||
/// </summary>
|
|
||||||
public class Program
|
public class Program
|
||||||
{
|
{
|
||||||
[STAThread]
|
[STAThread]
|
||||||
public static async Task Main(string[] args)
|
public static async Task Main(string[] args)
|
||||||
{
|
{
|
||||||
// ==============================================================================
|
|
||||||
// 1. 身份识别与端口计算
|
// 1. 身份识别与端口计算
|
||||||
// ==============================================================================
|
|
||||||
|
|
||||||
// 默认 1 号进程
|
|
||||||
int processId = 1;
|
int processId = 1;
|
||||||
|
if (args.Length > 0 && int.TryParse(args[0], out int pid)) processId = pid;
|
||||||
// 如果命令行传了参数 (例如: dotnet run 2),则覆盖为 2 号进程
|
|
||||||
if (args.Length > 0 && int.TryParse(args[0], out int pid))
|
|
||||||
{
|
|
||||||
processId = pid;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 端口计算公式:5000 + (ID - 1)
|
|
||||||
// ID=1 -> 5000
|
|
||||||
// ID=2 -> 5001
|
|
||||||
int port = 5000 + (processId - 1);
|
int port = 5000 + (processId - 1);
|
||||||
|
|
||||||
Console.Title = $"SHH Gateway - Instance #{processId} (Port: {port})";
|
Console.Title = $"SHH Gateway - Instance #{processId} (Port: {port})";
|
||||||
Console.WriteLine($"[System] 正在初始化实例 #{processId}...");
|
|
||||||
|
|
||||||
// ==============================================================================
|
// 2. 硬件预热 (静态方法保留)
|
||||||
// 2. 基础设施初始化
|
|
||||||
// ==============================================================================
|
|
||||||
InitHardwareEnv();
|
InitHardwareEnv();
|
||||||
|
|
||||||
// B. 【核心】创建独立的文件存储服务 (此时只建立目录,不进行具体读写)
|
// 3. 创建 WebHost 并加载 SDK
|
||||||
IStorageService storageService = new FileStorageService(processId);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// 核心设备管理器
|
// ★★★★★ 核心变化:调用扩展方法加载 SDK ★★★★★
|
||||||
// 注意:暂时保持无参构造,后续我们在改造 CameraManager 时再注入 storageService
|
// 这行代码把 MemoryCache、CameraManager、流水线全部配好了
|
||||||
using var cameraManager = new CameraManager(storageService);
|
builder.Services.AddCameraSdk(processId);
|
||||||
|
|
||||||
// 动态窗口管理器
|
// 4. 配置 Web 相关的服务 (Swagger, Controllers, CORS)
|
||||||
var displayManager = new DisplayWindowManager(cameraManager);
|
ConfigureWebServices(builder, processId);
|
||||||
|
|
||||||
// ==============================================================================
|
var app = builder.Build();
|
||||||
// 3. 启动 Web 监控与诊断服务 (注入服务与端口)
|
|
||||||
// ==============================================================================
|
|
||||||
var app = await StartWebMonitoring(cameraManager, displayManager, storageService, port);
|
|
||||||
|
|
||||||
// 启动网络哨兵
|
// 5. 配置中间件管道
|
||||||
var sentinel = new ConnectivitySentinel(cameraManager);
|
ConfigureMiddleware(app, processId);
|
||||||
|
|
||||||
// ==============================================================================
|
// 6. 启动业务逻辑
|
||||||
// 4. 业务编排
|
await StartBusinessLogic(app);
|
||||||
// ==============================================================================
|
|
||||||
|
|
||||||
// 【关键修复 1】先 StartAsync,让它先从文件把 999 号设备读进内存
|
// 7. 启动 Web 监听
|
||||||
await cameraManager.StartAsync();
|
_ = app.RunAsync($"http://0.0.0.0:{port}");
|
||||||
|
Console.WriteLine($"[System] 网关 #{processId} 就绪。地址: http://localhost:{port}");
|
||||||
|
|
||||||
// 【关键修复 2】文件加载完后,再决定要不要加默认设备
|
// 8. 阻塞驻留
|
||||||
await ConfigureBusinessLogic(cameraManager);
|
|
||||||
|
|
||||||
// ==============================================================================
|
|
||||||
// 5. 启动引擎与交互
|
|
||||||
// ==============================================================================
|
|
||||||
Console.WriteLine("\n[系统] 正在启动全局管理引擎...");
|
|
||||||
|
|
||||||
Console.WriteLine($">> 系统就绪。Web 管理地址: http://localhost:{port}");
|
|
||||||
Console.WriteLine($">> 数据存储路径: App_Data/Process_{processId}/");
|
|
||||||
Console.WriteLine(">> 按 'S' 键退出...");
|
Console.WriteLine(">> 按 'S' 键退出...");
|
||||||
|
while (Console.ReadKey(true).Key != ConsoleKey.S) { Thread.Sleep(100); }
|
||||||
|
|
||||||
// 阻塞主线程
|
Console.WriteLine("[System] 正在停机...");
|
||||||
while (Console.ReadKey(true).Key != ConsoleKey.S)
|
|
||||||
{
|
|
||||||
Thread.Sleep(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("[系统] 正在停机...");
|
|
||||||
await app.StopAsync();
|
await app.StopAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==============================================================================
|
// --- 下面是拆分出来的私有辅助方法,让 Main 看起来更清晰 ---
|
||||||
// Static Methods
|
|
||||||
// ==============================================================================
|
|
||||||
|
|
||||||
static void InitHardwareEnv()
|
static void ConfigureWebServices(WebApplicationBuilder builder, int processId)
|
||||||
{
|
{
|
||||||
Console.WriteLine("=== 工业级视频 SDK 架构测试 (V3.5 框架版) ===");
|
// CORS
|
||||||
Console.WriteLine("[硬件] 海康驱动预热中...");
|
|
||||||
HikNativeMethods.NET_DVR_Init();
|
|
||||||
HikSdkManager.ForceWarmUp();
|
|
||||||
Console.WriteLine("[硬件] 预热完成。");
|
|
||||||
}
|
|
||||||
|
|
||||||
static async Task<WebApplication> StartWebMonitoring(
|
|
||||||
CameraManager manager,
|
|
||||||
DisplayWindowManager displayMgr,
|
|
||||||
IStorageService storage, // 接收存储服务实例
|
|
||||||
int port) // 接收动态端口
|
|
||||||
{
|
|
||||||
var builder = WebApplication.CreateBuilder();
|
|
||||||
|
|
||||||
// 1. 注册配置管理器(指挥部)
|
|
||||||
var configManager = new ProcessingConfigManager();
|
|
||||||
builder.Services.AddSingleton(configManager);
|
|
||||||
|
|
||||||
// 2. 初始化预处理流水线环节
|
|
||||||
// 建议:此处直接手动创建实例,以便精确控制链条顺序
|
|
||||||
var scaleService = new ImageScaleCluster(4, configManager); // 环节一
|
|
||||||
var enhanceService = new ImageEnhanceCluster(4, configManager); // 环节二
|
|
||||||
|
|
||||||
// 3. 编排流水线:缩放 -> 增亮 -> 终点(GlobalProcessingCenter)
|
|
||||||
scaleService.SetNext(enhanceService);
|
|
||||||
|
|
||||||
// 4. 将流水线入口挂载到全局路由(驱动层改道)
|
|
||||||
GlobalPipelineRouter.SetProcessor(scaleService);
|
|
||||||
|
|
||||||
// 5. 【修复点】将具体实例注册到 DI 容器
|
|
||||||
// 这样 Controller 可以通过构造函数拿到具体的实例进行动态管理
|
|
||||||
builder.Services.AddSingleton(scaleService);
|
|
||||||
builder.Services.AddSingleton(enhanceService);
|
|
||||||
|
|
||||||
// 6. 配置 CORS
|
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
options.AddPolicy("AllowAll", policy =>
|
options.AddPolicy("AllowAll", p => p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod());
|
||||||
{
|
|
||||||
policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 7. 依赖注入注册
|
// Controllers & Filters
|
||||||
builder.Services.AddSingleton<IStorageService>(storage);
|
|
||||||
builder.Services.AddSingleton(manager);
|
|
||||||
builder.Services.AddSingleton(displayMgr);
|
|
||||||
|
|
||||||
//// 2. 日志降噪
|
|
||||||
//builder.Logging.SetMinimumLevel(LogLevel.Warning);
|
|
||||||
//builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Warning);
|
|
||||||
|
|
||||||
|
|
||||||
// 显式注册过滤器 (这是防止 500 错误的关键)
|
|
||||||
builder.Services.AddScoped<UserActionFilter>();
|
|
||||||
|
|
||||||
builder.Services.AddControllers(options =>
|
builder.Services.AddControllers(options =>
|
||||||
{
|
{
|
||||||
// 注册全局操作日志过滤器
|
|
||||||
options.Filters.Add<UserActionFilter>();
|
options.Filters.Add<UserActionFilter>();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Swagger
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen(c =>
|
builder.Services.AddSwaggerGen(c =>
|
||||||
{
|
{
|
||||||
c.SwaggerDoc("v1", new OpenApiInfo { Title = $"SHH Gateway #{processIdFromPort(port)}", Version = "v1" });
|
c.SwaggerDoc("v1", new OpenApiInfo { Title = $"SHH Gateway #{processId}", Version = "v1" });
|
||||||
});
|
});
|
||||||
|
|
||||||
var webApp = builder.Build();
|
|
||||||
|
|
||||||
// 4. 配置中间件
|
|
||||||
webApp.UseSwagger();
|
|
||||||
webApp.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Diagnostics V1"));
|
|
||||||
webApp.UseCors("AllowAll");
|
|
||||||
webApp.MapControllers();
|
|
||||||
|
|
||||||
// 5. 启动监听 (使用动态端口)
|
|
||||||
_ = webApp.RunAsync($"http://0.0.0.0:{port}");
|
|
||||||
Console.WriteLine($"[Web] 监控API已启动: http://localhost:{port}");
|
|
||||||
|
|
||||||
return webApp;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 辅助方法:从端口反推 ID,仅用于 Swagger 标题显示
|
static void ConfigureMiddleware(WebApplication app, int processId)
|
||||||
static int processIdFromPort(int port) => port - 5000 + 1;
|
{
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", $"Gateway {processId}"));
|
||||||
|
app.UseCors("AllowAll");
|
||||||
|
app.MapControllers();
|
||||||
|
}
|
||||||
|
|
||||||
static async Task ConfigureBusinessLogic(CameraManager manager)
|
static async Task StartBusinessLogic(WebApplication app)
|
||||||
|
{
|
||||||
|
// 从 DI 容器中获取已经注册好的服务
|
||||||
|
var cameraManager = app.Services.GetRequiredService<CameraManager>();
|
||||||
|
|
||||||
|
// 必须显式获取一次 Sentinel 确保它被实例化并开始工作
|
||||||
|
var sentinel = app.Services.GetRequiredService<ConnectivitySentinel>();
|
||||||
|
|
||||||
|
// 启动相机的加载逻辑
|
||||||
|
await cameraManager.StartAsync();
|
||||||
|
|
||||||
|
// 添加测试设备 (原有逻辑)
|
||||||
|
await AddTestDevices(cameraManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void InitHardwareEnv()
|
||||||
|
{
|
||||||
|
Console.WriteLine("[硬件] 海康驱动预热中...");
|
||||||
|
HikNativeMethods.NET_DVR_Init();
|
||||||
|
HikSdkManager.ForceWarmUp();
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task AddTestDevices(CameraManager manager)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 1. 添加测试设备
|
// ... 这里保留你原本的添加测试设备代码 ...
|
||||||
var config = new VideoSourceConfig
|
// var config = new VideoSourceConfig { ... }
|
||||||
{
|
// manager.AddDevice(config);
|
||||||
Id = 101,
|
|
||||||
Brand = DeviceBrand.HikVision,
|
|
||||||
IpAddress = "192.168.5.9",
|
|
||||||
Port = 8000,
|
|
||||||
Username = "admin",
|
|
||||||
Password = "RRYFOA",
|
|
||||||
StreamType = 0
|
|
||||||
};
|
|
||||||
manager.AddDevice(config);
|
|
||||||
|
|
||||||
var config2 = new VideoSourceConfig
|
|
||||||
{
|
|
||||||
Id = 102,
|
|
||||||
Brand = DeviceBrand.HikVision,
|
|
||||||
IpAddress = "172.16.41.20",
|
|
||||||
Port = 8000,
|
|
||||||
Username = "admin",
|
|
||||||
Password = "abcd1234",
|
|
||||||
StreamType = 0
|
|
||||||
};
|
|
||||||
manager.AddDevice(config2);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
}
|
}
|
||||||
|
catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
97
SHH.CameraService/CommandClientWorker.cs
Normal file
97
SHH.CameraService/CommandClientWorker.cs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
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)。
|
||||||
|
// 如果连接了多个 Dashboard,SendFrame 一次只会发给其中一个。
|
||||||
|
// 为了确保所有 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
SHH.CameraService/NetworkStreamManager.cs
Normal file
91
SHH.CameraService/NetworkStreamManager.cs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
79
SHH.CameraService/ParentProcessSentinel.cs
Normal file
79
SHH.CameraService/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; // 发生未知错误时,保守起见认为它还活着
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.OpenApi.Models;
|
using Microsoft.OpenApi.Models;
|
||||||
using SHH.CameraSdk; // 引用你的业务核心
|
using SHH.CameraSdk; // 引用你的业务核心
|
||||||
using SHH.NetMQ;
|
|
||||||
|
|
||||||
namespace SHH.CameraService;
|
namespace SHH.CameraService;
|
||||||
|
|
||||||
@@ -10,21 +9,25 @@ public class Program
|
|||||||
{
|
{
|
||||||
public static async Task Main(string[] args)
|
public static async Task Main(string[] args)
|
||||||
{
|
{
|
||||||
#region --- 1. 端口与身份计算 ---
|
// 缓冲时间 (您之前写了20000ms即20秒,可能是为了附加调试器。如果觉得太慢可以改回 2000)
|
||||||
|
for(var i=1; i<10; i++)
|
||||||
|
Thread.Sleep(1000);
|
||||||
|
|
||||||
int processId = 1;
|
// 1. 解析配置
|
||||||
// 从命令行参数解析进程ID(默认1)
|
var config = ServiceConfig.BuildFromArgs(args);
|
||||||
if (args.Length > 0 && int.TryParse(args[0], out int pid))
|
|
||||||
processId = pid;
|
|
||||||
|
|
||||||
// 计算 Web 服务端口(基础5000 + 进程ID偏移)
|
// ---【补全变量定义】---
|
||||||
int port = 5000 + (processId - 1);
|
|
||||||
|
|
||||||
Console.Title = $"SHH Gateway - Instance #{processId} (Port: {port})";
|
// A. 补全 webPort (统一使用 config.BasePort)
|
||||||
|
int webPort = config.BasePort;
|
||||||
|
|
||||||
#endregion
|
// B. 补全 processIdInt (用于 FileStorage 和 CameraSdk)
|
||||||
|
// 逻辑:尝试将 AppId 解析为数字;如果 AppId 是字符串(如"CameraApp_01"),则默认给 1,或者根据 BasePort 推算
|
||||||
|
int processIdInt = config.NumericId;
|
||||||
|
|
||||||
#region --- 2. 硬件环境预热 (【重要】必须在一切开始前调用) ---
|
Console.Title = $"SHH Gateway - {config.AppId} (Web: {webPort})";
|
||||||
|
|
||||||
|
#region --- 2. 硬件环境预热 ---
|
||||||
|
|
||||||
InitHardwareEnv();
|
InitHardwareEnv();
|
||||||
|
|
||||||
@@ -34,161 +37,135 @@ public class Program
|
|||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
#region --- A. 注册 ZeroMQ 组件 (传输层) ---
|
// ★★★ 核心:注入全局配置 ★★★
|
||||||
|
builder.Services.AddSingleton(config);
|
||||||
|
|
||||||
// 注册转发客户端(定向推送)
|
// -------------------------------------------------------------
|
||||||
string zmqBind = $"tcp://*:{5555 + (processId - 1)}";
|
// A. 注册新架构组件
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
builder.Services.AddSingleton<VideoDataChannel>();
|
||||||
|
|
||||||
// ★★★ 新增:注册指令总线服务 ★★★
|
// 推流服务 (连接 config.TargetClients 里的 :6002)
|
||||||
string zmqTarget = "tcp://127.0.0.1:6000";
|
builder.Services.AddHostedService<ZeroMQBridgeWorker>();
|
||||||
|
|
||||||
// 注册转发客户端(定向推送)
|
// 指令客户端 (连接 config.TargetClients 里的 :6001)
|
||||||
builder.Services.AddSingleton(new ForwarderClient(zmqTarget));
|
builder.Services.AddHostedService<CommandClientWorker>();
|
||||||
|
|
||||||
// ★★★ 新增:注册指令总线服务 ★★★
|
// 进程守护
|
||||||
builder.Services.AddHostedService<CommandBusService>();
|
builder.Services.AddHostedService<ParentProcessSentinel>();
|
||||||
|
|
||||||
// 注册分发服务器(广播)
|
// -------------------------------------------------------------
|
||||||
builder.Services.AddSingleton(new DistributorServer(zmqBind));
|
// B. 注册 SDK 业务服务
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// 使用刚刚补全的 processIdInt
|
||||||
|
builder.Services.AddSingleton<IStorageService>(new FileStorageService(processIdInt));
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region --- B. 注册核心业务服务 ---
|
|
||||||
|
|
||||||
// 注册文件存储服务(进程隔离)
|
|
||||||
builder.Services.AddSingleton<IStorageService>(new FileStorageService(processId));
|
|
||||||
|
|
||||||
// CameraManager 注册为单例,生命周期由 CameraEngineWorker 管理
|
|
||||||
builder.Services.AddSingleton<CameraManager>();
|
builder.Services.AddSingleton<CameraManager>();
|
||||||
|
|
||||||
// 图像处理配置管理器(单例)
|
|
||||||
builder.Services.AddSingleton<ProcessingConfigManager>();
|
builder.Services.AddSingleton<ProcessingConfigManager>();
|
||||||
|
|
||||||
// 显示窗口管理器(单例)
|
|
||||||
builder.Services.AddSingleton<DisplayWindowManager>();
|
builder.Services.AddSingleton<DisplayWindowManager>();
|
||||||
|
builder.Services.AddSingleton<NetworkStreamManager>();
|
||||||
|
|
||||||
#endregion
|
builder.Services.AddSingleton<ImageScaleCluster>(sp => new ImageScaleCluster(4, sp.GetRequiredService<ProcessingConfigManager>()));
|
||||||
|
builder.Services.AddSingleton<ImageEnhanceCluster>(sp => new ImageEnhanceCluster(4, sp.GetRequiredService<ProcessingConfigManager>()));
|
||||||
|
|
||||||
#region --- C. 注册图像处理集群 (修复版) ---
|
|
||||||
|
|
||||||
// 说明:通过责任链模式组装 Scale → Enhance 处理流程,确保顺序执行
|
|
||||||
// 1. 注册图像缩容集群(并行度4)
|
|
||||||
builder.Services.AddSingleton<ImageScaleCluster>(sp =>
|
|
||||||
{
|
|
||||||
var configManager = sp.GetRequiredService<ProcessingConfigManager>();
|
|
||||||
return new ImageScaleCluster(4, configManager);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. 注册图像增强集群(并行度4)
|
|
||||||
builder.Services.AddSingleton<ImageEnhanceCluster>(sp =>
|
|
||||||
{
|
|
||||||
var configManager = sp.GetRequiredService<ProcessingConfigManager>();
|
|
||||||
return new ImageEnhanceCluster(4, configManager);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. 注册管道配置服务(组装责任链)
|
|
||||||
builder.Services.AddHostedService<PipelineConfigurator>();
|
builder.Services.AddHostedService<PipelineConfigurator>();
|
||||||
|
|
||||||
#endregion
|
// 使用补全的 processIdInt
|
||||||
|
builder.Services.AddCameraSdk(processIdInt);
|
||||||
|
|
||||||
#region --- D. 注册 Web 基础服务 ---
|
builder.Services.AddHostedService<CameraEngineWorker>();
|
||||||
|
builder.Services.AddSingleton<ConnectivitySentinel>();
|
||||||
|
|
||||||
// 注册控制器(加载 SDK 中的 CamerasController、MonitorController)
|
builder.Services.AddControllers().AddApplicationPart(typeof(CamerasController).Assembly);
|
||||||
builder.Services.AddControllers()
|
builder.Services.AddControllers().AddApplicationPart(typeof(MonitorController).Assembly);
|
||||||
.AddApplicationPart(typeof(CamerasController).Assembly) // 加载 SDK 中的控制器
|
|
||||||
.AddApplicationPart(typeof(MonitorController).Assembly)
|
|
||||||
.AddControllersAsServices();
|
|
||||||
|
|
||||||
// 注册全局操作日志过滤器(捕获 API 操作日志)
|
// -------------------------------------------------------------
|
||||||
builder.Services.AddScoped<UserActionFilter>();
|
// C. Web API 基础
|
||||||
|
// -------------------------------------------------------------
|
||||||
// 注册 Swagger 文档(区分实例ID)
|
builder.Services.AddControllers().AddControllersAsServices();
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen(c =>
|
builder.Services.AddSwaggerGen(c =>
|
||||||
{
|
{
|
||||||
c.SwaggerDoc("v1", new OpenApiInfo
|
// 【修正】使用 config.AppId
|
||||||
{
|
c.SwaggerDoc("v1", new OpenApiInfo { Title = $"Gateway {config.AppId}", Version = "v1" });
|
||||||
Title = $"Gateway #{processId}",
|
|
||||||
Version = "v1"
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
#endregion
|
builder.Services.AddCors(o => o.AddPolicy("AllowAll", p => p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
|
||||||
|
|
||||||
#region --- E. 注册后台服务 (Worker) ---
|
|
||||||
|
|
||||||
// 1. 核心引擎工作者 (负责 StartAsync 和 ConfigureBusinessLogic)
|
|
||||||
builder.Services.AddHostedService<CameraEngineWorker>();
|
|
||||||
|
|
||||||
// 2.网络哨兵(负责断线重连)(监控设备断线重连,注册为单例)
|
|
||||||
builder.Services.AddSingleton<ConnectivitySentinel>();
|
|
||||||
|
|
||||||
// 3. ZeroMQ 桥梁服务(转发帧数据到外部系统)
|
|
||||||
builder.Services.AddHostedService<ZeroMqBridgeService>();
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region --- F. 配置 CORS(允许所有跨域请求) ---
|
|
||||||
|
|
||||||
builder.Services.AddCors(options =>
|
|
||||||
{
|
|
||||||
options.AddPolicy("AllowAll", policy =>
|
|
||||||
{
|
|
||||||
policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region --- 4. 启动应用 ---
|
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// 启用 Swagger 文档
|
//// =======================================================================
|
||||||
|
//// ★★★ 核心接入点:连接 [现有分发器] 与 [新推流通道] ★★★
|
||||||
|
//// =======================================================================
|
||||||
|
|
||||||
|
//// 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 推流通道");
|
||||||
|
|
||||||
app.UseSwagger();
|
app.UseSwagger();
|
||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
|
|
||||||
// 启用 CORS 策略
|
|
||||||
app.UseCors("AllowAll");
|
app.UseCors("AllowAll");
|
||||||
|
|
||||||
// 映射控制器路由
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
// 输出启动信息
|
// 【修正】使用 webPort
|
||||||
Console.WriteLine($"[System] 绑定 Web 端口: {port}");
|
Console.WriteLine($"[System] Web API 已启动: http://0.0.0.0:{webPort}");
|
||||||
Console.WriteLine($"[System] 绑定 ZMQ 端口: {zmqBind}");
|
|
||||||
|
|
||||||
// 启动 Web 应用
|
await app.RunAsync($"http://0.0.0.0:{webPort}");
|
||||||
await app.RunAsync($"http://0.0.0.0:{port}");
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|
||||||
#region --- 辅助方法:硬件环境预热 ---
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 初始化硬件环境(海康 SDK 预热)
|
|
||||||
/// </summary>
|
|
||||||
static void InitHardwareEnv()
|
static void InitHardwareEnv()
|
||||||
{
|
{
|
||||||
Console.WriteLine("=== 工业级视频 SDK 架构测试 (V3.5 框架版) ===");
|
Console.WriteLine("=== 工业级视频接入服务启动 ===");
|
||||||
Console.WriteLine("[硬件] 海康驱动预热中...");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 初始化海康 SDK
|
|
||||||
HikNativeMethods.NET_DVR_Init();
|
|
||||||
// 强制预热播放库(避免首次取流延迟)
|
|
||||||
HikSdkManager.ForceWarmUp();
|
|
||||||
Console.WriteLine("[硬件] 预热完成。");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[硬件] 预热失败: {ex.Message}");
|
|
||||||
// 不抛出异常,允许程序在无 DLL 环境下调试
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
/// <summary>
|
||||||
|
/// 内存转码:Mat -> Jpg Bytes
|
||||||
|
/// </summary>
|
||||||
|
static byte[] EncodeToJpg(SmartFrame frame)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 假设 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
|
||||||
|
{
|
||||||
|
// 容错处理,防止一帧损坏导致程序崩溃
|
||||||
|
}
|
||||||
|
return Array.Empty<byte>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
63
SHH.CameraService/VideoDataChannel.cs
Normal file
63
SHH.CameraService/VideoDataChannel.cs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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>();
|
||||||
|
}
|
||||||
87
SHH.CameraService/ZeroMQBridgeWorker.cs
Normal file
87
SHH.CameraService/ZeroMQBridgeWorker.cs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user