完善契约与客户端、服务端的收发代码

This commit is contained in:
2026-01-03 00:16:28 +08:00
parent d039559402
commit dcf424a86e
30 changed files with 3292 additions and 349 deletions

View File

@@ -37,6 +37,5 @@ namespace SHH.CameraDashboard
// 3. 彻底退出
Current.Shutdown();
}
}
}

View File

@@ -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>

View File

@@ -92,6 +92,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(); }
}
}
}

View 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>

View File

@@ -0,0 +1,15 @@
using System.Windows.Controls;
namespace SHH.CameraDashboard
{
/// <summary>
/// VideoTile.xaml 的交互逻辑
/// </summary>
public partial class VideoTile : UserControl
{
public VideoTile()
{
InitializeComponent();
}
}
}

View 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;
}
}
}

View 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>

View File

@@ -0,0 +1,15 @@
using System.Windows.Controls;
namespace SHH.CameraDashboard
{
/// <summary>
/// VideoWall.xaml 的交互逻辑
/// </summary>
public partial class VideoWall : UserControl
{
public VideoWall()
{
InitializeComponent();
}
}
}

View 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;
}
}
}
}

View 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;
}
}
}

View File

@@ -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>

View 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
}
}

View 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})";
}
}

View 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();
}
}
}