完善契约与客户端、服务端的收发代码
This commit is contained in:
1040
CameraService.txt
Normal file
1040
CameraService.txt
Normal file
File diff suppressed because it is too large
Load Diff
3
SHH.AIServer/SHH.AIServer.slnx
Normal file
3
SHH.AIServer/SHH.AIServer.slnx
Normal file
@@ -0,0 +1,3 @@
|
||||
<Solution>
|
||||
<Project Path="SHH.AIServer/SHH.AIServer.csproj" />
|
||||
</Solution>
|
||||
10
SHH.AIServer/SHH.AIServer/Program.cs
Normal file
10
SHH.AIServer/SHH.AIServer/Program.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace SHH.AIServer
|
||||
{
|
||||
internal class Program
|
||||
{
|
||||
static void Main(string[] args)
|
||||
{
|
||||
Console.WriteLine("Hello, World!");
|
||||
}
|
||||
}
|
||||
}
|
||||
10
SHH.AIServer/SHH.AIServer/SHH.AIServer.csproj
Normal file
10
SHH.AIServer/SHH.AIServer/SHH.AIServer.csproj
Normal file
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -37,6 +37,5 @@ namespace SHH.CameraDashboard
|
||||
// 3. 彻底退出
|
||||
Current.Shutdown();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -203,13 +203,20 @@
|
||||
</DataTemplate>
|
||||
</Border.Resources>
|
||||
<ContentControl Content="{Binding CurrentRightPanelViewModel}" />
|
||||
|
||||
</Border>
|
||||
|
||||
<DockPanel>
|
||||
<local:CameraItemTop DataSource="{Binding ElementName=CameraList, Path=DataContext.SelectedCamera}" DockPanel.Dock="Top" />
|
||||
|
||||
<Grid />
|
||||
<Grid>
|
||||
<Grid.Resources>
|
||||
<DataTemplate DataType="{x:Type local:VideoWallViewModel}">
|
||||
<local:VideoWall />
|
||||
</DataTemplate>
|
||||
</Grid.Resources>
|
||||
|
||||
<ContentControl Content="{Binding MainContent}" />
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</DockPanel>
|
||||
|
||||
|
||||
@@ -93,6 +93,8 @@ namespace SHH.CameraDashboard
|
||||
// 初始化子 ViewModel
|
||||
DiagnosticVM = new ServiceNodesViewModel();
|
||||
|
||||
MainContent = new VideoWallViewModel();
|
||||
|
||||
// 启动异步初始化流程
|
||||
_ = InitializeAsync();
|
||||
}
|
||||
@@ -511,5 +513,12 @@ namespace SHH.CameraDashboard
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private object _mainContent;
|
||||
public object MainContent
|
||||
{
|
||||
get => _mainContent;
|
||||
set { _mainContent = value; OnPropertyChanged(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
65
SHH.CameraDashboard/Pages/CameraWall/VideoTile.xaml
Normal file
65
SHH.CameraDashboard/Pages/CameraWall/VideoTile.xaml
Normal file
@@ -0,0 +1,65 @@
|
||||
<UserControl
|
||||
x:Class="SHH.CameraDashboard.VideoTile"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
d:DesignHeight="200"
|
||||
d:DesignWidth="300"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<UserControl.Resources>
|
||||
<BooleanToVisibilityConverter x:Key="BoolToVis" />
|
||||
</UserControl.Resources>
|
||||
|
||||
<Border
|
||||
Margin="2"
|
||||
Background="Black"
|
||||
BorderBrush="#444"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4">
|
||||
<Grid>
|
||||
<Image Source="{Binding DisplayImage}" Stretch="Uniform" />
|
||||
|
||||
<Grid Background="#222" Visibility="{Binding IsConnected, Converter={StaticResource BoolToVis}, ConverterParameter=Inverse}">
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<TextBlock
|
||||
HorizontalAlignment="Center"
|
||||
FontSize="24"
|
||||
Foreground="#666"
|
||||
Text="❌" />
|
||||
<TextBlock
|
||||
Margin="0,5,0,0"
|
||||
Foreground="#666"
|
||||
Text="无信号" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid
|
||||
Height="28"
|
||||
VerticalAlignment="Top"
|
||||
Background="#66000000">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock
|
||||
Grid.Column="0"
|
||||
Margin="8,0"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="Bold"
|
||||
Foreground="White"
|
||||
Text="{Binding CameraName}" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
Margin="8,0"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="11"
|
||||
Foreground="#00FF00"
|
||||
Text="{Binding StatusInfo}" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
15
SHH.CameraDashboard/Pages/CameraWall/VideoTile.xaml.cs
Normal file
15
SHH.CameraDashboard/Pages/CameraWall/VideoTile.xaml.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace SHH.CameraDashboard
|
||||
{
|
||||
/// <summary>
|
||||
/// VideoTile.xaml 的交互逻辑
|
||||
/// </summary>
|
||||
public partial class VideoTile : UserControl
|
||||
{
|
||||
public VideoTile()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
}
|
||||
87
SHH.CameraDashboard/Pages/CameraWall/VideoTileViewModel.cs
Normal file
87
SHH.CameraDashboard/Pages/CameraWall/VideoTileViewModel.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using SHH.Contracts;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
|
||||
namespace SHH.CameraDashboard
|
||||
{
|
||||
public class VideoTileViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
// --- 绑定属性 ---
|
||||
|
||||
private ImageSource _displayImage;
|
||||
public ImageSource DisplayImage
|
||||
{
|
||||
get => _displayImage;
|
||||
set => SetProperty(ref _displayImage, value);
|
||||
}
|
||||
|
||||
private string _cameraName;
|
||||
public string CameraName
|
||||
{
|
||||
get => _cameraName;
|
||||
set => SetProperty(ref _cameraName, value);
|
||||
}
|
||||
|
||||
private string _statusInfo;
|
||||
public string StatusInfo
|
||||
{
|
||||
get => _statusInfo;
|
||||
set => SetProperty(ref _statusInfo, value);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
93
SHH.CameraDashboard/Pages/CameraWall/VideoWall.xaml
Normal file
93
SHH.CameraDashboard/Pages/CameraWall/VideoWall.xaml
Normal file
@@ -0,0 +1,93 @@
|
||||
<UserControl
|
||||
x:Class="SHH.CameraDashboard.VideoWall"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:local="clr-namespace:SHH.CameraDashboard"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:SHH.CameraDashboard"
|
||||
d:DesignHeight="450"
|
||||
d:DesignWidth="800"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<UserControl.DataContext>
|
||||
<vm:VideoWallViewModel />
|
||||
</UserControl.DataContext>
|
||||
|
||||
<Grid Background="#111">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="40" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<StackPanel
|
||||
Grid.Row="0"
|
||||
Margin="0,0,0,2"
|
||||
Background="#252526"
|
||||
Orientation="Horizontal">
|
||||
<TextBlock
|
||||
Margin="10,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="White"
|
||||
Text="📺 视频墙布局:" />
|
||||
|
||||
<Button
|
||||
Margin="5,0"
|
||||
Command="{Binding SetLayoutCommand}"
|
||||
CommandParameter="1x1"
|
||||
Content="1画面"
|
||||
Style="{StaticResource PrimaryBtnStyle}" />
|
||||
<Button
|
||||
Margin="5,0"
|
||||
Command="{Binding SetLayoutCommand}"
|
||||
CommandParameter="2x2"
|
||||
Content="4画面"
|
||||
Style="{StaticResource PrimaryBtnStyle}" />
|
||||
<Button
|
||||
Margin="5,0"
|
||||
Command="{Binding SetLayoutCommand}"
|
||||
CommandParameter="3x3"
|
||||
Content="9画面"
|
||||
Style="{StaticResource PrimaryBtnStyle}" />
|
||||
</StackPanel>
|
||||
|
||||
<ListBox
|
||||
Grid.Row="1"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
VerticalContentAlignment="Stretch"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
ItemsSource="{Binding VideoTiles}"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<UniformGrid Columns="{Binding DataContext.Columns, RelativeSource={RelativeSource AncestorType=UserControl}}" Rows="0" />
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
|
||||
<ListBox.ItemContainerStyle>
|
||||
<Style TargetType="ListBoxItem">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Margin" Value="0" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<ContentPresenter />
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ListBox.ItemContainerStyle>
|
||||
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<local:VideoTile />
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
15
SHH.CameraDashboard/Pages/CameraWall/VideoWall.xaml.cs
Normal file
15
SHH.CameraDashboard/Pages/CameraWall/VideoWall.xaml.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace SHH.CameraDashboard
|
||||
{
|
||||
/// <summary>
|
||||
/// VideoWall.xaml 的交互逻辑
|
||||
/// </summary>
|
||||
public partial class VideoWall : UserControl
|
||||
{
|
||||
public VideoWall()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
}
|
||||
84
SHH.CameraDashboard/Pages/CameraWall/VideoWallViewModel.cs
Normal file
84
SHH.CameraDashboard/Pages/CameraWall/VideoWallViewModel.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using SHH.Contracts;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace SHH.CameraDashboard
|
||||
{
|
||||
public class VideoWallViewModel : ViewModelBase
|
||||
{
|
||||
// 引用推流接收服务
|
||||
private readonly VideoPushServer _pushServer;
|
||||
|
||||
// 视频列表
|
||||
public ObservableCollection<VideoTileViewModel> VideoTiles { get; } = new ObservableCollection<VideoTileViewModel>();
|
||||
|
||||
// 控制 UniformGrid 的列数 (决定是 2x2 还是 3x3)
|
||||
private int _columns = 2;
|
||||
public int Columns
|
||||
{
|
||||
get => _columns;
|
||||
set => SetProperty(ref _columns, value);
|
||||
}
|
||||
|
||||
// 切换布局命令
|
||||
public ICommand SetLayoutCommand { get; }
|
||||
|
||||
public VideoWallViewModel()
|
||||
{
|
||||
SetLayoutCommand = new RelayCommand<string>(ExecuteSetLayout);
|
||||
|
||||
// 1. 初始化并启动接收服务
|
||||
_pushServer = new VideoPushServer();
|
||||
_pushServer.OnFrameReceived += OnGlobalFrameReceived;
|
||||
|
||||
// 2. 启动监听端口 (比如 6000)
|
||||
// 之后你的采集端 ForwarderClient 需要 Connect("tcp://你的IP:6000")
|
||||
_pushServer.Start(6000);
|
||||
|
||||
// 3. 初始化格子 (不再需要传入 IP/Port 去主动连接了)
|
||||
// 我们用 CameraId 或 Name 来作为匹配标识
|
||||
InitVideoTiles();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全局接收回调:收到任何一路视频都会进这里
|
||||
/// </summary>
|
||||
private void OnGlobalFrameReceived(VideoPayload payload)
|
||||
{
|
||||
// 1. 在 VideoTiles 集合中找到对应的格子
|
||||
// 假设 payload.CameraId 与我们 VideoTileViewModel 中的 ID 对应
|
||||
//var targetTile = VideoTiles.FirstOrDefault(t => t.id == payload.CameraId);
|
||||
|
||||
//if (targetTile != null)
|
||||
//{
|
||||
// // 2. 将数据交给格子去渲染
|
||||
// targetTile.UpdateFrame(payload);
|
||||
//}
|
||||
}
|
||||
|
||||
private void InitVideoTiles()
|
||||
{
|
||||
// 假设我们预设 4 个格子,分别对应不同的摄像头 ID
|
||||
// 这里 ID 必须和采集端发送的 VideoPayload.CameraId 一致
|
||||
//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)
|
||||
{
|
||||
case "1x1": Columns = 1; break;
|
||||
case "2x2": Columns = 2; break;
|
||||
case "3x3": Columns = 3; break;
|
||||
case "4x4": Columns = 4; break;
|
||||
default: Columns = 2; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
SHH.CameraDashboard/Pages/CameraWall/ViewModelBase.cs
Normal file
24
SHH.CameraDashboard/Pages/CameraWall/ViewModelBase.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace SHH.CameraDashboard
|
||||
{
|
||||
public class ViewModelBase : INotifyPropertyChanged
|
||||
{
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
|
||||
// 辅助方法:设置属性值并触发通知
|
||||
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
||||
field = value;
|
||||
OnPropertyChanged(propertyName);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,11 @@
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SHH.Contracts\SHH.Contracts.csproj" />
|
||||
<ProjectReference Include="..\SHH.NetMQ\SHH.NetMQ.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Resource Include="Style\Themes\Colors.Light.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
|
||||
390
SHH.CameraDashboard/Services/CommandBusClient.cs
Normal file
390
SHH.CameraDashboard/Services/CommandBusClient.cs
Normal file
@@ -0,0 +1,390 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
using NetMQ;
|
||||
using NetMQ.Sockets;
|
||||
using Newtonsoft.Json;
|
||||
using SHH.Contracts;
|
||||
|
||||
namespace SHH.CameraDashboard.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// 客户端指令总线 (企业增强版)
|
||||
/// <para>核心职责:作为指挥中心监听 7000 端口,管理所有网关连接。</para>
|
||||
/// <para>通讯模式:Router (Bind) <--- Dealer (Connect)</para>
|
||||
/// <para>高级特性:</para>
|
||||
/// <para>1. 智能路由:根据 InstanceId 自动查找 NetMQ Identity。</para>
|
||||
/// <para>2. QoS 分级:支持 "强一致性等待" 和 "射后不理" 两种模式。</para>
|
||||
/// <para>3. 自动重试:网络超时自动重发,失败多次自动熔断。</para>
|
||||
/// <para>4. 性能监控:精确统计全链路耗时 (RTT)。</para>
|
||||
/// </summary>
|
||||
public class CommandBusClient : IDisposable
|
||||
{
|
||||
#region --- 1. 字段与配置 ---
|
||||
|
||||
private RouterSocket? _routerSocket;
|
||||
private NetMQPoller? _poller;
|
||||
private volatile bool _isRunning;
|
||||
private readonly object _disposeLock = new object();
|
||||
|
||||
// 默认超时设置
|
||||
private const int DEFAULT_TIMEOUT_MS = 2000;
|
||||
private const int DEFAULT_MAX_RETRIES = 2;
|
||||
|
||||
// ★★★ 核心:线程安全的任务字典 <RequestId, TCS> ★★★
|
||||
// Key: 请求ID (身份证号)
|
||||
// Value: 异步任务凭证 (用于 await 唤醒)
|
||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<CommandResult>> _pendingRequests
|
||||
= new ConcurrentDictionary<string, TaskCompletionSource<CommandResult>>();
|
||||
|
||||
// ★★★ 核心:路由表 ★★★
|
||||
// Key: 实例ID (例如 "Gateway_01")
|
||||
// Value: NetMQ 路由 Identity (二进制地址,这是 Router 发消息必须的“信封地址”)
|
||||
private readonly ConcurrentDictionary<string, byte[]> _sessions
|
||||
= new ConcurrentDictionary<string, byte[]>();
|
||||
|
||||
/// <summary>
|
||||
/// 当有服务端连上来并完成注册时触发
|
||||
/// </summary>
|
||||
public event Action<ServerRegistrationDto>? OnServerRegistered;
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 2. 启动与停止 ---
|
||||
|
||||
/// <summary>
|
||||
/// 启动指令中心监听
|
||||
/// </summary>
|
||||
/// <param name="port">监听端口 (建议 7000)</param>
|
||||
public void Start(int port)
|
||||
{
|
||||
if (_isRunning) return;
|
||||
|
||||
try
|
||||
{
|
||||
lock (_disposeLock)
|
||||
{
|
||||
_routerSocket = new RouterSocket();
|
||||
// 绑定端口,等待服务端(Active Mode)主动来连接
|
||||
// 使用 tcp://*:{port} 绑定本机所有网卡
|
||||
_routerSocket.Bind($"tcp://*:{port}");
|
||||
|
||||
// 注册接收事件 (基于 NetMQPoller 的异步非阻塞模式)
|
||||
_routerSocket.ReceiveReady += OnReceiveReady;
|
||||
|
||||
_poller = new NetMQPoller { _routerSocket };
|
||||
_poller.RunAsync(); // 在后台线程启动轮询
|
||||
|
||||
_isRunning = true;
|
||||
Debug.WriteLine($"[ClientBus] 指令中心已启动,监听端口: {port}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 启动失败属于致命错误,记录日志
|
||||
Debug.WriteLine($"[ClientBus-Error] 启动失败: {ex.Message}");
|
||||
throw; // 向上抛出,让 UI 层感知并报错
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (!_isRunning) return;
|
||||
|
||||
lock (_disposeLock)
|
||||
{
|
||||
_isRunning = false;
|
||||
try
|
||||
{
|
||||
_poller?.Stop();
|
||||
_poller?.Dispose();
|
||||
_routerSocket?.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[ClientBus-Error] 停止时异常: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 彻底清理状态
|
||||
CleanupPendingTasks();
|
||||
_sessions.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
}
|
||||
|
||||
private void CleanupPendingTasks()
|
||||
{
|
||||
// 取消所有挂起的请求,避免 SendAsync 里的 await 永久卡死
|
||||
foreach (var kvp in _pendingRequests)
|
||||
{
|
||||
kvp.Value.TrySetCanceled();
|
||||
}
|
||||
_pendingRequests.Clear();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 3. 核心发送逻辑 (策略层) ---
|
||||
|
||||
/// <summary>
|
||||
/// 发送指令(包含 QoS判断 + 重试循环 + 熔断 + RTT统计)
|
||||
/// </summary>
|
||||
/// <param name="instanceId">目标网关ID (如 "Gateway_01")</param>
|
||||
/// <param name="payload">指令包</param>
|
||||
/// <param name="timeoutMs">单次超时时间 (毫秒)</param>
|
||||
/// <param name="maxRetries">最大重试次数 (0表示不重试)</param>
|
||||
/// <returns>执行结果</returns>
|
||||
public async Task<CommandResult> SendAsync(string instanceId, CommandPayload payload, int timeoutMs = DEFAULT_TIMEOUT_MS, int maxRetries = DEFAULT_MAX_RETRIES)
|
||||
{
|
||||
if (!_isRunning) return CommandResult.Fail("服务未启动");
|
||||
|
||||
// 1. 检查目标是否在线 (快速失败)
|
||||
if (!_sessions.ContainsKey(instanceId))
|
||||
{
|
||||
return CommandResult.Fail($"服务端 {instanceId} 离线或未连接");
|
||||
}
|
||||
|
||||
// 2. 确保有 RequestId
|
||||
if (string.IsNullOrEmpty(payload.RequestId))
|
||||
payload.RequestId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// =========================================================
|
||||
// 策略 A: 射后不理 (Fire-and-Forget) - QoS 0
|
||||
// =========================================================
|
||||
// 适用于:心跳包、非关键日志、高频状态查询
|
||||
// 优势:不占用 await 线程资源,不产生网络拥堵
|
||||
if (!payload.RequireAck)
|
||||
{
|
||||
try
|
||||
{
|
||||
SendInternal(instanceId, payload);
|
||||
return CommandResult.Ok("已投递 (NoAck Mode)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CommandResult.Fail($"投递失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// 策略 B: 强一致性重试 (Reliable Retry) - QoS 1
|
||||
// =========================================================
|
||||
// 适用于:PTZ控制、录像启停、参数设置
|
||||
|
||||
int currentRetry = 0;
|
||||
// 启动高精度计时器 (统计包含重试在内的总耗时)
|
||||
Stopwatch totalStopwatch = Stopwatch.StartNew();
|
||||
|
||||
// 重试循环 (Retry Loop)
|
||||
while (currentRetry <= maxRetries)
|
||||
{
|
||||
// 更新重试计数,服务端可据此判断是否需要打印 "Retry Warning"
|
||||
payload.RetryCount = currentRetry;
|
||||
|
||||
try
|
||||
{
|
||||
// ★ 核心原子操作:发送并等待单次结果 ★
|
||||
var result = await SendRequestCore(instanceId, payload, timeoutMs);
|
||||
|
||||
// --- 成功路径 ---
|
||||
totalStopwatch.Stop();
|
||||
result.ElapsedMilliseconds = totalStopwatch.Elapsed.TotalMilliseconds;
|
||||
|
||||
// 如果重试过,打印一条恢复日志
|
||||
if (currentRetry > 0)
|
||||
Debug.WriteLine($"[ClientBus] {payload.CmdCode} 在第 {currentRetry} 次重试后成功恢复。");
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// --- 超时路径 ---
|
||||
Debug.WriteLine($"[ClientBus-Warn] Req {payload.RequestId} 超时 ({currentRetry + 1}/{maxRetries + 1})...");
|
||||
currentRetry++;
|
||||
|
||||
// 可选:在重试前稍微等待一下 (指数退避),避免瞬间拥塞
|
||||
// await Task.Delay(50 * currentRetry);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// --- 致命错误路径 (如序列化失败、Socket已释放) ---
|
||||
// 这种错误重试也没用,直接报错
|
||||
return CommandResult.Fail($"发送过程发生不可恢复错误: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// 熔断 (Meltdown)
|
||||
// =========================================================
|
||||
totalStopwatch.Stop();
|
||||
var failRes = CommandResult.Fail($"请求熔断: 目标无响应 (已重试 {maxRetries} 次)");
|
||||
failRes.ElapsedMilliseconds = totalStopwatch.Elapsed.TotalMilliseconds;
|
||||
return failRes;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 4. 底层发送实现 (原子操作层) ---
|
||||
|
||||
/// <summary>
|
||||
/// 执行单次 "请求-响应" 周期
|
||||
/// </summary>
|
||||
private async Task<CommandResult> SendRequestCore(string instanceId, CommandPayload payload, int timeoutMs)
|
||||
{
|
||||
// 1. 创建异步凭证 (TCS)
|
||||
// RunContinuationsAsynchronously 是必须的,防止 NetMQ 接收线程直接执行 await 后的 UI 代码导致死锁
|
||||
var tcs = new TaskCompletionSource<CommandResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
// 2. 注册到字典,等待回信
|
||||
// 如果 ID 冲突 (极低概率),说明上一个还没处理完,强行覆盖或报错
|
||||
_pendingRequests[payload.RequestId] = tcs;
|
||||
|
||||
try
|
||||
{
|
||||
// 3. 发送网络包
|
||||
SendInternal(instanceId, payload);
|
||||
|
||||
// 4. 异步等待 (Wait for TCS or Timeout)
|
||||
// Task.WhenAny 是实现超时的经典模式
|
||||
var completedTask = await Task.WhenAny(tcs.Task, Task.Delay(timeoutMs));
|
||||
|
||||
if (completedTask == tcs.Task)
|
||||
{
|
||||
// 任务完成 (OnReceiveReady 设置了结果)
|
||||
return await tcs.Task;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 时间到,任务还没完成 -> 抛出超时异常,触发外层重试
|
||||
throw new TimeoutException();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 5. 清理现场 (无论成功失败,必须移除字典,防止内存泄漏)
|
||||
_pendingRequests.TryRemove(payload.RequestId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 纯粹的 NetMQ 数据发送 (不处理逻辑)
|
||||
/// </summary>
|
||||
private void SendInternal(string instanceId, CommandPayload payload)
|
||||
{
|
||||
// 查路由表获取 Identity
|
||||
if (_sessions.TryGetValue(instanceId, out byte[]? identity))
|
||||
{
|
||||
var msg = new NetMQMessage();
|
||||
// Frame 1: 目标地址 (Identity)
|
||||
msg.Append(identity);
|
||||
// Frame 2: 数据 (JSON)
|
||||
msg.Append(JsonConvert.SerializeObject(payload));
|
||||
|
||||
// 线程安全检查
|
||||
if (_routerSocket != null)
|
||||
{
|
||||
_routerSocket.SendMultipartMessage(msg);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"无法找到目标 {instanceId} 的路由信息");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 5. 核心接收逻辑 (Router) ---
|
||||
|
||||
/// <summary>
|
||||
/// 处理所有入站消息
|
||||
/// </summary>
|
||||
private void OnReceiveReady(object? sender, NetMQSocketEventArgs e)
|
||||
{
|
||||
// 防止处理过程中崩溃导致监听停止
|
||||
try
|
||||
{
|
||||
NetMQMessage msg = new NetMQMessage();
|
||||
// Router 模式:至少包含 [Identity, Data] 两帧,有时中间会有空帧
|
||||
if (!e.Socket.TryReceiveMultipartMessage(ref msg) || msg.FrameCount < 2) return;
|
||||
|
||||
// 第一帧永远是发送方的 Identity
|
||||
byte[] identity = msg[0].Buffer;
|
||||
// 最后一帧通常是 JSON 数据
|
||||
string json = msg.Last.ConvertToString();
|
||||
|
||||
// 简单的协议识别
|
||||
// 优化建议:正式项目中可以用更严谨的 Header 区分,这里用 JSON 嗅探即可
|
||||
if (json.Contains("\"CmdCode\""))
|
||||
{
|
||||
// ---> 收到注册包 (CmdCode 字段存在)
|
||||
HandleRegistration(identity, json);
|
||||
}
|
||||
else if (json.Contains("\"Success\""))
|
||||
{
|
||||
// ---> 收到回执包 (Success 字段存在)
|
||||
HandleResponse(json);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[ClientBus-RecvError] 接收处理异常: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRegistration(byte[] identity, string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payload = JsonConvert.DeserializeObject<CommandPayload>(json);
|
||||
if (payload?.CmdCode == "SERVER_REGISTER")
|
||||
{
|
||||
var regInfo = JsonConvert.DeserializeObject<ServerRegistrationDto>(payload.JsonParams);
|
||||
if (regInfo != null)
|
||||
{
|
||||
// 更新路由表:[实例名] -> [二进制地址]
|
||||
_sessions[regInfo.InstanceId] = identity;
|
||||
|
||||
Debug.WriteLine($"[ClientBus] 网关上线: {regInfo.InstanceId} IP: {regInfo.ServerIp}");
|
||||
|
||||
// 通知 UI 刷新列表
|
||||
OnServerRegistered?.Invoke(regInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[ClientBus-Warn] 注册包解析失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleResponse(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = JsonConvert.DeserializeObject<CommandResult>(json);
|
||||
|
||||
// 闭环匹配:根据 RequestId 找到挂起的 TCS
|
||||
if (!string.IsNullOrEmpty(result?.RequestId) &&
|
||||
_pendingRequests.TryGetValue(result.RequestId, out var tcs))
|
||||
{
|
||||
// 设置结果 -> 唤醒 SendRequestCore -> 唤醒 SendAsync
|
||||
tcs.TrySetResult(result);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[ClientBus-Warn] 回执包解析失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
68
SHH.CameraDashboard/Services/ServerStateManager.cs
Normal file
68
SHH.CameraDashboard/Services/ServerStateManager.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows; // 如果是 WPF
|
||||
using SHH.Contracts;
|
||||
|
||||
namespace SHH.CameraDashboard
|
||||
{
|
||||
/// <summary>
|
||||
/// 服务端状态管理器
|
||||
/// <para>职责:维护在线设备列表,供 UI 绑定</para>
|
||||
/// </summary>
|
||||
public class ServerStateManager
|
||||
{
|
||||
// UI 绑定的集合
|
||||
public ObservableCollection<ServerNodeInfo> OnlineServers { get; }
|
||||
= new ObservableCollection<ServerNodeInfo>();
|
||||
|
||||
/// <summary>
|
||||
/// 处理注册/心跳包,更新列表
|
||||
/// </summary>
|
||||
public void RegisterOrUpdate(ServerRegistrationDto info)
|
||||
{
|
||||
// 确保在 UI 线程执行 (WPF 必须)
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
var existing = OnlineServers.FirstOrDefault(x => x.InstanceId == info.InstanceId);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
// 更新已有节点状态
|
||||
existing.IpAddress = info.ServerIp;
|
||||
existing.WebApiPort = info.WebApiPort;
|
||||
existing.LastHeartbeat = DateTime.Now;
|
||||
existing.Status = "在线";
|
||||
// 触发属性变更通知...
|
||||
}
|
||||
else
|
||||
{
|
||||
// 新增节点
|
||||
OnlineServers.Add(new ServerNodeInfo
|
||||
{
|
||||
InstanceId = info.InstanceId,
|
||||
IpAddress = info.ServerIp,
|
||||
WebApiPort = info.WebApiPort,
|
||||
ProcessId = info.ProcessId,
|
||||
LastHeartbeat = DateTime.Now,
|
||||
Status = "在线"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UI 显示用的模型
|
||||
/// </summary>
|
||||
public class ServerNodeInfo
|
||||
{
|
||||
public string InstanceId { get; set; }
|
||||
public string IpAddress { get; set; }
|
||||
public int WebApiPort { get; set; }
|
||||
public int ProcessId { get; set; }
|
||||
public string Status { get; set; }
|
||||
public DateTime LastHeartbeat { get; set; }
|
||||
|
||||
// 方便 UI 显示的字符串
|
||||
public string DisplayName => $"{InstanceId} ({IpAddress}:{WebApiPort})";
|
||||
}
|
||||
}
|
||||
110
SHH.CameraDashboard/Services/VideoPushServer.cs
Normal file
110
SHH.CameraDashboard/Services/VideoPushServer.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using NetMQ;
|
||||
using NetMQ.Sockets;
|
||||
using SHH.Contracts; // 引用你 ExportCode_Dump2.txt 里的契约
|
||||
|
||||
namespace SHH.CameraDashboard
|
||||
{
|
||||
/// <summary>
|
||||
/// 视频推流接收服务 (服务端模式)
|
||||
/// 职责:监听本地端口,被动接收来自采集端的 Push 数据流
|
||||
/// </summary>
|
||||
public class VideoPushServer : IDisposable
|
||||
{
|
||||
private PullSocket? _pullSocket;
|
||||
private bool _isRunning;
|
||||
private Task? _listenTask;
|
||||
|
||||
// 当收到完整视频帧时触发,UI 层订阅此事件来更新画面
|
||||
// VideoPayload 定义在 ExportCode_Dump2.txt 中
|
||||
public event Action<VideoPayload>? OnFrameReceived;
|
||||
|
||||
/// <summary>
|
||||
/// 启动监听
|
||||
/// </summary>
|
||||
/// <param name="port">本机开放的监听端口 (例如 6000)</param>
|
||||
public void Start(int port)
|
||||
{
|
||||
if (_isRunning) return;
|
||||
|
||||
try
|
||||
{
|
||||
_pullSocket = new PullSocket();
|
||||
|
||||
// 1. 设置高水位 (HWM),防止渲染不及导致内存溢出
|
||||
// 与发送端 ForwarderClient 的 HWM_LIMIT = 50 保持策略一致
|
||||
_pullSocket.Options.ReceiveHighWatermark = 50;
|
||||
|
||||
// 2. 【核心】绑定本地端口 (Bind),等待别人连我
|
||||
// 允许局域网内任何 IP 推送数据过来
|
||||
_pullSocket.Bind($"tcp://*:{port}");
|
||||
|
||||
_isRunning = true;
|
||||
|
||||
// 3. 开启后台接收线程
|
||||
_listenTask = Task.Run(ReceiveLoop);
|
||||
|
||||
System.Diagnostics.Debug.WriteLine($"[PushServer] 服务已启动,正在监听端口: {port}...");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"[PushServer] 启动失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ReceiveLoop()
|
||||
{
|
||||
while (_isRunning && _pullSocket != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. 接收多帧消息 (超时控制以便能优雅退出)
|
||||
NetMQMessage? msg = null;
|
||||
if (!_pullSocket.TryReceiveMultipartMessage(TimeSpan.FromMilliseconds(500), ref msg))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. 协议校验:必须包含 3 帧 (JSON + Raw + Target)
|
||||
// 对应 ExportCode_Dump1.txt 中 ForwarderClient.Push 的 msg.Append 顺序
|
||||
if (msg == null || msg.FrameCount < 3) continue;
|
||||
|
||||
// 3. 解析数据帧
|
||||
string jsonMeta = msg[0].ConvertToString();
|
||||
byte[] originalBytes = msg[1].Buffer;
|
||||
byte[] targetBytes = msg[2].Buffer;
|
||||
|
||||
// 4. 反序列化元数据
|
||||
// 使用你 Core/JsonHelper.cs 里的稳健反序列化
|
||||
var payload = JsonHelper.Deserialize<VideoPayload>(jsonMeta);
|
||||
|
||||
if (payload != null)
|
||||
{
|
||||
// 5. 组装二进制数据 (因为传输时是分离的)
|
||||
payload.OriginalImageBytes = originalBytes;
|
||||
payload.TargetImageBytes = targetBytes;
|
||||
|
||||
// 6. 触发事件 (抛出给 ViewModel)
|
||||
OnFrameReceived?.Invoke(payload);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"[PushServer] 接收异常: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_isRunning = false;
|
||||
_pullSocket?.Close();
|
||||
_pullSocket?.Dispose();
|
||||
_pullSocket = null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
113
SHH.CameraService/CameraEngineWorker.cs
Normal file
113
SHH.CameraService/CameraEngineWorker.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using SHH.CameraSdk;
|
||||
|
||||
namespace SHH.CameraService;
|
||||
|
||||
/// <summary>
|
||||
/// 相机服务核心引擎工作者(后台长驻服务)
|
||||
/// <para>核心职责:</para>
|
||||
/// <para>1. 管理 CameraManager 全生命周期(启动、配置、释放)</para>
|
||||
/// <para>2. 初始化网络哨兵(ConnectivitySentinel),监控设备网络连通性</para>
|
||||
/// <para>3. 无配置时自动添加默认测试设备,降低调试门槛</para>
|
||||
/// <para>设计说明:</para>
|
||||
/// <para>- 基于 BackgroundService,运行在独立后台线程,不阻塞 Web 主线程</para>
|
||||
/// <para>- 与 CameraManager 强绑定,是所有相机设备的统一入口</para>
|
||||
/// <para>- 包含容错机制,添加设备失败不影响整体服务启动</para>
|
||||
public class CameraEngineWorker : BackgroundService
|
||||
{
|
||||
#region --- 依赖注入字段 ---
|
||||
|
||||
/// <summary>
|
||||
/// 相机管理器实例(核心业务对象)
|
||||
/// 功能:管理所有相机设备的生命周期、状态监控、配置更新
|
||||
/// </summary>
|
||||
private readonly CameraManager _manager;
|
||||
|
||||
/// <summary>
|
||||
/// 网络连通性哨兵实例
|
||||
/// 功能:周期性 Ping 设备、检测网络状态、触发断线重连
|
||||
/// </summary>
|
||||
private readonly ConnectivitySentinel _sentinel;
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 构造函数 ---
|
||||
/// <summary>
|
||||
/// 初始化相机引擎工作者实例
|
||||
/// </summary>
|
||||
/// <param name="manager">相机管理器(通过 DI 注入,已关联存储服务)</param>
|
||||
/// <param name="sentinel">网络哨兵(通过 DI 注入,已预设监控周期)</param>
|
||||
public CameraEngineWorker(CameraManager manager, ConnectivitySentinel sentinel)
|
||||
{
|
||||
_manager = manager ?? throw new ArgumentNullException(
|
||||
nameof(manager), "相机管理器实例不能为空,核心引擎启动失败");
|
||||
_sentinel = sentinel ?? throw new ArgumentNullException(
|
||||
nameof(sentinel), "网络哨兵实例不能为空,设备监控功能失效");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- BackgroundService 核心实现 ---
|
||||
|
||||
/// <summary>
|
||||
/// 启动引擎:初始化相机管理器并加载业务配置
|
||||
/// <para>执行流程:</para>
|
||||
/// <para>1. 启动 CameraManager(加载本地配置文件中的设备信息)</para>
|
||||
/// <para>2. 加载默认业务逻辑(无设备时添加测试设备)</para>
|
||||
/// <para>注意:网络哨兵的启动逻辑已内置在其构造函数中,此处无需额外调用</para>
|
||||
/// </summary>
|
||||
/// <param name="stoppingToken">服务停止令牌(响应应用关闭/重启信号)</param>
|
||||
/// <returns>异步任务(引擎启动完成后结束)</returns>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
Console.WriteLine("[Engine] 核心引擎启动中...");
|
||||
Console.WriteLine("[Engine] 启动相机管理器(加载设备配置)");
|
||||
|
||||
try
|
||||
{
|
||||
// 启动相机管理器:加载 App_Data/Process_X 目录下的设备配置文件
|
||||
await _manager.StartAsync();
|
||||
Console.WriteLine("[Engine] 相机管理器启动成功,已加载配置文件中的设备");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Engine] 相机管理器启动失败:{ex.Message}");
|
||||
Console.WriteLine("[Engine] 警告:核心引擎将继续运行,但无法管理任何相机设备");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine("[Engine] 核心引擎启动完成,进入运行状态");
|
||||
Console.WriteLine("[Engine] 提示:可通过 API 接口添加/编辑/删除设备,实时生效");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止引擎:优雅释放资源
|
||||
/// <para>执行流程:</para>
|
||||
/// <para>1. 调用 CameraManager.DisposeAsync(),释放所有设备连接、句柄、线程资源</para>
|
||||
/// <para>2. 调用基类 StopAsync(),标记服务停止状态</para>
|
||||
/// <para>注意:必须先释放 CameraManager,避免设备连接泄露</para>
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌(用于强制终止释放流程)</param>
|
||||
/// <returns>异步任务(资源释放完成后结束)</returns>
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Console.WriteLine("[Engine] 核心引擎正在停止...");
|
||||
|
||||
try
|
||||
{
|
||||
// 释放相机管理器:停止所有设备取流、注销登录、释放非托管资源
|
||||
await _manager.DisposeAsync();
|
||||
Console.WriteLine("[Engine] 相机管理器资源已释放");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Engine] 资源释放异常:{ex.Message}");
|
||||
}
|
||||
|
||||
// 调用基类方法,完成服务停止
|
||||
await base.StopAsync(cancellationToken);
|
||||
Console.WriteLine("[Engine] 核心引擎已停止");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
123
SHH.CameraService/CommandBusProcessor.cs
Normal file
123
SHH.CameraService/CommandBusProcessor.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using Newtonsoft.Json;
|
||||
using SHH.CameraSdk;
|
||||
using SHH.Contracts;
|
||||
|
||||
namespace SHH.CameraService
|
||||
{
|
||||
/// <summary>
|
||||
/// 指令业务逻辑分发器 (纯逻辑层)
|
||||
/// <para>职责:解析业务参数 -> 调用 CameraManager -> 返回执行结果</para>
|
||||
/// <para>注意:本类不处理网络协议,也不负责 RequestId 的回填,只关注业务本身</para>
|
||||
/// </summary>
|
||||
public static class CommandBusProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// 核心业务入口
|
||||
/// </summary>
|
||||
/// <param name="manager">相机管理器实例</param>
|
||||
/// <param name="payload">已解析的指令包</param>
|
||||
/// <returns>执行结果 (不含 RequestId,由调用方补充)</returns>
|
||||
public static CommandResult ProcessBusinessLogic(CameraManager manager, CommandPayload payload)
|
||||
{
|
||||
string cmd = payload.CmdCode.ToUpper();
|
||||
|
||||
// 忽略客户端发回的 ACK (如果是双向确认模式)
|
||||
if (cmd == "REGISTER_ACK") return CommandResult.Ok();
|
||||
|
||||
// 解析 TargetId (CameraId)
|
||||
long deviceId = 0;
|
||||
// 只有非 SYSTEM 指令才需要解析设备ID
|
||||
if (payload.TargetId != "SYSTEM" && !long.TryParse(payload.TargetId, out deviceId))
|
||||
{
|
||||
return CommandResult.Fail($"Invalid Device ID: {payload.TargetId}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
switch (cmd)
|
||||
{
|
||||
// ==========================================
|
||||
// 1. PTZ 云台控制
|
||||
// ==========================================
|
||||
case "PTZ":
|
||||
{
|
||||
var device = manager.GetDevice(deviceId);
|
||||
if (device == null) return CommandResult.Fail("Device Not Found");
|
||||
if (!device.IsOnline) return CommandResult.Fail("Device Offline");
|
||||
|
||||
// 检查设备是否支持 PTZ 能力 (接口模式匹配)
|
||||
if (device is IPtzFeature ptzFeature)
|
||||
{
|
||||
var ptzDto = JsonConvert.DeserializeObject<PtzControlDto>(payload.JsonParams);
|
||||
if (ptzDto == null) return CommandResult.Fail("Invalid PTZ Params");
|
||||
|
||||
// 异步转同步执行 (Task.Wait 在后台线程是安全的)
|
||||
if (ptzDto.Duration > 0)
|
||||
{
|
||||
// 点动模式 (例如:向左转 500ms)
|
||||
ptzFeature.PtzStepAsync(ptzDto.Action, ptzDto.Duration, ptzDto.Speed).Wait();
|
||||
}
|
||||
else
|
||||
{
|
||||
// 持续模式 (开始转/停止转)
|
||||
ptzFeature.PtzControlAsync(ptzDto.Action, ptzDto.Stop, ptzDto.Speed).Wait();
|
||||
}
|
||||
return CommandResult.Ok("PTZ Executed");
|
||||
}
|
||||
return CommandResult.Fail("Device does not support PTZ");
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. 远程重启
|
||||
// ==========================================
|
||||
case "REBOOT":
|
||||
{
|
||||
var device = manager.GetDevice(deviceId);
|
||||
if (device == null) return CommandResult.Fail("Device Not Found");
|
||||
|
||||
if (device is IRebootFeature rebootFeature)
|
||||
{
|
||||
rebootFeature.RebootAsync().Wait();
|
||||
return CommandResult.Ok("Reboot command sent");
|
||||
}
|
||||
return CommandResult.Fail("Device does not support Reboot");
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. 时间同步
|
||||
// ==========================================
|
||||
case "SYNC_TIME":
|
||||
{
|
||||
var device = manager.GetDevice(deviceId);
|
||||
if (device == null) return CommandResult.Fail("Device Not Found");
|
||||
|
||||
if (device is ITimeSyncFeature timeFeature)
|
||||
{
|
||||
timeFeature.SetTimeAsync(DateTime.Now).Wait();
|
||||
return CommandResult.Ok("Time synced");
|
||||
}
|
||||
return CommandResult.Fail("Device does not support TimeSync");
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 4. 系统级指令 (心跳/诊断)
|
||||
// ==========================================
|
||||
case "PING":
|
||||
return CommandResult.Ok("PONG");
|
||||
|
||||
default:
|
||||
return CommandResult.Fail($"Unknown Command: {cmd}");
|
||||
}
|
||||
}
|
||||
catch (AggregateException ae)
|
||||
{
|
||||
// 捕获异步任务内部的异常
|
||||
return CommandResult.Fail($"Execution Error: {ae.InnerException?.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CommandResult.Fail($"Execution Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
346
SHH.CameraService/CommandBusService.cs
Normal file
346
SHH.CameraService/CommandBusService.cs
Normal file
@@ -0,0 +1,346 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using NetMQ;
|
||||
using NetMQ.Sockets;
|
||||
using Newtonsoft.Json;
|
||||
using SHH.CameraSdk;
|
||||
using SHH.Contracts;
|
||||
|
||||
namespace SHH.CameraService
|
||||
{
|
||||
/// <summary>
|
||||
/// 双模指令总线服务 (Enterprise V2)
|
||||
/// <para>核心职责:建立 TCP 指令通道,接收客户端指令并分发给 CameraManager</para>
|
||||
/// <para>增强特性:</para>
|
||||
/// <para>1. 支持双模:被动监听 (Bind) 与 主动投递 (Connect)</para>
|
||||
/// <para>2. 幂等性控制:利用 MemoryCache 防止客户端重试导致的重复执行</para>
|
||||
/// <para>3. 顺序一致性:利用时间戳防止指令乱序</para>
|
||||
/// </summary>
|
||||
public class CommandBusService : BackgroundService
|
||||
{
|
||||
#region --- 1. 字段与依赖 ---
|
||||
|
||||
private readonly CameraManager _cameraManager;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly IMemoryCache _cache; // 核心:用于请求去重
|
||||
private readonly int _processId;
|
||||
|
||||
// 运行状态标志
|
||||
private volatile bool _isRunning = false;
|
||||
|
||||
// 两种模式的 Socket (互斥存在)
|
||||
private ResponseSocket? _repSocket; // 模式A: 被动监听 (Server-Listening)
|
||||
private DealerSocket? _dealerSocket; // 模式B: 主动投递 (Server-Dialing)
|
||||
|
||||
// 顺序一致性锁:记录每个设备最后处理的指令时间戳
|
||||
// Key: TargetId (设备ID), Value: Timestamp (最后执行时间)
|
||||
private readonly Dictionary<string, DateTime> _deviceLastCmdTime = new();
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 2. 构造函数 ---
|
||||
|
||||
/// <summary>
|
||||
/// 构造函数 (注意:必须在 Program.cs 注册 AddMemoryCache)
|
||||
/// </summary>
|
||||
public CommandBusService(CameraManager manager, IConfiguration config, IMemoryCache cache)
|
||||
{
|
||||
_cameraManager = manager;
|
||||
_config = config;
|
||||
_cache = cache;
|
||||
// 获取当前进程 ID (默认为 1)
|
||||
_processId = _config.GetValue<int>("ProcessId", 1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 3. 核心生命周期 ---
|
||||
|
||||
protected override Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// 在后台线程启动,避免阻塞 Web 主线程
|
||||
return Task.Run(() =>
|
||||
{
|
||||
_isRunning = true;
|
||||
|
||||
// 1. 读取网络策略
|
||||
// 优先读取配置中的主动目标,如果没有则回退到被动监听
|
||||
string? activeTargetIp = _config["Network:ActiveTargets:0:Ip"];
|
||||
bool isActiveMode = !string.IsNullOrEmpty(activeTargetIp);
|
||||
|
||||
try
|
||||
{
|
||||
if (isActiveMode)
|
||||
{
|
||||
// === 模式 B: 主动投递 (Server Connects Client) ===
|
||||
// 场景:服务器在内网,主动连接公网/固定IP的客户端
|
||||
int cmdPort = _config.GetValue<int>("Network:ActiveTargets:0:CmdPort", 7000);
|
||||
string addr = $"tcp://{activeTargetIp}:{cmdPort}";
|
||||
RunActiveMode(addr, stoppingToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// === 模式 A: 被动监听 (Server Binds Port) ===
|
||||
// 场景:服务器有固定IP,等待客户端连接
|
||||
int basePort = _config.GetValue<int>("Network:Passive:CmdPortBase", 7000);
|
||||
int listenPort = basePort + (_processId - 1);
|
||||
string addr = $"tcp://*:{listenPort}";
|
||||
RunPassiveMode(addr, stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[CmdBus] 致命错误停止: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isRunning = false;
|
||||
CleanupSockets();
|
||||
}
|
||||
|
||||
}, stoppingToken);
|
||||
}
|
||||
|
||||
private void CleanupSockets()
|
||||
{
|
||||
try
|
||||
{
|
||||
_repSocket?.Dispose();
|
||||
_dealerSocket?.Dispose();
|
||||
}
|
||||
catch { /* 忽略销毁时的异常 */ }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 4. 模式实现:被动监听 (Passive) ---
|
||||
|
||||
private void RunPassiveMode(string address, CancellationToken token)
|
||||
{
|
||||
using (_repSocket = new ResponseSocket())
|
||||
{
|
||||
_repSocket.Bind(address);
|
||||
Console.WriteLine($"[CmdBus] [被动模式] 指令监听已启动: {address}");
|
||||
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. 阻塞等待请求 (超时1秒以便响应 Cancel 信号)
|
||||
if (!_repSocket.TryReceiveFrameString(TimeSpan.FromSeconds(1), out string reqJson))
|
||||
continue;
|
||||
|
||||
// 2. 处理业务 (带 去重 + ID回填 逻辑)
|
||||
CommandResult result = this.ProcessRequest(reqJson);
|
||||
|
||||
// 3. 发送回执
|
||||
// 注意:REP 模式必须发送应答,即使 result 为 null (Fire-and-Forget) 也建议发一个空 ACK 防止 Socket 状态错乱
|
||||
// 但为了协议统一,建议 Passive 模式下总是返回结果
|
||||
string respJson = result != null ? JsonConvert.SerializeObject(result) : "{}";
|
||||
_repSocket.SendFrame(respJson);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[CmdBus-Passive] 异常: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 5. 模式实现:主动投递 (Active) ---
|
||||
|
||||
private void RunActiveMode(string address, CancellationToken token)
|
||||
{
|
||||
// 外层循环:断线重连机制
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (_dealerSocket = new DealerSocket())
|
||||
{
|
||||
Console.WriteLine($"[CmdBus] [主动模式] 正在连接指令中心: {address}");
|
||||
_dealerSocket.Connect(address);
|
||||
|
||||
// ★★★ 关键步骤:连接成功后,立即发送【身份注册包】 ★★★
|
||||
// 客户端收到这个包后,才能在界面上显示"设备在线"
|
||||
SendRegistration(_dealerSocket);
|
||||
|
||||
// 内层循环:消息收发
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
// 1. 接收指令
|
||||
// DealerSocket 是异步全双工的,这里即使没收到消息也不会阻塞发送
|
||||
if (!_dealerSocket.TryReceiveFrameString(TimeSpan.FromSeconds(1), out string reqJson))
|
||||
{
|
||||
// 空闲周期,可在此处添加心跳发送逻辑 (Ping)
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. 处理业务 (带 去重 + ID回填 逻辑)
|
||||
CommandResult result = this.ProcessRequest(reqJson);
|
||||
|
||||
// 3. 发送结果 (QoS控制)
|
||||
// 如果结果为 null,说明指令是 Fire-and-Forget (无需回执),则不发送网络包节省带宽
|
||||
if (result != null)
|
||||
{
|
||||
_dealerSocket.SendFrame(JsonConvert.SerializeObject(result));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[CmdBus-Active] 连接中断或异常: {ex.Message}");
|
||||
// 避免死循环狂刷 CPU,等待 3 秒再重连
|
||||
Thread.Sleep(3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发送身份注册包 (Active 模式专用)
|
||||
/// </summary>
|
||||
private void SendRegistration(DealerSocket socket)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 计算实际端口信息
|
||||
int portOffset = _processId - 1;
|
||||
var regInfo = new ServerRegistrationDto
|
||||
{
|
||||
ProcessId = _processId,
|
||||
InstanceId = $"Gateway_{_processId}",
|
||||
ServerIp = GetLocalIpAddress(),
|
||||
WebApiPort = 5000 + portOffset,
|
||||
VideoPort = 5555 + portOffset,
|
||||
CmdPort = 7000 + portOffset,
|
||||
StartTime = DateTime.Now,
|
||||
Description = "Active Mode Connection (V2)"
|
||||
};
|
||||
|
||||
// 封装信封 (系统级指令)
|
||||
var payload = new CommandPayload
|
||||
{
|
||||
CmdCode = "SERVER_REGISTER",
|
||||
TargetId = "SYSTEM",
|
||||
JsonParams = JsonConvert.SerializeObject(regInfo),
|
||||
RequestId = Guid.NewGuid().ToString("N"),
|
||||
RequireAck = false // 注册包通常不需要回执,只要连上就行
|
||||
};
|
||||
|
||||
socket.SendFrame(JsonConvert.SerializeObject(payload));
|
||||
Console.WriteLine($"[CmdBus] 身份注册包已发送 -> {regInfo.ServerIp}:{regInfo.WebApiPort}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[CmdBus] 注册包发送失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private string GetLocalIpAddress()
|
||||
{
|
||||
try
|
||||
{
|
||||
var host = System.Net.Dns.GetHostEntry(System.Net.Dns.GetHostName());
|
||||
foreach (var ip in host.AddressList)
|
||||
{
|
||||
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
return ip.ToString();
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return "127.0.0.1";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 6. 协议处理核心 (★★ V2 核心增强 ★★) ---
|
||||
|
||||
/// <summary>
|
||||
/// 统一处理请求协议:去重 -> 排序 -> 执行 -> 回填 ID
|
||||
/// </summary>
|
||||
private CommandResult ProcessRequest(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return CommandResult.Fail("Empty Request");
|
||||
|
||||
CommandPayload? payload;
|
||||
try { payload = JsonConvert.DeserializeObject<CommandPayload>(json); }
|
||||
catch { return CommandResult.Fail("Invalid JSON Protocol"); }
|
||||
|
||||
if (payload == null) return CommandResult.Fail("Null Payload");
|
||||
|
||||
// =========================================================
|
||||
// A. 【幂等性检查】(Idempotency Check)
|
||||
// =========================================================
|
||||
// 查缓存:如果这个 RequestId 10秒内处理过,直接返回上次的结果
|
||||
// 这样即使客户端重试发了 10 次,业务逻辑也只跑 1 次
|
||||
if (_cache.TryGetValue(payload.RequestId, out CommandResult cachedResult))
|
||||
{
|
||||
Console.WriteLine($"[Dedup] 拦截重复请求: {payload.RequestId} (Retry: {payload.RetryCount})");
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// B. 【顺序一致性检查】(Order Guarantee)
|
||||
// =========================================================
|
||||
// 防止乱序:比如先发的“停止”因为网络卡顿,比后发的“开始”晚到
|
||||
if (payload.TargetId != "SYSTEM")
|
||||
{
|
||||
lock (_deviceLastCmdTime)
|
||||
{
|
||||
if (_deviceLastCmdTime.TryGetValue(payload.TargetId, out DateTime lastTime))
|
||||
{
|
||||
if (payload.Timestamp < lastTime)
|
||||
{
|
||||
Console.WriteLine($"[Order] 丢弃乱序指令: {payload.CmdCode}");
|
||||
return CommandResult.Fail("Order Violation: Stale Command Dropped");
|
||||
}
|
||||
}
|
||||
_deviceLastCmdTime[payload.TargetId] = payload.Timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// C. 【业务执行】
|
||||
// =========================================================
|
||||
CommandResult result;
|
||||
try
|
||||
{
|
||||
// 调用纯逻辑层
|
||||
result = CommandBusProcessor.ProcessBusinessLogic(_cameraManager, payload);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result = CommandResult.Fail($"Internal Logic Error: {ex.Message}");
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// D. 【闭环回填】
|
||||
// =========================================================
|
||||
// 必须把身份证号贴回去,不然客户端不知道这是谁的回执
|
||||
result.RequestId = payload.RequestId;
|
||||
|
||||
// =========================================================
|
||||
// E. 【存入缓存】
|
||||
// =========================================================
|
||||
// 缓存 10 秒,覆盖客户端的重试窗口
|
||||
_cache.Set(payload.RequestId, result, TimeSpan.FromSeconds(10));
|
||||
|
||||
// =========================================================
|
||||
// F. 【QoS 过滤】
|
||||
// =========================================================
|
||||
// 如果客户端说不需要回信,返回 null
|
||||
if (!payload.RequireAck)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
using SHH.Contracts; // 引用契约
|
||||
|
||||
namespace SHH.CameraService
|
||||
{
|
||||
/// <summary>
|
||||
/// 摄像头工作者
|
||||
/// 职责:管理海康SDK生命周期,产出 VideoPayload 数据流
|
||||
/// </summary>
|
||||
public class HikCameraWorker : IDisposable
|
||||
{
|
||||
// 定义一个事件:当产生新图片时触发
|
||||
// 参数是我们的标准快递盒 VideoPayload
|
||||
public event Action<VideoPayload> OnNewFrame;
|
||||
|
||||
private bool _isRunning = false;
|
||||
private string _ip;
|
||||
|
||||
public HikCameraWorker(string ip)
|
||||
{
|
||||
_ip = ip;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动取流
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
if (_isRunning) return;
|
||||
|
||||
// TODO: 【在此处填入海康 SDK 初始化代码】
|
||||
// CHCNetSDK.NET_DVR_Init();
|
||||
// CHCNetSDK.NET_DVR_Login_V40(...);
|
||||
// CHCNetSDK.NET_DVR_RealPlay_V40(...);
|
||||
|
||||
Console.WriteLine($"[HikWorker] 摄像头 {_ip} 已启动,开始取流...");
|
||||
_isRunning = true;
|
||||
|
||||
// 模拟一个后台线程不断产出视频帧 (仅用于演示架构)
|
||||
// 实际中,这里应该是海康的 RealDataCallBack 函数
|
||||
Task.Run(() => MockCaptureLoop());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止取流
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
_isRunning = false;
|
||||
// TODO: 【在此处填入海康 SDK 释放代码】
|
||||
// CHCNetSDK.NET_DVR_StopRealPlay(...);
|
||||
// CHCNetSDK.NET_DVR_Logout(...);
|
||||
Console.WriteLine($"[HikWorker] 摄像头 {_ip} 已停止。");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟抓图循环 (实际开发中请替换为 SDK 回调函数)
|
||||
/// </summary>
|
||||
private void MockCaptureLoop()
|
||||
{
|
||||
while (_isRunning)
|
||||
{
|
||||
// 1. 模拟拿到了一张 JPG 图片 (假设 100KB)
|
||||
byte[] mockJpg = new byte[1024 * 100];
|
||||
|
||||
// 2. 立即封装成标准包
|
||||
var payload = new VideoPayload
|
||||
{
|
||||
CameraId = _ip, // 使用 IP 或 ID 作为标记
|
||||
CaptureTime = DateTime.Now,
|
||||
OriginalWidth = 1920,
|
||||
OriginalHeight = 1080,
|
||||
OriginalImageBytes = mockJpg, // 填入原始数据
|
||||
TargetImageBytes = null // SDK 只产出原图,还没有处理图
|
||||
};
|
||||
|
||||
// 3. 【核心】触发事件,把包扔给上层 (主程序)
|
||||
// ?.Invoke 确保如果没有人订阅,不会报错
|
||||
OnNewFrame?.Invoke(payload);
|
||||
|
||||
// 模拟 25fps (每40ms一帧)
|
||||
Thread.Sleep(40);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
83
SHH.CameraService/PipelineConfigurator.cs
Normal file
83
SHH.CameraService/PipelineConfigurator.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using SHH.CameraSdk;
|
||||
|
||||
namespace SHH.CameraService;
|
||||
|
||||
/// <summary>
|
||||
/// 图像处理管道配置服务(基于责任链模式)
|
||||
/// <para>核心职责:</para>
|
||||
/// <para>1. 组装图像处理集群的执行顺序,形成 "缩放 → 增强" 的固定流程</para>
|
||||
/// <para>2. 将组装好的管道挂载到全局路由,统一接收驱动层输出的帧数据</para>
|
||||
/// <para>设计说明:</para>
|
||||
/// <para>- 采用责任链模式,支持动态扩展处理节点(如后续新增滤镜、裁剪等功能)</para>
|
||||
/// <para>- 依赖 IHostedService 生命周期,确保在应用启动时完成管道初始化</para>
|
||||
/// <para>- 与 GlobalPipelineRouter 强关联,是帧数据进入处理流程的唯一入口</para>
|
||||
public class PipelineConfigurator : IHostedService
|
||||
{
|
||||
#region --- 依赖注入字段 ---
|
||||
|
||||
/// <summary>
|
||||
/// 图像缩放集群实例(责任链第一节点)
|
||||
/// 功能:根据配置缩放帧分辨率、控制图像放大/缩小开关
|
||||
/// </summary>
|
||||
private readonly ImageScaleCluster _scale;
|
||||
|
||||
/// <summary>
|
||||
/// 图像增强集群实例(责任链第二节点)
|
||||
/// 功能:调整图像亮度、对比度等增强效果(基于 ProcessingConfigManager 配置)
|
||||
/// </summary>
|
||||
private readonly ImageEnhanceCluster _enhance;
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 构造函数 ---
|
||||
|
||||
/// <summary>
|
||||
/// 初始化管道配置服务实例
|
||||
/// </summary>
|
||||
/// <param name="scale">图像缩放集群(通过 DI 注入,已预设并行度和配置管理器)</param>
|
||||
/// <param name="enhance">图像增强集群(通过 DI 注入,已预设并行度和配置管理器)</param>
|
||||
|
||||
public PipelineConfigurator(ImageScaleCluster scale, ImageEnhanceCluster enhance)
|
||||
{
|
||||
_scale = scale;
|
||||
_enhance = enhance;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- IHostedService 实现 ---
|
||||
|
||||
/// <summary>
|
||||
/// 启动服务:组装责任链并挂载到全局路由
|
||||
/// <para>执行时机:应用启动时,在所有 Singleton 服务初始化完成后触发</para>
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">服务停止令牌(用于响应应用关闭信号)</param>
|
||||
/// <returns>异步任务(无返回值)</returns>
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 建立责任链关系:缩放集群处理完成后,将帧数据传递给增强集群
|
||||
// 设计逻辑:Scale 是入口节点,Enhance 是后续节点,可按需求插入更多处理节点
|
||||
_scale.SetNext(_enhance);
|
||||
|
||||
// 2. 将责任链入口挂载到全局路由:驱动层输出的所有帧数据都会进入该管道
|
||||
// 关键作用:统一帧数据处理入口,屏蔽驱动层与处理层的直接依赖
|
||||
GlobalPipelineRouter.SetProcessor(_scale);
|
||||
|
||||
// 启动日志:打印管道组装结果,便于运维排查
|
||||
Console.WriteLine("[Pipeline] 图像处理链组装完成: ImageScaleCluster -> ImageEnhanceCluster");
|
||||
Console.WriteLine("[Pipeline] 提示:帧数据将按 '缩放 → 增强' 顺序处理,可通过 GlobalPipelineRouter 调整流程");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止服务:空实现(无资源需要释放)
|
||||
/// <para>说明:图像处理集群的资源释放由各自的 Dispose 方法管理,此处无需额外操作</para>
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">服务停止令牌</param>
|
||||
/// <returns>空异步任务</returns>
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using SHH.CameraSdk; // 引用你的业务核心
|
||||
using SHH.NetMQ;
|
||||
@@ -11,94 +10,126 @@ public class Program
|
||||
{
|
||||
public static async Task Main(string[] args)
|
||||
{
|
||||
// =============================================================
|
||||
// 1. 端口与身份计算
|
||||
// =============================================================
|
||||
#region --- 1. 端口与身份计算 ---
|
||||
|
||||
int processId = 1;
|
||||
if (args.Length > 0 && int.TryParse(args[0], out int pid)) processId = pid;
|
||||
// 从命令行参数解析进程ID(默认1)
|
||||
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})";
|
||||
|
||||
// =============================================================
|
||||
// 2. 硬件环境预热 (【重要】必须在一切开始前调用)
|
||||
// =============================================================
|
||||
#endregion
|
||||
|
||||
#region --- 2. 硬件环境预热 (【重要】必须在一切开始前调用) ---
|
||||
|
||||
InitHardwareEnv();
|
||||
|
||||
// =============================================================
|
||||
// 3. 构建 WebHost
|
||||
// =============================================================
|
||||
#endregion
|
||||
|
||||
#region --- 3. 构建 WebHost ---
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// --- A. 注册 ZeroMQ 组件 (传输层) ---
|
||||
#region --- A. 注册 ZeroMQ 组件 (传输层) ---
|
||||
|
||||
// 注册转发客户端(定向推送)
|
||||
string zmqBind = $"tcp://*:{5555 + (processId - 1)}";
|
||||
|
||||
// ★★★ 新增:注册指令总线服务 ★★★
|
||||
string zmqTarget = "tcp://127.0.0.1:6000";
|
||||
|
||||
builder.Services.AddSingleton(new DistributorServer(zmqBind));
|
||||
// 注册转发客户端(定向推送)
|
||||
builder.Services.AddSingleton(new ForwarderClient(zmqTarget));
|
||||
|
||||
// --- B. 注册核心业务服务 ---
|
||||
// ★★★ 新增:注册指令总线服务 ★★★
|
||||
builder.Services.AddHostedService<CommandBusService>();
|
||||
|
||||
// 注册分发服务器(广播)
|
||||
builder.Services.AddSingleton(new DistributorServer(zmqBind));
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- B. 注册核心业务服务 ---
|
||||
|
||||
// 注册文件存储服务(进程隔离)
|
||||
builder.Services.AddSingleton<IStorageService>(new FileStorageService(processId));
|
||||
|
||||
// CameraManager 注册为单例,生命周期由 CameraEngineWorker 管理
|
||||
builder.Services.AddSingleton<CameraManager>();
|
||||
|
||||
// 图像处理配置管理器(单例)
|
||||
builder.Services.AddSingleton<ProcessingConfigManager>();
|
||||
|
||||
// 显示窗口管理器(单例)
|
||||
builder.Services.AddSingleton<DisplayWindowManager>();
|
||||
|
||||
// --- C. 注册图像处理集群 (修复版) ---
|
||||
// 我们需要确保 ImageScaleCluster 和 ImageEnhanceCluster 都能被独立注入,
|
||||
// 同时它们之间又要建立链式关系。我们使用一个专门的 HostedService 来做连接。
|
||||
#endregion
|
||||
|
||||
// 1. 注册 Scale 实例
|
||||
#region --- C. 注册图像处理集群 (修复版) ---
|
||||
|
||||
// 说明:通过责任链模式组装 Scale → Enhance 处理流程,确保顺序执行
|
||||
// 1. 注册图像缩容集群(并行度4)
|
||||
builder.Services.AddSingleton<ImageScaleCluster>(sp =>
|
||||
{
|
||||
var config = sp.GetRequiredService<ProcessingConfigManager>();
|
||||
return new ImageScaleCluster(4, config);
|
||||
var configManager = sp.GetRequiredService<ProcessingConfigManager>();
|
||||
return new ImageScaleCluster(4, configManager);
|
||||
});
|
||||
|
||||
// 2. 注册 Enhance 实例
|
||||
// 2. 注册图像增强集群(并行度4)
|
||||
builder.Services.AddSingleton<ImageEnhanceCluster>(sp =>
|
||||
{
|
||||
var config = sp.GetRequiredService<ProcessingConfigManager>();
|
||||
return new ImageEnhanceCluster(4, config);
|
||||
var configManager = sp.GetRequiredService<ProcessingConfigManager>();
|
||||
return new ImageEnhanceCluster(4, configManager);
|
||||
});
|
||||
|
||||
// 3. 注册一个启动服务来连接这两个集群 (Chain of Responsibility)
|
||||
// 3. 注册管道配置服务(组装责任链)
|
||||
builder.Services.AddHostedService<PipelineConfigurator>();
|
||||
|
||||
#endregion
|
||||
|
||||
// --- D. 注册 Web 基础服务 ---
|
||||
#region --- D. 注册 Web 基础服务 ---
|
||||
|
||||
// 注册控制器(加载 SDK 中的 CamerasController、MonitorController)
|
||||
builder.Services.AddControllers()
|
||||
.AddApplicationPart(typeof(CamerasController).Assembly) // 加载 SDK 中的控制器
|
||||
.AddApplicationPart(typeof(MonitorController).Assembly)
|
||||
.AddControllersAsServices();
|
||||
|
||||
// 注册全局操作日志过滤器 (防止 500 错误)
|
||||
// 注册全局操作日志过滤器(捕获 API 操作日志)
|
||||
builder.Services.AddScoped<UserActionFilter>();
|
||||
|
||||
// 注册 Swagger 文档(区分实例ID)
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
c.SwaggerDoc("v1", new OpenApiInfo { Title = $"Gateway #{processId}", Version = "v1" });
|
||||
c.SwaggerDoc("v1", new OpenApiInfo
|
||||
{
|
||||
Title = $"Gateway #{processId}",
|
||||
Version = "v1"
|
||||
});
|
||||
});
|
||||
|
||||
// --- E. 注册后台服务 (Worker) ---
|
||||
#endregion
|
||||
|
||||
#region --- E. 注册后台服务 (Worker) ---
|
||||
|
||||
// 1. 核心引擎工作者 (负责 StartAsync 和 ConfigureBusinessLogic)
|
||||
builder.Services.AddHostedService<CameraEngineWorker>();
|
||||
|
||||
// 2. 网络哨兵 (负责断线重连)
|
||||
// 假设 ConnectivitySentinel 实现了 IHostedService 或者它是一个简单的类
|
||||
// 如果它实现了 IHostedService:
|
||||
// builder.Services.AddHostedService<ConnectivitySentinel>();
|
||||
// 如果它只是一个普通类,需要在 CameraEngineWorker 里启动它,或者注册为单例并手动启动
|
||||
// 这里假设我们需要显式注册它以便让它工作:
|
||||
builder.Services.AddSingleton<ConnectivitySentinel>(); // 注册单例
|
||||
// 注意:ConnectivitySentinel 的启动逻辑我们放到 CameraEngineWorker 里去调用
|
||||
// 2.网络哨兵(负责断线重连)(监控设备断线重连,注册为单例)
|
||||
builder.Services.AddSingleton<ConnectivitySentinel>();
|
||||
|
||||
// 3. ZeroMQ 桥梁
|
||||
// 3. ZeroMQ 桥梁服务(转发帧数据到外部系统)
|
||||
builder.Services.AddHostedService<ZeroMqBridgeService>();
|
||||
|
||||
// 4. 配置 CORS
|
||||
#endregion
|
||||
|
||||
#region --- F. 配置 CORS(允许所有跨域请求) ---
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("AllowAll", policy =>
|
||||
@@ -107,144 +138,57 @@ public class Program
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 4. 启动应用
|
||||
// =============================================================
|
||||
#endregion
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 4. 启动应用 ---
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// 启用 Swagger 文档
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
app.UseCors("AllowAll"); // 启用 CORS
|
||||
|
||||
// 启用 CORS 策略
|
||||
app.UseCors("AllowAll");
|
||||
|
||||
// 映射控制器路由
|
||||
app.MapControllers();
|
||||
|
||||
// 输出启动信息
|
||||
Console.WriteLine($"[System] 绑定 Web 端口: {port}");
|
||||
Console.WriteLine($"[System] 绑定 ZMQ 端口: {zmqBind}");
|
||||
|
||||
// 启动 Web 应用
|
||||
await app.RunAsync($"http://0.0.0.0:{port}");
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region --- 辅助方法:硬件环境预热 ---
|
||||
|
||||
/// <summary>
|
||||
/// 初始化硬件环境(海康 SDK 预热)
|
||||
/// </summary>
|
||||
static void InitHardwareEnv()
|
||||
{
|
||||
Console.WriteLine("=== 工业级视频 SDK 架构测试 (V3.5 框架版) ===");
|
||||
Console.WriteLine("[硬件] 海康驱动预热中...");
|
||||
try
|
||||
{
|
||||
// 初始化海康 SDK
|
||||
HikNativeMethods.NET_DVR_Init();
|
||||
// 强制预热播放库(避免首次取流延迟)
|
||||
HikSdkManager.ForceWarmUp();
|
||||
Console.WriteLine("[硬件] 预热完成。");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[硬件] 预热失败: {ex.Message}");
|
||||
// 不抛出异常,允许程序继续尝试启动(可能是在无 DLL 环境调试)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 负责图像处理管道的组装 (Scale -> Enhance -> Global)
|
||||
/// </summary>
|
||||
public class PipelineConfigurator : IHostedService
|
||||
{
|
||||
private readonly ImageScaleCluster _scale;
|
||||
private readonly ImageEnhanceCluster _enhance;
|
||||
|
||||
public PipelineConfigurator(ImageScaleCluster scale, ImageEnhanceCluster enhance)
|
||||
{
|
||||
_scale = scale;
|
||||
_enhance = enhance;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// 建立责任链: Scale -> Enhance
|
||||
_scale.SetNext(_enhance);
|
||||
|
||||
// 挂载到全局路由 (驱动层回调会把流推给 Scale)
|
||||
GlobalPipelineRouter.SetProcessor(_scale);
|
||||
|
||||
Console.WriteLine("[Pipeline] 图像处理链组装完成: Scale -> Enhance");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 负责 CameraManager 的生命周期管理和业务初始化
|
||||
/// </summary>
|
||||
public class CameraEngineWorker : BackgroundService
|
||||
{
|
||||
private readonly CameraManager _manager;
|
||||
private readonly ConnectivitySentinel _sentinel; // 注入哨兵
|
||||
|
||||
public CameraEngineWorker(CameraManager manager, ConnectivitySentinel sentinel)
|
||||
{
|
||||
_manager = manager;
|
||||
_sentinel = sentinel;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
Console.WriteLine("[Engine] 正在启动摄像头管理器...");
|
||||
|
||||
// 1. 启动管理器 (加载文件配置)
|
||||
await _manager.StartAsync();
|
||||
|
||||
// 2. 启动哨兵 (开始监控断线)
|
||||
// 假设 ConnectivitySentinel 有一个 Start 或类似的方法,如果没有,说明它在构造函数里就启动了 timers
|
||||
// _sentinel.Start();
|
||||
|
||||
// 3. 加载默认业务逻辑 (添加测试设备)
|
||||
await ConfigureBusinessLogic(_manager);
|
||||
|
||||
Console.WriteLine("[Engine] 业务逻辑加载完成。");
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Console.WriteLine("[Engine] 正在停止...");
|
||||
await _manager.DisposeAsync();
|
||||
await base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// 以前 Program 类里的静态方法,现在移到这里
|
||||
private async Task ConfigureBusinessLogic(CameraManager manager)
|
||||
{
|
||||
try
|
||||
{
|
||||
//// 检查是否已经有设备了,如果没有才添加默认的
|
||||
//if (manager.GetAllCameras().Any()) return;
|
||||
|
||||
Console.WriteLine("[Engine] 检测到空配置,正在添加默认测试设备...");
|
||||
|
||||
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 (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Engine] 添加默认设备失败: {ex.Message}");
|
||||
// 不抛出异常,允许程序在无 DLL 环境下调试
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -5,72 +5,136 @@ using SHH.NetMQ;
|
||||
|
||||
namespace SHH.CameraSdk
|
||||
{
|
||||
/// <summary>
|
||||
/// ZeroMQ 消息桥接服务(后台服务)。
|
||||
/// 核心职责:订阅系统全局视频帧广播,将帧数据编码为标准协议格式后,通过 ZeroMQ 分发至外部系统。
|
||||
/// 设计特性:
|
||||
/// <para>1. 无侵入集成:通过订阅 GlobalStreamDispatcher 事件,无需修改原有帧处理流程。</para>
|
||||
/// <para>2. 自动适配:支持动态增删设备,无需手动注册设备监听。</para>
|
||||
/// <para>3. 安全隔离:帧数据深拷贝,避免跨线程内存访问冲突。</para>
|
||||
/// </summary>
|
||||
public class ZeroMqBridgeService : BackgroundService
|
||||
{
|
||||
#region --- 依赖注入字段 ---
|
||||
|
||||
/// <summary>
|
||||
/// ZeroMQ 分发服务器(用于广播帧数据至多个订阅端)
|
||||
/// </summary>
|
||||
private readonly DistributorServer _distributor;
|
||||
|
||||
/// <summary>
|
||||
/// ZeroMQ 转发客户端(用于定向推送帧数据至指定目标)
|
||||
/// </summary>
|
||||
private readonly ForwarderClient _forwarder;
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 构造函数 ---
|
||||
|
||||
/// <summary>
|
||||
/// 初始化 <see cref="ZeroMqBridgeService"/> 实例。
|
||||
/// </summary>
|
||||
/// <param name="distributor">ZeroMQ 分发服务器实例(通过 DI 注入)</param>
|
||||
/// <param name="forwarder">ZeroMQ 转发客户端实例(通过 DI 注入)</param>
|
||||
public ZeroMqBridgeService(DistributorServer distributor, ForwarderClient forwarder)
|
||||
{
|
||||
_distributor = distributor;
|
||||
_forwarder = forwarder;
|
||||
_distributor = distributor ?? throw new ArgumentNullException(nameof(distributor));
|
||||
_forwarder = forwarder ?? throw new ArgumentNullException(nameof(forwarder));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 后台服务核心逻辑 ---
|
||||
|
||||
/// <summary>
|
||||
/// 启动后台服务,订阅全局视频帧广播。
|
||||
/// </summary>
|
||||
/// <param name="stoppingToken">服务停止令牌(用于优雅关闭)</param>
|
||||
protected override Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
Console.WriteLine("[Bridge] 正在连接全局广播总线...");
|
||||
Console.WriteLine("[ZeroMQ Bridge] 正在连接全局视频帧广播总线...");
|
||||
|
||||
// 【关键修改】直接订阅静态的全局事件
|
||||
// 不需要传入 APP_ID,因为这是 C# 原生事件,不是字典查找
|
||||
GlobalStreamDispatcher.OnGlobalFrame += BridgeHandler;
|
||||
// 订阅全局帧广播事件:所有设备的帧数据都会触发该事件
|
||||
// 无需手动绑定设备,动态增删的设备自动适配
|
||||
GlobalStreamDispatcher.OnGlobalFrame += OnGlobalFrameReceived;
|
||||
|
||||
Console.WriteLine("[Bridge] 全局总线连接成功!任何动态增删的设备都会自动转发。");
|
||||
Console.WriteLine("[ZeroMQ Bridge] 全局总线连接成功!已开始监听所有设备帧数据。");
|
||||
Console.WriteLine("[ZeroMQ Bridge] 说明:动态增删的设备会自动转发,无需重启服务。");
|
||||
|
||||
// 返回空任务:服务通过事件驱动,无需阻塞主线程
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// 真正的事件处理函数
|
||||
private void BridgeHandler(long deviceId, SmartFrame frame)
|
||||
/// <summary>
|
||||
/// 停止后台服务,取消事件订阅以避免内存泄漏。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌</param>
|
||||
public override Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Console.WriteLine("[ZeroMQ Bridge] 正在停止服务,取消全局总线订阅...");
|
||||
|
||||
// 取消事件订阅:必须执行,否则会导致内存泄漏
|
||||
GlobalStreamDispatcher.OnGlobalFrame -= OnGlobalFrameReceived;
|
||||
|
||||
Console.WriteLine("[ZeroMQ Bridge] 服务已安全停止。");
|
||||
return base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 帧数据处理核心逻辑 ---
|
||||
|
||||
/// <summary>
|
||||
/// 全局帧数据接收回调(事件处理函数)。
|
||||
/// 处理流程:安全检查 → 帧数据深拷贝 → JPG 编码 → 封装为标准协议 → ZeroMQ 分发。
|
||||
/// </summary>
|
||||
/// <param name="deviceId">产生该帧的设备唯一标识</param>
|
||||
/// <param name="frame">智能帧对象(包含原始/处理后图像数据)</param>
|
||||
private void OnGlobalFrameReceived(long deviceId, SmartFrame frame)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. 安全检查
|
||||
// 1. 安全校验:跳过空帧或已释放的帧
|
||||
var sourceMat = frame.TargetMat ?? frame.InternalMat;
|
||||
if (sourceMat == null || sourceMat.Empty()) return;
|
||||
if (sourceMat == null || sourceMat.Empty() || sourceMat.IsDisposed)
|
||||
return;
|
||||
|
||||
// 2. 内存克隆 (Deep Copy) - 这一步不能省
|
||||
// 2. 深拷贝图像数据:避免跨线程访问冲突(原帧可能被其他模块异步释放)
|
||||
using var safeMat = sourceMat.Clone();
|
||||
|
||||
// 3. 编码 & 封装
|
||||
// 建议:可以在这里判断一下 deviceId,如果某些设备不想发,可以在这里 return
|
||||
var jpgParams = new int[] { (int)ImwriteFlags.JpegQuality, 70 };
|
||||
byte[] jpgBytes = safeMat.ImEncode(".jpg", jpgParams);
|
||||
// 3. 图像编码:将 OpenCV Mat 转换为 JPG 字节数组(质量70,平衡画质与性能)
|
||||
var jpgEncodeParams = new int[] { (int)ImwriteFlags.JpegQuality, 70 };
|
||||
byte[] jpgBytes = safeMat.ImEncode(".jpg", jpgEncodeParams);
|
||||
|
||||
var payload = new VideoPayload
|
||||
// 4. 封装为标准传输协议:使用 SHH.Contracts 中的 VideoPayload 统一格式
|
||||
var videoPayload = new VideoPayload
|
||||
{
|
||||
CameraId = deviceId.ToString(),
|
||||
CaptureTime = DateTime.Now,
|
||||
DispatchTime = DateTime.Now,
|
||||
OriginalWidth = safeMat.Width,
|
||||
OriginalHeight = safeMat.Height,
|
||||
OriginalImageBytes = jpgBytes,
|
||||
CameraId = deviceId.ToString(), // 设备ID(转为字符串,兼容协议标准)
|
||||
CaptureTime = DateTime.Now, // 帧采集时间(当前时间)
|
||||
DispatchTime = DateTime.Now, // 帧分发时间(当前时间)
|
||||
OriginalWidth = safeMat.Width, // 图像原始宽度(编码后宽度)
|
||||
OriginalHeight = safeMat.Height, // 图像原始高度(编码后高度)
|
||||
OriginalImageBytes = jpgBytes // JPG 编码后的二进制数据
|
||||
};
|
||||
payload.SubscriberIds.AddRange(frame.SubscriberIds);
|
||||
|
||||
// 4. 发射
|
||||
_distributor.Broadcast(payload);
|
||||
_forwarder.Push(payload);
|
||||
// 5. 传递订阅者ID:保持与原帧的订阅者关联
|
||||
if (frame.SubscriberIds.Any())
|
||||
videoPayload.SubscriberIds.AddRange(frame.SubscriberIds);
|
||||
|
||||
// 6. ZeroMQ 分发:同时执行广播和定向推送(根据业务需求选择,可按需注释)
|
||||
_distributor.Broadcast(videoPayload); // 广播给所有订阅端
|
||||
_forwarder.Push(videoPayload); // 定向推送给指定目标
|
||||
|
||||
// 调试日志(生产环境建议注释,避免性能损耗)
|
||||
// Console.WriteLine($"[ZeroMQ Bridge] 转发设备 {deviceId} 帧数据,大小:{jpgBytes.Length / 1024}KB");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Console.WriteLine(ex.Message); // 生产环境建议注释掉,防止日志刷屏
|
||||
// 异常隔离:单个帧处理失败不影响整体服务运行
|
||||
Console.WriteLine($"[ZeroMQ Bridge] 帧转发失败(设备ID:{deviceId}):{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public override Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// 优雅退订,防止内存泄漏
|
||||
GlobalStreamDispatcher.OnGlobalFrame -= BridgeHandler;
|
||||
return base.StopAsync(cancellationToken);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
73
SHH.Contracts/CommandPayload.cs
Normal file
73
SHH.Contracts/CommandPayload.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
|
||||
namespace SHH.Contracts
|
||||
{
|
||||
/// <summary>
|
||||
/// 通用指令请求载体 (Request)
|
||||
/// <para>用于 NetMQ 的 Request-Reply 或 Router-Dealer 模式</para>
|
||||
/// </summary>
|
||||
public class CommandPayload
|
||||
{
|
||||
#region --- 核心路由信息 ---
|
||||
|
||||
/// <summary>
|
||||
/// 指令代码 (路由键)
|
||||
/// <para>示例: "PTZ", "RECORD_START", "SERVER_REGISTER"</para>
|
||||
/// </summary>
|
||||
public string CmdCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标对象 ID
|
||||
/// <para>示例: 摄像头ID "101",或者系统级指令填 "SYSTEM"</para>
|
||||
/// </summary>
|
||||
public string TargetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 业务参数 (JSON 字符串)
|
||||
/// <para>根据 CmdCode 的不同,反序列化为不同的 DTO (如 PtzControlDto)</para>
|
||||
/// </summary>
|
||||
public string JsonParams { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 追踪与元数据 ---
|
||||
|
||||
/// <summary>
|
||||
/// 请求追踪 ID (UUID)
|
||||
/// <para>核心字段:用于实现异步等待 (await)。回执包必须携带此 ID。</para>
|
||||
/// </summary>
|
||||
public string RequestId { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// 发送时间戳
|
||||
/// </summary>
|
||||
public DateTime Timestamp { get; set; } = DateTime.Now;
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 可靠性控制字段 (QoS) ---
|
||||
|
||||
/// <summary>
|
||||
/// 是否需要回执 (ACK)
|
||||
/// <para>true: 发送端会 await 等待结果 (默认)</para>
|
||||
/// <para>false: 发后即忘 (Fire-and-Forget),服务端收到后不回发任何消息,减少带宽</para>
|
||||
/// </summary>
|
||||
public bool RequireAck { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 重试计数器
|
||||
/// <para>0: 首次发送</para>
|
||||
/// <para>1, 2...: 第N次重试</para>
|
||||
/// <para>服务端据此判断是否需要查重 (幂等性处理)</para>
|
||||
/// </summary>
|
||||
public int RetryCount { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 消息过期时间 (Unix时间戳)
|
||||
/// <para>如果接收端收到时已经超过此时间,直接丢弃,不处理也不回复</para>
|
||||
/// </summary>
|
||||
public long ExpireTime { get; set; }
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
65
SHH.Contracts/CommandResult.cs
Normal file
65
SHH.Contracts/CommandResult.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
namespace SHH.Contracts
|
||||
{
|
||||
/// <summary>
|
||||
/// 通用指令执行结果 (Response)
|
||||
/// </summary>
|
||||
public class CommandResult
|
||||
{
|
||||
#region --- 核心匹配信息 ---
|
||||
|
||||
/// <summary>
|
||||
/// 回执 ID (必须与请求包的 RequestId 一致)
|
||||
/// <para>客户端靠这个 ID 来找到对应的 await Task</para>
|
||||
/// </summary>
|
||||
public string RequestId { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 执行结果 ---
|
||||
|
||||
/// <summary>
|
||||
/// 执行是否成功
|
||||
/// </summary>
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结果消息 (成功提示或错误原因)
|
||||
/// </summary>
|
||||
public string Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回的数据 (JSON 或 Base64 字符串)
|
||||
/// <para>示例: 截图的 Base64,或者查询到的设备列表 JSON</para>
|
||||
/// </summary>
|
||||
public string Data { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 性能统计 ---
|
||||
|
||||
/// <summary>
|
||||
/// 全链路耗时 (毫秒)
|
||||
/// <para>从客户端发出指令,到收到服务端回执的总时长</para>
|
||||
/// <para>注意:该字段由客户端收到回执后自动计算填充,服务端不需要赋值</para>
|
||||
/// </summary>
|
||||
public double ElapsedMilliseconds { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 快捷构造方法 ---
|
||||
|
||||
/// <summary>
|
||||
/// 快速创建一个成功的回执
|
||||
/// </summary>
|
||||
public static CommandResult Ok(string msg = "OK", string data = null)
|
||||
=> new CommandResult { Success = true, Message = msg, Data = data };
|
||||
|
||||
/// <summary>
|
||||
/// 快速创建一个失败的回执
|
||||
/// </summary>
|
||||
public static CommandResult Fail(string msg)
|
||||
=> new CommandResult { Success = false, Message = msg };
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
71
SHH.Contracts/ServerRegistrationDto.cs
Normal file
71
SHH.Contracts/ServerRegistrationDto.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
|
||||
namespace SHH.Contracts
|
||||
{
|
||||
/// <summary>
|
||||
/// 服务端身份注册信息 (DTO)
|
||||
/// <para>用于服务端主动连上客户端后,上报自身的端口和身份信息</para>
|
||||
/// </summary>
|
||||
public class ServerRegistrationDto
|
||||
{
|
||||
#region --- 1. 身份标识 ---
|
||||
|
||||
/// <summary>
|
||||
/// 进程 ID (用于区分同一台机器上的多个实例)
|
||||
/// </summary>
|
||||
public int ProcessId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实例唯一标识符
|
||||
/// <para>启动时通过命令行传入,例如 "Gateway_Factory_A"</para>
|
||||
/// </summary>
|
||||
public string InstanceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务端版本号
|
||||
/// </summary>
|
||||
public string Version { get; set; } = "1.0.0";
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 2. 网络诊断信息 (用于运维) ---
|
||||
|
||||
/// <summary>
|
||||
/// 服务端所在的局域网 IP
|
||||
/// <para>客户端无法直接连接此IP(因为可能是内网),但运维人员需要知道</para>
|
||||
/// </summary>
|
||||
public string ServerIp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// WebAPI 监听端口 (HTTP)
|
||||
/// <para>用于运维人员打开 Swagger 进行调试</para>
|
||||
/// </summary>
|
||||
public int WebApiPort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 视频流端口 (ZeroMQ Publisher/Push)
|
||||
/// </summary>
|
||||
public int VideoPort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 指令流端口 (ZeroMQ Response)
|
||||
/// </summary>
|
||||
public int CmdPort { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 3. 运行时状态 ---
|
||||
|
||||
/// <summary>
|
||||
/// 启动时间
|
||||
/// </summary>
|
||||
public DateTime StartTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 描述信息 (可选)
|
||||
/// </summary>
|
||||
public string Description { get; set; }
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace SHH.Contracts
|
||||
{
|
||||
@@ -9,57 +9,90 @@ namespace SHH.Contracts
|
||||
/// </summary>
|
||||
public class VideoPayload
|
||||
{
|
||||
public List<string> SubscriberIds { get; } = new List<string>(16);
|
||||
/// <summary>
|
||||
/// 初始化 <see cref="VideoPayload"/> 类的新实例。
|
||||
/// </summary>
|
||||
public VideoPayload()
|
||||
{
|
||||
// 预分配一个容量为 16 的列表,以减少内存分配和垃圾回收的压力。
|
||||
SubscriberIds = new List<string>(16);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 1. 基础元数据 (将被序列化到 JSON)
|
||||
// ==========================================
|
||||
|
||||
public string CameraId { get; set; } // 摄像头唯一标记
|
||||
|
||||
// 时间信息 (建议使用 DateTime,调试看日志更直观)
|
||||
public DateTime CaptureTime { get; set; } // 采集时间 (SDK产生图的时间)
|
||||
public DateTime DispatchTime { get; set; } // 分发时间 (Server发出图的时间)
|
||||
|
||||
// ==========================================
|
||||
// 2. 图像规格信息
|
||||
// ==========================================
|
||||
|
||||
public int OriginalWidth { get; set; } // 原始宽度
|
||||
public int OriginalHeight { get; set; } // 原始高度
|
||||
|
||||
public int TargetWidth { get; set; } // 目标/处理后宽度
|
||||
public int TargetHeight { get; set; } // 目标/处理后高度
|
||||
|
||||
// ==========================================
|
||||
// 3. 核心二进制数据 (严禁序列化到 JSON)
|
||||
// ==========================================
|
||||
#region --- 元数据 (Metadata) ---
|
||||
|
||||
/// <summary>
|
||||
/// 原始图像数据 (例如海康SDK出来的原始 JPG)
|
||||
/// JsonIgnore 防止误操作导致序列化性能崩塌
|
||||
/// 获取订阅了此帧数据的客户端ID列表。
|
||||
/// </summary>
|
||||
public List<string> SubscriberIds { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置摄像头的唯一标识符。
|
||||
/// </summary>
|
||||
public string CameraId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置图像的采集时间,即从SDK获取到图像数据的时间。
|
||||
/// </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>
|
||||
/// 获取或设置经过处理后的目标图像宽度。
|
||||
/// </summary>
|
||||
public int TargetWidth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置经过处理后的目标图像高度。
|
||||
/// </summary>
|
||||
public int TargetHeight { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 核心二进制数据 ---
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置原始图像的二进制数据(例如,从SDK直接获取的JPG或YUV数据)。
|
||||
/// 此属性被标记为 <see cref="JsonIgnore"/>,以防止在序列化元数据时将其包含在内,从而避免严重的性能问题。
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public byte[] OriginalImageBytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 处理后的目标图像 (例如 Yolo 画框后的图,或者缩放后的图)
|
||||
/// 可为空
|
||||
/// 获取或设置经过处理后的目标图像的二进制数据(例如,经过缩放、画框或其他AI处理后的图像)。
|
||||
/// 此属性可为空,表示此帧可能只包含原始图像或没有图像数据。
|
||||
/// 同样,此属性也被标记为 <see cref="JsonIgnore"/>。
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public byte[] TargetImageBytes { get; set; }
|
||||
|
||||
// ==========================================
|
||||
// 4. 辅助方法
|
||||
// ==========================================
|
||||
#endregion
|
||||
|
||||
#region --- 序列化与反序列化辅助方法 ---
|
||||
|
||||
/// <summary>
|
||||
/// 仅获取元数据的 JSON 字符串
|
||||
/// 将当前对象的元数据序列化为一个纯净的 JSON 字符串。
|
||||
/// 此方法会自动忽略所有二进制数据(<see cref="OriginalImageBytes"/> 和 <see cref="TargetImageBytes"/>)。
|
||||
/// </summary>
|
||||
/// <returns>包含元数据的 JSON 字符串。</returns>
|
||||
public string GetMetadataJson()
|
||||
{
|
||||
// 创建一个纯净的匿名对象用于序列化
|
||||
var meta = new
|
||||
// 创建一个匿名对象,该对象仅包含需要被序列化的元数据字段。
|
||||
// 这比直接序列化整个对象更安全、更高效。
|
||||
var metadata = new
|
||||
{
|
||||
CameraId,
|
||||
CaptureTime,
|
||||
@@ -69,18 +102,24 @@ namespace SHH.Contracts
|
||||
TargetWidth,
|
||||
TargetHeight,
|
||||
SubscriberIds,
|
||||
// 标记一下是否有目标图,方便接收端判断要不要读第3帧
|
||||
// 附加一个标志,指示此载荷中是否包含目标图像数据,以便接收端进行判断。
|
||||
HasTargetImage = (TargetImageBytes != null && TargetImageBytes.Length > 0)
|
||||
};
|
||||
return JsonConvert.SerializeObject(meta);
|
||||
return JsonConvert.SerializeObject(metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从 JSON 还原元数据 (还原出来的对象 ImageBytes 默认为空,需后续填充)
|
||||
/// 从一个 JSON 字符串反序列化,创建一个新的 <see cref="VideoPayload"/> 对象。
|
||||
/// 注意:反序列化后,对象中的二进制图像数据(<see cref="OriginalImageBytes"/> 和 <see cref="TargetImageBytes"/>)将为 null,
|
||||
/// 需要在后续步骤中手动填充。
|
||||
/// </summary>
|
||||
/// <param name="json">包含元数据的 JSON 字符串。</param>
|
||||
/// <returns>一个新的 <see cref="VideoPayload"/> 对象,其元数据已填充。</returns>
|
||||
public static VideoPayload FromMetadataJson(string json)
|
||||
{
|
||||
return JsonConvert.DeserializeObject<VideoPayload>(json);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,69 +1,147 @@
|
||||
using System;
|
||||
using NetMQ;
|
||||
using NetMQ.Sockets;
|
||||
using Newtonsoft.Json;
|
||||
using SHH.Contracts;
|
||||
|
||||
namespace SHH.NetMQ
|
||||
{
|
||||
/// <summary>
|
||||
/// 视频转发客户端 (Pusher)
|
||||
/// 特性:主动推送、断线重连、非阻塞
|
||||
/// 视频转发客户端 (Fusion Version)
|
||||
/// <para>核心特性:</para>
|
||||
/// <para>1. 主动推送 (Push Mode)</para>
|
||||
/// <para>2. 带宽保护 (HWM=2, 拥堵丢弃)</para>
|
||||
/// <para>3. 高效传输 (Multipart, 避免 Base64)</para>
|
||||
/// <para>4. 线程安全 (Lock 保护)</para>
|
||||
/// </summary>
|
||||
public class ForwarderClient : IDisposable
|
||||
{
|
||||
private PushSocket _pushSocket;
|
||||
private readonly object _lock = new object();
|
||||
private bool _isInitialized = false;
|
||||
#region --- 字段与配置 ---
|
||||
|
||||
// 同样设置 50 帧的缓存限制
|
||||
private const int HWM_LIMIT = 50;
|
||||
private readonly PushSocket _pushSocket;
|
||||
private readonly object _lock = new object();
|
||||
private bool _isDisposed = false;
|
||||
|
||||
// ★★★ 核心策略:高水位线 (High Water Mark) ★★★
|
||||
// 设置为 2,意味着内存队列中最多只允许堆积 2 帧图片。
|
||||
// 第 3 帧到来时,如果网速不够发不出去,这第 3 帧会被直接丢弃。
|
||||
// 收益:永远只发最新的图,永远不挤占物理带宽,彻底杜绝延迟累积。
|
||||
private const int HWM_LIMIT = 2;
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 构造函数 ---
|
||||
|
||||
public ForwarderClient(string remoteAddress)
|
||||
{
|
||||
if (string.IsNullOrEmpty(remoteAddress)) return;
|
||||
|
||||
_pushSocket = new PushSocket();
|
||||
|
||||
// 1. 防堆积设置
|
||||
_pushSocket.Options.SendHighWatermark = HWM_LIMIT;
|
||||
if (string.IsNullOrWhiteSpace(remoteAddress))
|
||||
throw new ArgumentNullException(nameof(remoteAddress));
|
||||
|
||||
try
|
||||
{
|
||||
// NetMQ 会自动在后台处理重连,无需人工干预
|
||||
_pushSocket = new PushSocket();
|
||||
|
||||
// 1. 配置防堆积策略 (必须在 Connect 之前设置)
|
||||
_pushSocket.Options.SendHighWatermark = HWM_LIMIT;
|
||||
|
||||
// 2. 配置 Linger (逗留时间)
|
||||
// 设为 0 表示:当 Dispose 时,如果队列里还有没发完的数据,直接扔掉,不要等待。
|
||||
// 避免关程序时卡死。
|
||||
_pushSocket.Options.Linger = TimeSpan.Zero;
|
||||
|
||||
// 3. 建立连接 (NetMQ 会自动在后台处理断线重连)
|
||||
_pushSocket.Connect(remoteAddress);
|
||||
_isInitialized = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Client Error] 连接失败: {ex.Message}");
|
||||
_isInitialized = false;
|
||||
// 构造函数异常通常是致命的,向上抛出让启动流程感知
|
||||
throw new InvalidOperationException($"[ForwarderClient] 初始化失败: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 核心发送逻辑 ---
|
||||
|
||||
/// <summary>
|
||||
/// 推送视频帧 (非阻塞,线程安全)
|
||||
/// </summary>
|
||||
/// <param name="payload">视频帧载荷</param>
|
||||
public void Push(VideoPayload payload)
|
||||
{
|
||||
if (!_isInitialized || payload == null) return;
|
||||
if (_isDisposed || payload == null) return;
|
||||
|
||||
if (payload.DispatchTime == DateTime.MinValue)
|
||||
payload.DispatchTime = DateTime.Now;
|
||||
|
||||
var msg = new NetMQMessage();
|
||||
msg.Append(payload.GetMetadataJson());
|
||||
msg.Append(payload.OriginalImageBytes ?? new byte[0]);
|
||||
msg.Append(payload.TargetImageBytes ?? new byte[0]);
|
||||
|
||||
lock (_lock)
|
||||
try
|
||||
{
|
||||
// 2. 非阻塞推送
|
||||
// 如果对方挂了,或者网络断了,缓冲区满后这里的 TrySend 会立即返回 false
|
||||
// 保证 SDK 采集不受影响
|
||||
bool sent = _pushSocket.TrySendMultipartMessage(TimeSpan.Zero, msg);
|
||||
// 1. 准备多帧消息 (Multipart Message)
|
||||
// 这种方式比把 byte[] 转成 Base64 字符串塞进 JSON 要高效得多 (减少 33% 体积,且无 GC 压力)
|
||||
var msg = new NetMQMessage();
|
||||
|
||||
// --- 第一帧:元数据 (JSON) ---
|
||||
// 我们使用匿名对象来生成 JSON,刻意排除 byte[] 数组
|
||||
// 这样生成的 JSON 非常小,只有几十字节
|
||||
var metaJson = JsonConvert.SerializeObject(new
|
||||
{
|
||||
payload.CameraId,
|
||||
payload.CaptureTime,
|
||||
payload.DispatchTime,
|
||||
payload.OriginalWidth,
|
||||
payload.OriginalHeight,
|
||||
// 如果有订阅者ID列表也带上
|
||||
payload.SubscriberIds
|
||||
});
|
||||
msg.Append(metaJson);
|
||||
|
||||
// --- 第二帧:原始图像数据 (Binary) ---
|
||||
// 直接追加二进制数据,实现 Zero-Copy (零拷贝)
|
||||
// NetMQ 底层会直接搬运这段内存,不会产生临时的 Base64 字符串
|
||||
msg.Append(payload.OriginalImageBytes ?? Array.Empty<byte>());
|
||||
|
||||
// 2. 线程安全发送
|
||||
// NetMQ 的 Socket 实例不是线程安全的,多线程同时调用 Push 必须加锁
|
||||
lock (_lock)
|
||||
{
|
||||
// 3. 非阻塞尝试发送 (TrySend)
|
||||
// TimeSpan.Zero 表示:如果队列满了 (超过 HWM),立刻返回 false,不要等待。
|
||||
// 这实现了 "拥堵即丢弃" 的保护机制。
|
||||
if (!_pushSocket.TrySendMultipartMessage(TimeSpan.Zero, msg))
|
||||
{
|
||||
// 返回 false 说明触发了 HWM 保护
|
||||
// 此时我们选择静默丢弃,或者仅在调试模式下打印日志
|
||||
// Console.WriteLine($"[Drop] 网络拥堵,丢弃帧: {payload.CameraId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 发送过程中的异常 (通常是 ObjectDisposedException 或 NetMQException)
|
||||
// 捕获它以防止单个帧的发送失败导致整个服务崩溃
|
||||
Console.WriteLine($"[ForwarderClient] 推送异常: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region --- 资源释放 ---
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_pushSocket?.Dispose();
|
||||
if (_isDisposed) return;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
_isDisposed = true;
|
||||
|
||||
try
|
||||
{
|
||||
_pushSocket?.Close();
|
||||
_pushSocket?.Dispose();
|
||||
}
|
||||
catch { /* 忽略关闭时的异常 */ }
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user