diff --git a/SHH.CameraDashboard/App.xaml.cs b/SHH.CameraDashboard/App.xaml.cs index 41bc3e7..22b9f30 100644 --- a/SHH.CameraDashboard/App.xaml.cs +++ b/SHH.CameraDashboard/App.xaml.cs @@ -1,4 +1,6 @@ -using System.Collections.ObjectModel; +using SHH.CameraDashboard.Services; +using SHH.ProcessLaunchers; +using System.Collections.ObjectModel; using System.Windows; namespace SHH.CameraDashboard @@ -8,10 +10,30 @@ namespace SHH.CameraDashboard /// public partial class App : Application { + #region 定义全局单例 + + /// + /// 进程管理器 + /// + public static ProcessManager ProcManager { get; private set; } + = new ProcessManager(new ProcessDashboardLogger()); + + #endregion + + protected override async void OnStartup(StartupEventArgs e) { base.OnStartup(e); + // 启动视频流接收 (Port 6002) + StreamReceiverService.Instance.Start(6002); + + // 启动指令服务 (Port 6001) + CommandServer.Instance.Start(6001); + + + // 现在我们来配置启动 + // 1. 【核心代码】程序启动时,异步读取配置文件 var savedNodes = await LocalStorageService.LoadAsync>(AppPaths.ServiceNodesConfig); if (savedNodes != null) @@ -20,6 +42,43 @@ namespace SHH.CameraDashboard 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. 启动主窗口 // 注意:如果 LoadAsync 耗时较长,这里可能会导致启动画面停留, // 实际项目中可以搞一个 Splash Screen (启动屏) 来做这件事。 @@ -35,7 +94,13 @@ namespace SHH.CameraDashboard // 1. 这里可以处理统一的资源清理逻辑 (如停止摄像头推流、关闭数据库连接) // 2. 保存用户配置 // 3. 彻底退出 + StreamReceiverService.Instance.Dispose(); + CommandServer.Instance.Dispose(); + // 停止所有子进程 + ProcManager.StopAll(); + Current.Shutdown(); } + } } \ No newline at end of file diff --git a/SHH.CameraDashboard/Invokes/BitmapHelper.cs b/SHH.CameraDashboard/Invokes/BitmapHelper.cs new file mode 100644 index 0000000..c7f745b --- /dev/null +++ b/SHH.CameraDashboard/Invokes/BitmapHelper.cs @@ -0,0 +1,45 @@ +using System.IO; +using System.Windows.Media.Imaging; + +namespace SHH.CameraDashboard; + +/// +/// [UI层] 图像数据转换助手 +/// 职责:将内存中的二进制 JPEG 数据高效转换为 WPF 可用的 BitmapImage +/// 优化:使用 OnLoad 缓存策略和 Freeze 冻结对象,支持跨线程访问,防止内存泄漏 +/// +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; + } + } +} \ No newline at end of file diff --git a/SHH.CameraDashboard/Invokes/CommandServer.cs b/SHH.CameraDashboard/Invokes/CommandServer.cs new file mode 100644 index 0000000..52fa13a --- /dev/null +++ b/SHH.CameraDashboard/Invokes/CommandServer.cs @@ -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; + +/// +/// [Dashboard端] 指令控制服务 +/// 职责:双向通信通道。接收 Service 心跳/响应,向 Service 发送控制指令。 +/// 核心模式:ROUTER (Dashboard) <--> DEALER (Service) +/// +public class CommandServer : IDisposable +{ + // 单例模式 + public static CommandServer Instance { get; } = new CommandServer(); + + // 事件:收到消息时触发 (ServiceId, MessageContent) + public event Action? OnMessageReceived; + + private RouterSocket? _routerSocket; + private NetMQPoller? _poller; + + // 【关键新增】发送队列:用于解决跨线程发送的安全问题 + // UI线程 -> Enqueue -> Poller线程 -> Socket.Send + private NetMQQueue? _sendQueue; + + public int ListenPort { get; private set; } + public bool IsRunning => _poller != null && _poller.IsRunning; + + // 在线设备表 (可选,用于记录谁在线) + // Key: ServiceId (Identity字符串) + private readonly ConcurrentDictionary _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(); + _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 感知 + } + } + + /// + /// 处理来自 Service 的网络消息 (运行在 Poller 线程) + /// + 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}"); + } + } + + /// + /// 处理发送队列 (运行在 Poller 线程) + /// + private void OnQueueReady(object? sender, NetMQQueueEventArgs 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}"); + } + } + + /// + /// 发送指令 (线程安全,可由 UI 线程调用) + /// + 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; } = ""; + } +} \ No newline at end of file diff --git a/SHH.CameraDashboard/Invokes/ServiceLaunchOptions.cs b/SHH.CameraDashboard/Invokes/ServiceLaunchOptions.cs new file mode 100644 index 0000000..6a32003 --- /dev/null +++ b/SHH.CameraDashboard/Invokes/ServiceLaunchOptions.cs @@ -0,0 +1,37 @@ +using System; + +namespace SHH.CameraDashboard; + +/// +/// [Dashboard端] Service 启动参数构建器 +/// 职责:生成标准化的命令行参数字符串,告诉 Service 如何反向连接 +/// +public static class ServiceLaunchOptions +{ + /// + /// 生成启动参数 + /// + /// 给子服务起的唯一ID (如 "CamService_01") + /// Dashboard 的 IP (通常是 127.0.0.1) + /// Dashboard 监听视频的端口 (如 6000) + /// 指定子服务 WebAPI 监听的端口 (如 5005) + /// 命令行参数字符串 + 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 是保留位,暂不用 + } +} \ No newline at end of file diff --git a/SHH.CameraDashboard/Invokes/StreamReceiverService.cs b/SHH.CameraDashboard/Invokes/StreamReceiverService.cs new file mode 100644 index 0000000..ebbecd4 --- /dev/null +++ b/SHH.CameraDashboard/Invokes/StreamReceiverService.cs @@ -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? 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] 接收循环已停止"); + } + + /// + /// 停止服务(支持停止后重新 Start) + /// + 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(); + } +} \ No newline at end of file diff --git a/SHH.CameraDashboard/MainWindowViewModel.cs b/SHH.CameraDashboard/MainWindowViewModel.cs index 79af3ba..b5b1eef 100644 --- a/SHH.CameraDashboard/MainWindowViewModel.cs +++ b/SHH.CameraDashboard/MainWindowViewModel.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Diagnostics; using System.Windows; using System.Windows.Input; @@ -9,7 +10,7 @@ namespace SHH.CameraDashboard /// 它封装了主窗口的所有业务逻辑、状态和用户交互命令。 /// 它还作为一个中心协调者,响应全局事件并管理子视图(如右侧编辑面板和蒙板)。 /// - public class MainWindowViewModel : INotifyPropertyChanged + public class MainWindowViewModel : INotifyPropertyChanged, IDisposable { #region --- 构造函数 --- @@ -520,5 +521,9 @@ namespace SHH.CameraDashboard get => _mainContent; set { _mainContent = value; OnPropertyChanged(); } } + + public void Dispose() + { + } } } \ No newline at end of file diff --git a/SHH.CameraDashboard/Pages/CameraWall/VideoTileViewModel.cs b/SHH.CameraDashboard/Pages/CameraWall/VideoTileViewModel.cs index 85ac187..ae81e3c 100644 --- a/SHH.CameraDashboard/Pages/CameraWall/VideoTileViewModel.cs +++ b/SHH.CameraDashboard/Pages/CameraWall/VideoTileViewModel.cs @@ -1,87 +1,76 @@ -using SHH.Contracts; -using System.IO; -using System.Windows; -using System.Windows.Media; +using System.Windows; 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 string _cameraName; + public string CameraName { - // --- 绑定属性 --- + get => _cameraName; + set { _cameraName = value; OnPropertyChanged(); } + } - private ImageSource _displayImage; - public ImageSource DisplayImage + private string _statusInfo; + public string StatusInfo + { + get => _statusInfo; + set { _statusInfo = value; OnPropertyChanged(); } + } + + private BitmapImage _videoSource; + public BitmapImage VideoSource + { + get => _videoSource; + set { _videoSource = value; OnPropertyChanged(); } + } + + // --- 构造函数 --- + public VideoTileViewModel(string cameraId, string name) + { + _boundCameraId = cameraId; + CameraName = name; + StatusInfo = "等待信号..."; + + // 【修正 1】直接订阅单例服务 + // 不需要判断 null,因为 Instance 是静态初始化的,永远存在 + StreamReceiverService.Instance.OnFrameReceived += OnGlobalFrameReceived; + } + + // --- 事件回调 (后台线程) --- + private void OnGlobalFrameReceived(string cameraId, byte[] jpgData) + { + // 1. 过滤:不是我的画面,直接忽略 + if (cameraId != _boundCameraId) return; + + // 2. 解码:耗时操作在后台完成 + var bitmap = BitmapHelper.ToBitmapImage(jpgData); + if (bitmap == null) return; + + // 3. 【修正 2】恢复 UI 更新逻辑 + // 必须使用 Dispatcher,因为 VideoSource 绑定在界面上,只能在主线程修改 + Application.Current.Dispatcher.InvokeAsync(() => { - get => _displayImage; - set => SetProperty(ref _displayImage, value); - } + VideoSource = bitmap; - private string _cameraName; - public string CameraName - { - get => _cameraName; - set => SetProperty(ref _cameraName, value); - } + // 更新状态信息 (例如显示当前时间和数据大小) + StatusInfo = $"{DateTime.Now:HH:mm:ss} | {jpgData.Length / 1024} KB"; + }); + } - private string _statusInfo; - public string StatusInfo - { - get => _statusInfo; - set => SetProperty(ref _statusInfo, value); - } + // --- 资源清理 --- + public void Unload() + { + // 【修正 3】从单例服务取消订阅 + // 这一步至关重要,否则切换页面时会内存泄漏 + StreamReceiverService.Instance.OnFrameReceived -= OnGlobalFrameReceived; - private bool _isConnected; - public bool IsConnected - { - get => _isConnected; - set => SetProperty(ref _isConnected, value); - } - - // --- 构造函数 --- - public VideoTileViewModel(string ip, int port, string name) - { - CameraName = name; - StatusInfo = "连接中..."; - - IsConnected = true; - } - - private void HandleNewFrame(VideoPayload payload) - { - // 必须回到 UI 线程更新 ImageSource - Application.Current.Dispatcher.Invoke(() => - { - // 1. 更新图片 - byte[] data = payload.TargetImageBytes ?? payload.OriginalImageBytes; - if (data != null && data.Length > 0) - { - DisplayImage = ByteToBitmap(data); - } - - // 2. 更新状态文字 - StatusInfo = $"{payload.CaptureTime:HH:mm:ss} | {data?.Length / 1024} KB"; - }); - } - - // 简单的 Bytes 转 BitmapImage (生产环境建议优化为 WriteableBitmap) - private BitmapImage ByteToBitmap(byte[] bytes) - { - var bitmap = new BitmapImage(); - using (var stream = new MemoryStream(bytes)) - { - bitmap.BeginInit(); - bitmap.CacheOption = BitmapCacheOption.OnLoad; - bitmap.StreamSource = stream; - bitmap.EndInit(); - } - bitmap.Freeze(); // 必须冻结才能跨线程 - return bitmap; - } - - public void Dispose() - { - IsConnected = false; - } + // 清空图片引用,帮助 GC 回收内存 + VideoSource = null; } } \ No newline at end of file diff --git a/SHH.CameraDashboard/Pages/CameraWall/VideoWallViewModel.cs b/SHH.CameraDashboard/Pages/CameraWall/VideoWallViewModel.cs index 3a301f3..e9ff5b6 100644 --- a/SHH.CameraDashboard/Pages/CameraWall/VideoWallViewModel.cs +++ b/SHH.CameraDashboard/Pages/CameraWall/VideoWallViewModel.cs @@ -63,12 +63,6 @@ namespace SHH.CameraDashboard //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) { switch (layoutType) diff --git a/SHH.CameraDashboard/SHH.CameraDashboard.csproj b/SHH.CameraDashboard/SHH.CameraDashboard.csproj index 29abf3e..f40e757 100644 --- a/SHH.CameraDashboard/SHH.CameraDashboard.csproj +++ b/SHH.CameraDashboard/SHH.CameraDashboard.csproj @@ -34,6 +34,7 @@ + diff --git a/SHH.CameraDashboard/Services/ProcessDashboardLogger.cs b/SHH.CameraDashboard/Services/ProcessDashboardLogger.cs new file mode 100644 index 0000000..2e3a61f --- /dev/null +++ b/SHH.CameraDashboard/Services/ProcessDashboardLogger.cs @@ -0,0 +1,37 @@ +using SHH.ProcessLaunchers; +using System.Diagnostics; +using System.Windows; + +namespace SHH.CameraDashboard +{ + /// + /// 启动器日志适配器 + /// 将底层 ProcessManager 的日志桥接到 System.Diagnostics.Debug 和 MessageBox + /// + 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); + }); + } + } + } +} \ No newline at end of file diff --git a/SHH.CameraSdk/Configs/NetworkMode.cs b/SHH.CameraSdk/Configs/NetworkMode.cs new file mode 100644 index 0000000..17c3b9f --- /dev/null +++ b/SHH.CameraSdk/Configs/NetworkMode.cs @@ -0,0 +1,26 @@ +namespace SHH.CameraSdk; + +/// +/// 网络连接模式 +/// +public enum NetworkMode +{ + /// + /// [模式0] 被动模式 (Server) + /// 只监听本地端口 (Bind),等待别人来连。 + /// + Passive = 0, + + /// + /// [模式1] 主动模式 (Client) + /// 只主动连接远程目标 (Connect),不监听本地。 + /// + Active = 1, + + /// + /// [模式2] 混合模式 (Both) + /// 既监听本地端口,又主动连接远程目标。 + /// 场景:本机有客户端需要看视频,同时需往云端服务器发视频。 + /// + Hybrid = 2 +} \ No newline at end of file diff --git a/SHH.CameraSdk/Configs/ServiceConfig.cs b/SHH.CameraSdk/Configs/ServiceConfig.cs new file mode 100644 index 0000000..62d3bc1 --- /dev/null +++ b/SHH.CameraSdk/Configs/ServiceConfig.cs @@ -0,0 +1,227 @@ +namespace SHH.CameraSdk; + +/// +/// 全局服务配置模型 (V3 最终版) +/// 负责解析命令行参数,构建网络拓扑和身份标识 +/// +public class ServiceConfig +{ + // ========================================== + // 1. 身份与进程属性 + // ========================================== + + /// + /// 父进程 PID (用于哨兵守护,--pid) + /// + public int ParentPid { get; private set; } + + /// + /// 应用完整标识 (例如 "CameraApp_01", --appid) + /// + public string AppId { get; private set; } = "Unknown_01"; + + /// + /// 【核心】从 AppId 自动提取的数字编号 + /// 规则:取最后一个下划线后的数字 + /// 示例:"CameraApp_05" -> 5 + /// + public int NumericId { get; private set; } = 1; + + // ========================================== + // 2. 网络连接属性 (分流) + // ========================================== + + /// + /// 视频流目标地址列表 (对应 & 符号左侧) + /// ZeroMQBridgeWorker 使用此列表 + /// + public List VideoEndpoints { get; private set; } = new List(); + + /// + /// 指令控制目标地址列表 (对应 & 符号右侧) + /// CommandClientWorker 使用此列表 + /// + public List CommandEndpoints { get; private set; } = new List(); + + /// + /// WebAPI 基础端口 (--ports 的第一个值) + /// + public int BasePort { get; private set; } = 5000; + + /// + /// 端口扫描范围 (--ports 的第二个值) + /// + public int MaxPortRange { get; private set; } = 100; + + /// + /// 网络模式 (--mode) + /// + public NetworkMode Mode { get; private set; } = NetworkMode.Passive; + + // ========================================== + // 3. 辅助属性 + // ========================================== + + /// + /// 是否需要执行 Connect 操作 + /// + 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. 核心解析算法实现 + // ========================================== + + /// + /// 算法:提取下划线后的数字 + /// + 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; + } + + /// + /// 算法:解析 URI 列表并分流 + /// 格式: IP,VideoPort&CommandPort + /// 空缺处理: "&6001" (仅指令), "6002&" (仅视频) + /// + 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); + } + } + } + } +} \ No newline at end of file diff --git a/SHH.CameraSdk/Core/ServiceExtensions.cs b/SHH.CameraSdk/Core/ServiceExtensions.cs new file mode 100644 index 0000000..b62670f --- /dev/null +++ b/SHH.CameraSdk/Core/ServiceExtensions.cs @@ -0,0 +1,82 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace SHH.CameraSdk +{ + public static class ServiceExtensions + { + /// + /// 注入 CameraSdk 的核心服务 + /// 包含:内存缓存、配置管理、图像流水线、存储服务、相机管理、窗口管理等 + /// + /// DI 容器 + /// 进程ID (用于确定存储路径) + /// + public static IServiceCollection AddCameraSdk(this IServiceCollection services, int processId) + { + // ============================================================= + // 1. 基础组件注册 (修复你之前的报错) + // ============================================================= + services.AddMemoryCache(); // ★ 核心修复:添加内存缓存 + + // 注册配置管理器(指挥部) + services.AddSingleton(); + + // ============================================================= + // 2. 图像处理流水线编排 (Pipeline) + // ============================================================= + // 这里我们利用 Factory 模式在注册时完成链条组装,保持了你原有的逻辑 + services.AddSingleton(sp => + { + var configMgr = sp.GetRequiredService(); + + // 手动创建实例 + 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(sp => + { + var scale = sp.GetRequiredService(); + // 这里假设链条没变,或者你可以重新 new 一个,但为了保持引用一致性, + // 建议尽量通过主入口访问,或者在这里重新创建独立的(取决于业务需求)。 + // 按照你之前的逻辑,这里为了简单,我们重新注册一个新的或沿用上一个逻辑。 + // *最佳实践*:如果 enhancing 是依附于 scaling 的,通常只注册 Head。 + // 但为了兼容你原代码的 DI 注册: + return new ImageEnhanceCluster(4, sp.GetRequiredService()); + }); + + // ============================================================= + // 3. 核心业务服务 + // ============================================================= + + // 文件存储服务 (依赖 processId) + services.AddSingleton(sp => new FileStorageService(processId)); + + // 核心设备管理器 (自动注入 IStorageService) + services.AddSingleton(); + + // 动态窗口管理器 (自动注入 CameraManager) + services.AddSingleton(); + + // 网络哨兵 (建议注册为单例,方便后续获取状态) + services.AddSingleton(); + + // ============================================================= + // 4. Web 过滤器 + // ============================================================= + services.AddScoped(); + + return services; + } + } +} \ No newline at end of file diff --git a/SHH.CameraSdk/Drivers/HikVision/HikVideoSource.cs b/SHH.CameraSdk/Drivers/HikVision/HikVideoSource.cs index ca5322a..1e527b5 100644 --- a/SHH.CameraSdk/Drivers/HikVision/HikVideoSource.cs +++ b/SHH.CameraSdk/Drivers/HikVision/HikVideoSource.cs @@ -1,6 +1,5 @@ using OpenCvSharp; using SHH.CameraSdk.HikFeatures; -using System; namespace SHH.CameraSdk; @@ -380,11 +379,9 @@ public class HikVideoSource : BaseVideoSource, smartFrame.SubscriberIds.AddRange(decision.TargetAppIds); // ========================================================================= - // 【新增】插入这一行! - // 此时 smartFrame.InternalMat 已经有了图像数据 - // 我们把它交给全局分发器,触发 ZeroMQ 广播 - // ========================================================================= - GlobalStreamDispatcher.Dispatch(Id, smartFrame); + // 【修正】删除这里的 GlobalStreamDispatcher.Dispatch! + // 严禁在这里分发,因为这时的图是“生的”,还没经过 Pipeline 处理。 + // =========================================================================GlobalStreamDispatcher.Dispatch(Id, smartFrame); // 4. [分发] 将决策结果传递给处理中心 // decision.TargetAppIds 包含了 "谁需要这一帧" 的信息 diff --git a/SHH.CameraSdk/Program.cs b/SHH.CameraSdk/Program.cs index 6967fb1..f8a7de6 100644 --- a/SHH.CameraSdk/Program.cs +++ b/SHH.CameraSdk/Program.cs @@ -1,215 +1,116 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Microsoft.OpenApi.Models; namespace SHH.CameraSdk; -/// -/// A 方案:标准控制台结构 (框架搭建版:支持动态端口与依赖注入) -/// public class Program { [STAThread] public static async Task Main(string[] args) { - // ============================================================================== // 1. 身份识别与端口计算 - // ============================================================================== - - // 默认 1 号进程 int processId = 1; - - // 如果命令行传了参数 (例如: 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 + if (args.Length > 0 && int.TryParse(args[0], out int pid)) processId = pid; int port = 5000 + (processId - 1); Console.Title = $"SHH Gateway - Instance #{processId} (Port: {port})"; - Console.WriteLine($"[System] 正在初始化实例 #{processId}..."); - // ============================================================================== - // 2. 基础设施初始化 - // ============================================================================== + // 2. 硬件预热 (静态方法保留) InitHardwareEnv(); - // B. 【核心】创建独立的文件存储服务 (此时只建立目录,不进行具体读写) - IStorageService storageService = new FileStorageService(processId); + // 3. 创建 WebHost 并加载 SDK + var builder = WebApplication.CreateBuilder(args); - // 核心设备管理器 - // 注意:暂时保持无参构造,后续我们在改造 CameraManager 时再注入 storageService - using var cameraManager = new CameraManager(storageService); + // ★★★★★ 核心变化:调用扩展方法加载 SDK ★★★★★ + // 这行代码把 MemoryCache、CameraManager、流水线全部配好了 + builder.Services.AddCameraSdk(processId); - // 动态窗口管理器 - var displayManager = new DisplayWindowManager(cameraManager); + // 4. 配置 Web 相关的服务 (Swagger, Controllers, CORS) + ConfigureWebServices(builder, processId); - // ============================================================================== - // 3. 启动 Web 监控与诊断服务 (注入服务与端口) - // ============================================================================== - var app = await StartWebMonitoring(cameraManager, displayManager, storageService, port); + var app = builder.Build(); - // 启动网络哨兵 - var sentinel = new ConnectivitySentinel(cameraManager); + // 5. 配置中间件管道 + ConfigureMiddleware(app, processId); - // ============================================================================== - // 4. 业务编排 - // ============================================================================== - - // 【关键修复 1】先 StartAsync,让它先从文件把 999 号设备读进内存 - await cameraManager.StartAsync(); + // 6. 启动业务逻辑 + await StartBusinessLogic(app); - // 【关键修复 2】文件加载完后,再决定要不要加默认设备 - await ConfigureBusinessLogic(cameraManager); + // 7. 启动 Web 监听 + _ = app.RunAsync($"http://0.0.0.0:{port}"); + Console.WriteLine($"[System] 网关 #{processId} 就绪。地址: http://localhost:{port}"); - // ============================================================================== - // 5. 启动引擎与交互 - // ============================================================================== - Console.WriteLine("\n[系统] 正在启动全局管理引擎..."); - - Console.WriteLine($">> 系统就绪。Web 管理地址: http://localhost:{port}"); - Console.WriteLine($">> 数据存储路径: App_Data/Process_{processId}/"); + // 8. 阻塞驻留 Console.WriteLine(">> 按 'S' 键退出..."); + while (Console.ReadKey(true).Key != ConsoleKey.S) { Thread.Sleep(100); } - // 阻塞主线程 - while (Console.ReadKey(true).Key != ConsoleKey.S) - { - Thread.Sleep(100); - } - - Console.WriteLine("[系统] 正在停机..."); + Console.WriteLine("[System] 正在停机..."); await app.StopAsync(); } - // ============================================================================== - // Static Methods - // ============================================================================== + // --- 下面是拆分出来的私有辅助方法,让 Main 看起来更清晰 --- - static void InitHardwareEnv() + static void ConfigureWebServices(WebApplicationBuilder builder, int processId) { - Console.WriteLine("=== 工业级视频 SDK 架构测试 (V3.5 框架版) ==="); - Console.WriteLine("[硬件] 海康驱动预热中..."); - HikNativeMethods.NET_DVR_Init(); - HikSdkManager.ForceWarmUp(); - Console.WriteLine("[硬件] 预热完成。"); - } - - static async Task 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 + // CORS builder.Services.AddCors(options => { - options.AddPolicy("AllowAll", policy => - { - policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod(); - }); + options.AddPolicy("AllowAll", p => p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()); }); - // 7. 依赖注入注册 - builder.Services.AddSingleton(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(); - + // Controllers & Filters builder.Services.AddControllers(options => { - // 注册全局操作日志过滤器 options.Filters.Add(); }); + // Swagger builder.Services.AddEndpointsApiExplorer(); 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 int processIdFromPort(int port) => port - 5000 + 1; + static void ConfigureMiddleware(WebApplication app, int processId) + { + 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(); + + // 必须显式获取一次 Sentinel 确保它被实例化并开始工作 + var sentinel = app.Services.GetRequiredService(); + + // 启动相机的加载逻辑 + await cameraManager.StartAsync(); + + // 添加测试设备 (原有逻辑) + await AddTestDevices(cameraManager); + } + + static void InitHardwareEnv() + { + Console.WriteLine("[硬件] 海康驱动预热中..."); + HikNativeMethods.NET_DVR_Init(); + HikSdkManager.ForceWarmUp(); + } + + static async Task AddTestDevices(CameraManager manager) { try { - // 1. 添加测试设备 - var config = new VideoSourceConfig - { - 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 - { + // ... 这里保留你原本的添加测试设备代码 ... + // var config = new VideoSourceConfig { ... } + // manager.AddDevice(config); } + catch { } } } \ No newline at end of file diff --git a/SHH.CameraService/CommandClientWorker.cs b/SHH.CameraService/CommandClientWorker.cs new file mode 100644 index 0000000..b4fa75b --- /dev/null +++ b/SHH.CameraService/CommandClientWorker.cs @@ -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(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); + } + } + } +} \ No newline at end of file diff --git a/SHH.CameraService/NetworkStreamManager.cs b/SHH.CameraService/NetworkStreamManager.cs new file mode 100644 index 0000000..be4b262 --- /dev/null +++ b/SHH.CameraService/NetworkStreamManager.cs @@ -0,0 +1,91 @@ +using SHH.CameraSdk; +using System.Collections.Concurrent; + +namespace SHH.CameraService; + +/// +/// 网络推流管理器 +/// 职责:管理 ZeroMQ 推流任务的生命周期 +/// 类似于 DisplayWindowManager,它负责订阅数据并将其桥接到传输层 +/// +public class NetworkStreamManager +{ + private readonly VideoDataChannel _channel; + // 记录当前活跃的推流任务,防止重复订阅 + private readonly ConcurrentDictionary _activeStreams = new(); + + public NetworkStreamManager(VideoDataChannel channel) + { + _channel = channel; + } + + /// + /// 启动推流任务 + /// + 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}"); + } + + /// + /// 停止推流任务 + /// + 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(); + } +} \ No newline at end of file diff --git a/SHH.CameraService/ParentProcessSentinel.cs b/SHH.CameraService/ParentProcessSentinel.cs new file mode 100644 index 0000000..5b68170 --- /dev/null +++ b/SHH.CameraService/ParentProcessSentinel.cs @@ -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 _logger; + + public ParentProcessSentinel( + ServiceConfig config, + IHostApplicationLifetime lifetime, + ILogger 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; // 发生未知错误时,保守起见认为它还活着 + } + } +} \ No newline at end of file diff --git a/SHH.CameraService/Program.cs b/SHH.CameraService/Program.cs index 4a503e9..5c69482 100644 --- a/SHH.CameraService/Program.cs +++ b/SHH.CameraService/Program.cs @@ -2,7 +2,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi.Models; using SHH.CameraSdk; // 引用你的业务核心 -using SHH.NetMQ; namespace SHH.CameraService; @@ -10,22 +9,26 @@ public class Program { public static async Task Main(string[] args) { - #region --- 1. 端口与身份计算 --- - - int processId = 1; - // 从命令行参数解析进程ID(默认1) - if (args.Length > 0 && int.TryParse(args[0], out int pid)) - processId = pid; - - // 计算 Web 服务端口(基础5000 + 进程ID偏移) - int port = 5000 + (processId - 1); + // 缓冲时间 (您之前写了20000ms即20秒,可能是为了附加调试器。如果觉得太慢可以改回 2000) + for(var i=1; i<10; i++) + Thread.Sleep(1000); - Console.Title = $"SHH Gateway - Instance #{processId} (Port: {port})"; + // 1. 解析配置 + var config = ServiceConfig.BuildFromArgs(args); - #endregion - - #region --- 2. 硬件环境预热 (【重要】必须在一切开始前调用) --- + // ---【补全变量定义】--- + + // A. 补全 webPort (统一使用 config.BasePort) + int webPort = config.BasePort; + + // B. 补全 processIdInt (用于 FileStorage 和 CameraSdk) + // 逻辑:尝试将 AppId 解析为数字;如果 AppId 是字符串(如"CameraApp_01"),则默认给 1,或者根据 BasePort 推算 + int processIdInt = config.NumericId; + Console.Title = $"SHH Gateway - {config.AppId} (Web: {webPort})"; + + #region --- 2. 硬件环境预热 --- + InitHardwareEnv(); #endregion @@ -34,161 +37,135 @@ public class Program var builder = WebApplication.CreateBuilder(args); - #region --- A. 注册 ZeroMQ 组件 (传输层) --- + // ★★★ 核心:注入全局配置 ★★★ + builder.Services.AddSingleton(config); - // 注册转发客户端(定向推送) - string zmqBind = $"tcp://*:{5555 + (processId - 1)}"; + // ------------------------------------------------------------- + // A. 注册新架构组件 + // ------------------------------------------------------------- + builder.Services.AddSingleton(); - // ★★★ 新增:注册指令总线服务 ★★★ - string zmqTarget = "tcp://127.0.0.1:6000"; + // 推流服务 (连接 config.TargetClients 里的 :6002) + builder.Services.AddHostedService(); - // 注册转发客户端(定向推送) - builder.Services.AddSingleton(new ForwarderClient(zmqTarget)); + // 指令客户端 (连接 config.TargetClients 里的 :6001) + builder.Services.AddHostedService(); - // ★★★ 新增:注册指令总线服务 ★★★ - builder.Services.AddHostedService(); + // 进程守护 + builder.Services.AddHostedService(); - // 注册分发服务器(广播) - builder.Services.AddSingleton(new DistributorServer(zmqBind)); + // ------------------------------------------------------------- + // B. 注册 SDK 业务服务 + // ------------------------------------------------------------- + // 使用刚刚补全的 processIdInt + builder.Services.AddSingleton(new FileStorageService(processIdInt)); - #endregion - - #region --- B. 注册核心业务服务 --- - - // 注册文件存储服务(进程隔离) - builder.Services.AddSingleton(new FileStorageService(processId)); - - // CameraManager 注册为单例,生命周期由 CameraEngineWorker 管理 builder.Services.AddSingleton(); - - // 图像处理配置管理器(单例) builder.Services.AddSingleton(); - - // 显示窗口管理器(单例) builder.Services.AddSingleton(); + builder.Services.AddSingleton(); - #endregion + builder.Services.AddSingleton(sp => new ImageScaleCluster(4, sp.GetRequiredService())); + builder.Services.AddSingleton(sp => new ImageEnhanceCluster(4, sp.GetRequiredService())); - #region --- C. 注册图像处理集群 (修复版) --- - - // 说明:通过责任链模式组装 Scale → Enhance 处理流程,确保顺序执行 - // 1. 注册图像缩容集群(并行度4) - builder.Services.AddSingleton(sp => - { - var configManager = sp.GetRequiredService(); - return new ImageScaleCluster(4, configManager); - }); - - // 2. 注册图像增强集群(并行度4) - builder.Services.AddSingleton(sp => - { - var configManager = sp.GetRequiredService(); - return new ImageEnhanceCluster(4, configManager); - }); - - // 3. 注册管道配置服务(组装责任链) builder.Services.AddHostedService(); - #endregion + // 使用补全的 processIdInt + builder.Services.AddCameraSdk(processIdInt); - #region --- D. 注册 Web 基础服务 --- + builder.Services.AddHostedService(); + builder.Services.AddSingleton(); - // 注册控制器(加载 SDK 中的 CamerasController、MonitorController) - builder.Services.AddControllers() - .AddApplicationPart(typeof(CamerasController).Assembly) // 加载 SDK 中的控制器 - .AddApplicationPart(typeof(MonitorController).Assembly) - .AddControllersAsServices(); + builder.Services.AddControllers().AddApplicationPart(typeof(CamerasController).Assembly); + builder.Services.AddControllers().AddApplicationPart(typeof(MonitorController).Assembly); - // 注册全局操作日志过滤器(捕获 API 操作日志) - builder.Services.AddScoped(); - - // 注册 Swagger 文档(区分实例ID) + // ------------------------------------------------------------- + // C. Web API 基础 + // ------------------------------------------------------------- + builder.Services.AddControllers().AddControllersAsServices(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { - c.SwaggerDoc("v1", new OpenApiInfo - { - Title = $"Gateway #{processId}", - Version = "v1" - }); + // 【修正】使用 config.AppId + c.SwaggerDoc("v1", new OpenApiInfo { Title = $"Gateway {config.AppId}", Version = "v1" }); }); - #endregion - - #region --- E. 注册后台服务 (Worker) --- - - // 1. 核心引擎工作者 (负责 StartAsync 和 ConfigureBusinessLogic) - builder.Services.AddHostedService(); - - // 2.网络哨兵(负责断线重连)(监控设备断线重连,注册为单例) - builder.Services.AddSingleton(); - - // 3. ZeroMQ 桥梁服务(转发帧数据到外部系统) - builder.Services.AddHostedService(); - - #endregion - - #region --- F. 配置 CORS(允许所有跨域请求) --- - - builder.Services.AddCors(options => - { - options.AddPolicy("AllowAll", policy => - { - policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod(); - }); - }); - - #endregion - - #endregion - - #region --- 4. 启动应用 --- + builder.Services.AddCors(o => o.AddPolicy("AllowAll", p => p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); var app = builder.Build(); - // 启用 Swagger 文档 + //// ======================================================================= + //// ★★★ 核心接入点:连接 [现有分发器] 与 [新推流通道] ★★★ + //// ======================================================================= + + //// 1. 获取刚刚注册的数据通道 + //var videoChannel = app.Services.GetRequiredService(); + ////var config = app.Services.GetRequiredService(); + + //// 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.UseSwaggerUI(); - - // 启用 CORS 策略 app.UseCors("AllowAll"); - - // 映射控制器路由 app.MapControllers(); - // 输出启动信息 - Console.WriteLine($"[System] 绑定 Web 端口: {port}"); - Console.WriteLine($"[System] 绑定 ZMQ 端口: {zmqBind}"); + // 【修正】使用 webPort + Console.WriteLine($"[System] Web API 已启动: http://0.0.0.0:{webPort}"); - // 启动 Web 应用 - await app.RunAsync($"http://0.0.0.0:{port}"); + await app.RunAsync($"http://0.0.0.0:{webPort}"); #endregion } - #region --- 辅助方法:硬件环境预热 --- - - /// - /// 初始化硬件环境(海康 SDK 预热) - /// static void InitHardwareEnv() { - Console.WriteLine("=== 工业级视频 SDK 架构测试 (V3.5 框架版) ==="); - Console.WriteLine("[硬件] 海康驱动预热中..."); + Console.WriteLine("=== 工业级视频接入服务启动 ==="); + } + + /// + /// 内存转码:Mat -> Jpg Bytes + /// + static byte[] EncodeToJpg(SmartFrame frame) + { try { - // 初始化海康 SDK - HikNativeMethods.NET_DVR_Init(); - // 强制预热播放库(避免首次取流延迟) - HikSdkManager.ForceWarmUp(); - Console.WriteLine("[硬件] 预热完成。"); + // 假设 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 (Exception ex) + catch { - Console.WriteLine($"[硬件] 预热失败: {ex.Message}"); - // 不抛出异常,允许程序在无 DLL 环境下调试 + // 容错处理,防止一帧损坏导致程序崩溃 } + return Array.Empty(); } - - #endregion } \ No newline at end of file diff --git a/SHH.CameraService/VideoDataChannel.cs b/SHH.CameraService/VideoDataChannel.cs new file mode 100644 index 0000000..422859c --- /dev/null +++ b/SHH.CameraService/VideoDataChannel.cs @@ -0,0 +1,63 @@ +using System.Threading.Channels; + +namespace SHH.CameraService; + +/// +/// 视频数据高速通道 +/// 作用:解耦 采集线程(Producer) 和 发送线程(Consumer) +/// 特性:使用 BoundedChannel,当网络发送慢时,自动丢弃旧帧(DropOldest),防止内存溢出。 +/// +public class VideoDataChannel +{ + // 创建一个有限容量的通道 (容量 5) + // 如果发送端太慢,这就满了,DropOldest 会丢弃最旧的帧,保证实时性 + private readonly Channel _channel = Channel.CreateBounded( + new BoundedChannelOptions(5) + { + FullMode = BoundedChannelFullMode.DropOldest, // 核心策略:丢弃旧帧 + SingleReader = true, // 只有一个 ZeroMQWorker 在读 + SingleWriter = false //可能有多个相机线程在写 + }); + + // ★★★ 新增:公开 Reader 属性,让外部可以直接调用 ReadAsync ★★★ + public ChannelReader Reader => _channel.Reader; + + /// + /// 写入数据 (生产者调用) + /// + public ValueTask WriteAsync(VideoPayload payload) + { + return _channel.Writer.WriteAsync(payload); + } + + /// + /// 读取数据流 (消费者调用) + /// + public IAsyncEnumerable ReadAllAsync(CancellationToken ct) + { + return _channel.Reader.ReadAllAsync(ct); + } +} + +// 附带:如果您的项目中还没有定义 VideoPayload,这里是一个最小实现 +// 如果 SHH.Contracts 中已有,请忽略此类 +public class VideoPayload +{ + /// 相机唯一标识 + public string CameraId { get; set; } = string.Empty; + + /// 采集时间 + public DateTime CaptureTime { get; set; } + + /// 发送时间 + public DateTime DispatchTime { get; set; } + + /// 原始宽 + public int OriginalWidth { get; set; } + + /// 原始高 + public int OriginalHeight { get; set; } + + /// 已编码的图片数据 (JPG) + public byte[] OriginalImageBytes { get; set; } = Array.Empty(); +} \ No newline at end of file diff --git a/SHH.CameraService/ZeroMQBridgeWorker.cs b/SHH.CameraService/ZeroMQBridgeWorker.cs new file mode 100644 index 0000000..6a3e764 --- /dev/null +++ b/SHH.CameraService/ZeroMQBridgeWorker.cs @@ -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); + } + } + } +} \ No newline at end of file