新增 Mjpegplayer 用来播放 Web 流

This commit is contained in:
2026-01-21 19:03:59 +08:00
parent f79cb6e74d
commit c438edfa0d
71 changed files with 4538 additions and 452 deletions

View File

@@ -1,48 +1,24 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.36623.8 d17.14
VisualStudioVersion = 17.14.36623.8
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.CameraSdk", "SHH.CameraSdk\SHH.CameraSdk.csproj", "{21B70A94-43FC-4D17-AB83-9E4B5178397E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.CameraService", "SHH.CameraService\SHH.CameraService.csproj", "{033B348B-4588-4C81-8D6C-D953E8E7967B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.Contracts", "SHH.Contracts\SHH.Contracts.csproj", "{E7A63644-7A55-4267-99D2-7D0A7D54B43C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.CameraDashboard", "SHH.CameraDashboard\SHH.CameraDashboard.csproj", "{03C249D7-BCF1-404D-AD09-7AB39BA263AD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.ProcessLaunchers", "SHH.ProcessLaunchers\SHH.ProcessLaunchers.csproj", "{E12F2D41-B7BB-4303-AD01-5DCD02D7FF3C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.Contracts.Grpc", "SHH.Contracts.Grpc\SHH.Contracts.Grpc.csproj", "{5CBDD688-1CD0-4E63-81C5-8E18750D891A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ayay.SerilogLogs", "Ayay.SerilogLogs\Ayay.SerilogLogs.csproj", "{0AC10F89-F938-4EA2-BC9F-63CB02BFB067}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.CameraSdk", "SHH.CameraSdk\SHH.CameraSdk.csproj", "{7B906CA8-28B6-B1E3-CA10-54749D96294B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.CameraService", "SHH.CameraService\SHH.CameraService.csproj", "{FC1A9F65-BBC2-7D8E-5D15-9ED7CAE0BAB3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.ProcessLaunchers", "SHH.ProcessLaunchers\SHH.ProcessLaunchers.csproj", "{20EB6234-44AD-B888-27B8-9DEDC1C1F0C7}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{21B70A94-43FC-4D17-AB83-9E4B5178397E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{21B70A94-43FC-4D17-AB83-9E4B5178397E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{21B70A94-43FC-4D17-AB83-9E4B5178397E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{21B70A94-43FC-4D17-AB83-9E4B5178397E}.Release|Any CPU.Build.0 = Release|Any CPU
{033B348B-4588-4C81-8D6C-D953E8E7967B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{033B348B-4588-4C81-8D6C-D953E8E7967B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{033B348B-4588-4C81-8D6C-D953E8E7967B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{033B348B-4588-4C81-8D6C-D953E8E7967B}.Release|Any CPU.Build.0 = Release|Any CPU
{E7A63644-7A55-4267-99D2-7D0A7D54B43C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E7A63644-7A55-4267-99D2-7D0A7D54B43C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E7A63644-7A55-4267-99D2-7D0A7D54B43C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E7A63644-7A55-4267-99D2-7D0A7D54B43C}.Release|Any CPU.Build.0 = Release|Any CPU
{03C249D7-BCF1-404D-AD09-7AB39BA263AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{03C249D7-BCF1-404D-AD09-7AB39BA263AD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{03C249D7-BCF1-404D-AD09-7AB39BA263AD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{03C249D7-BCF1-404D-AD09-7AB39BA263AD}.Release|Any CPU.Build.0 = Release|Any CPU
{E12F2D41-B7BB-4303-AD01-5DCD02D7FF3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E12F2D41-B7BB-4303-AD01-5DCD02D7FF3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E12F2D41-B7BB-4303-AD01-5DCD02D7FF3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E12F2D41-B7BB-4303-AD01-5DCD02D7FF3C}.Release|Any CPU.Build.0 = Release|Any CPU
{5CBDD688-1CD0-4E63-81C5-8E18750D891A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5CBDD688-1CD0-4E63-81C5-8E18750D891A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5CBDD688-1CD0-4E63-81C5-8E18750D891A}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -51,6 +27,18 @@ Global
{0AC10F89-F938-4EA2-BC9F-63CB02BFB067}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0AC10F89-F938-4EA2-BC9F-63CB02BFB067}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0AC10F89-F938-4EA2-BC9F-63CB02BFB067}.Release|Any CPU.Build.0 = Release|Any CPU
{7B906CA8-28B6-B1E3-CA10-54749D96294B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7B906CA8-28B6-B1E3-CA10-54749D96294B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7B906CA8-28B6-B1E3-CA10-54749D96294B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7B906CA8-28B6-B1E3-CA10-54749D96294B}.Release|Any CPU.Build.0 = Release|Any CPU
{FC1A9F65-BBC2-7D8E-5D15-9ED7CAE0BAB3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FC1A9F65-BBC2-7D8E-5D15-9ED7CAE0BAB3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FC1A9F65-BBC2-7D8E-5D15-9ED7CAE0BAB3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FC1A9F65-BBC2-7D8E-5D15-9ED7CAE0BAB3}.Release|Any CPU.Build.0 = Release|Any CPU
{20EB6234-44AD-B888-27B8-9DEDC1C1F0C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{20EB6234-44AD-B888-27B8-9DEDC1C1F0C7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{20EB6234-44AD-B888-27B8-9DEDC1C1F0C7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{20EB6234-44AD-B888-27B8-9DEDC1C1F0C7}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@@ -163,10 +163,9 @@ public class ServiceConfig
string ip = parts[0].Trim();
string portStr = parts[1].Trim();
string type = parts[2].Trim().ToLower();
// ★★★ 提取第四个字段作为备注 ★★★
string desc = parts.Length >= 4 ? parts[3].Trim() : "未命名终端";
string desc = parts.Length >= 4 ? parts[2].Trim() : "未命名终端";
if (int.TryParse(portStr, out int port))
{
@@ -180,16 +179,10 @@ public class ServiceConfig
};
// 添加前检查 Uri 是否重复 (备注不参与排重)
if (type == "video")
{
if (!config.VideoEndpoints.Any(e => e.Uri == zmqUri))
config.VideoEndpoints.Add(endpoint);
}
else if (type == "command" || type == "text")
{
if (!config.CommandEndpoints.Any(e => e.Uri == zmqUri))
config.CommandEndpoints.Add(endpoint);
}
if (!config.VideoEndpoints.Any(e => e.Uri == zmqUri))
config.VideoEndpoints.Add(endpoint);
if (!config.CommandEndpoints.Any(e => e.Uri == zmqUri))
config.CommandEndpoints.Add(endpoint);
}
}
}

View File

@@ -409,15 +409,15 @@ public class CameraManager : IDisposable, IAsyncDisposable
/// <para>参数2: IsOnline (true=在线, false=离线)</para>
/// <para>参数3: Reason (变更原因)</para>
/// </summary>
public event Action<long, bool, string>? OnDeviceStatusChanged;
public event Action<long, string, bool, string>? OnDeviceStatusChanged;
/// <summary>
/// [内部方法] 供 Sentinel 调用,触发事件冒泡
/// </summary>
internal void NotifyStatusChange(long deviceId, bool isOnline, string reason)
internal void NotifyStatusChange(long deviceId, string ipAddress, bool isOnline, string reason)
{
// 仅仅是触发 C# 事件,完全不知道网络发送的存在
OnDeviceStatusChanged?.Invoke(deviceId, isOnline, reason);
OnDeviceStatusChanged?.Invoke(deviceId, ipAddress, isOnline, reason);
}
#endregion

View File

@@ -152,7 +152,7 @@ public class ConnectivitySentinel
: $"持续断连超过{OFFLINE_DURATION_THRESHOLD}秒";
// ★★★ 核心动作:通知 Manager ★★★
_manager.NotifyStatusChange(device.Id, isLogicallyOnline, reason);
_manager.NotifyStatusChange(device.Id, device.IpAddress, isLogicallyOnline, reason);
}
}

View File

@@ -49,4 +49,25 @@ public static class DahuaPlaySDK
[DllImport(DLL_PATH)]
public static extern bool PLAY_SetStreamOpenMode(int nPort, uint nMode);
// 解码模式枚举
public enum DecodeType
{
DECODE_SW = 1, // 软解 (CPU)
DECODE_HW = 2, // 硬解拷贝模式 (GPU解码后拷贝回内存)
DECODE_HW_FAST = 3, // 硬解直接显示模式 (GPU解码直接渲染最高性能)
DECODE_HW_NV_CUDA = 7, // 英伟达显卡 CUDA 硬解 (Ayay 推荐,多路并发最强)
DECODE_HW_D3D11 = 8 // D3D11 硬解
}
// 渲染模式枚举
public enum RenderType
{
RENDER_GDI = 1,
RENDER_D3D9 = 4,
RENDER_D3D11 = 7
}
[DllImport(DLL_PATH, EntryPoint = "PLAY_SetEngine")]
public static extern bool PLAY_SetEngine(int nPort, DecodeType decodeType, RenderType renderType);
}

View File

@@ -3,6 +3,7 @@ using OpenCvSharp;
using Serilog;
using System.Runtime.ExceptionServices;
using System.Security;
using static SHH.CameraSdk.DahuaPlaySDK;
namespace SHH.CameraSdk;
@@ -173,8 +174,35 @@ public class DahuaVideoSource : BaseVideoSource
{
_playPort = port;
DahuaPlaySDK.PLAY_SetStreamOpenMode(_playPort, 0);
// 打开流
DahuaPlaySDK.PLAY_OpenStream(_playPort, IntPtr.Zero, 0, 1024 * 1024 * 2);
// =================================================================================
// 🚀 [新增代码] 性能优化:尝试开启大华 GPU 硬解码
// 位置:必须在 PLAY_OpenStream 之后PLAY_Play 之前
// =================================================================================
try
{
// nDecodeEngine: 1 = 开启硬解码 (Nvidia/Intel)
// 注意:大华 SDK 若不支持会自动降级try-catch 仅为了防止 P/Invoke 签名缺失崩溃
// Optimized: 使用新版接口开启硬件解码,优先尝试 CUDA 以保证 Ayay 的多路并发性能
// nPort 是通过 PLAY_GetFreePort 获取的播放通道号
bool success = PLAY_SetEngine(_playPort, DecodeType.DECODE_HW_NV_CUDA, RenderType.RENDER_D3D11);
if (!success)
{
// 如果显卡不支持 CUDA降级为普通硬解或软解
PLAY_SetEngine(_playPort, DecodeType.DECODE_HW, RenderType.RENDER_D3D9);
}
_sdkLog.Information($"[Perf] Dahua 尝试开启硬解码. ID:{_config.Id} Port:{_playPort}");
}
catch (Exception ex)
{
_sdkLog.Warning($"[Perf] Dahua 开启硬解码失败: {ex.Message}");
}
// 设置回调与播放
_decCallBack = new DahuaPlaySDK.DECCBFUN(SafeOnDecodingCallBack);
DahuaPlaySDK.PLAY_SetDecCallBack(_playPort, _decCallBack);
DahuaPlaySDK.PLAY_Play(_playPort, IntPtr.Zero);

View File

@@ -1,6 +1,4 @@
using System;
using System.Runtime.InteropServices;
namespace SHH.CameraSdk;
namespace SHH.CameraSdk;
/// <summary>
/// 海康播放库 PlayCtrl.dll 的封装
@@ -343,5 +341,11 @@ public static class HikPlayMethods
[DllImport(DllName)]
public static extern bool PlayM4_ResetSourceBuffer(int nPort);
/// <summary>
/// [新增] 开启硬件解码
/// </summary>
[DllImport(DllName)]
public static extern bool PlayM4_SetHardWareDecode(int nPort, int nMode);
#endregion
}

View File

@@ -54,8 +54,8 @@ public class HikVideoSource : BaseVideoSource,
private volatile int _connectionEpoch = 0; // 连接轮询版本号
// 回调委托强引用防止GC回收
private HikNativeMethods.REALDATACALLBACK? _realDataCallBack;
private HikPlayMethods.DECCBFUN? _decCallBack;
private readonly HikNativeMethods.REALDATACALLBACK _realDataCallBack;
private readonly HikPlayMethods.DECCBFUN _decCallBack;
// 图像处理资源, 内存复用对象
private Mat? _sharedYuvMat;
@@ -77,6 +77,11 @@ public class HikVideoSource : BaseVideoSource,
_timeProvider = new HikTimeSyncProvider(this);
_rebootProvider = new HikRebootProvider(this);
_ptzProvider = new HikPtzProvider(this);
// Modified: [Fix GC Crash] 移除此处的 new REALDATACALLBACK
// 直接使用构造函数初始化的 _realDataCallBack保证委托地址在整个对象生命周期内不变
_realDataCallBack = new HikNativeMethods.REALDATACALLBACK(SafeOnRealDataReceived);
_decCallBack = new HikPlayMethods.DECCBFUN(SafeOnDecodingCallBack);
}
#endregion
@@ -424,8 +429,10 @@ public class HikVideoSource : BaseVideoSource,
bBlocked = false
};
_realDataCallBack = new HikNativeMethods.REALDATACALLBACK(SafeOnRealDataReceived);
_realPlayHandle = HikNativeMethods.NET_DVR_RealPlay_V40(_userId, ref previewInfo, _realDataCallBack, IntPtr.Zero);
// Optimized: [Fix GC Crash] 显式保活,防止 JIT 在 P/Invoke 过程中激进回收(双重保险)
GC.KeepAlive(_realDataCallBack);
return _realPlayHandle >= 0;
}
@@ -480,8 +487,26 @@ public class HikVideoSource : BaseVideoSource,
return;
}
_decCallBack = new HikPlayMethods.DECCBFUN(SafeOnDecodingCallBack);
// =================================================================================
// 🚀 [新增代码] 尝试开启 GPU 硬解码 (1=开启, 0=关闭)
// 位置:必须在 OpenStream 成功之后SetDecCallBack 之前
// =================================================================================
try
{
HikPlayMethods.PlayM4_SetHardWareDecode(_playPort, 1);
_sdkLog.Information($"[Perf] Hik 尝试开启硬解码. ID:{_config.Id} Port:{_playPort}");
}
catch (Exception ex)
{
// 即使失败也不影响流程,仅记录警告
_sdkLog.Warning($"[Perf] Hik 开启硬解码失败: {ex.Message}");
}
HikPlayMethods.PlayM4_SetDecCallBackEx(_playPort, _decCallBack, IntPtr.Zero, 0);
// Optimized: [Fix GC Crash] 显式保活
GC.KeepAlive(_decCallBack);
HikPlayMethods.PlayM4_Play(_playPort, IntPtr.Zero);
_sdkLog.Debug($"[SDK] Hik 播放端口初始化成功, ID:{_config.Id} IP:{_config.IpAddress} Port:{_config.Port} Name:{_config.Name}, UserID: {_userId}, 播放端口:{_playPort}");

View File

@@ -24,7 +24,7 @@
<ItemGroup>
<ProjectReference Include="..\Ayay.SerilogLogs\Ayay.SerilogLogs.csproj" />
<ProjectReference Include="..\SHH.Contracts\SHH.Contracts.csproj" />
<ProjectReference Include="..\SHH.Contracts.Grpc\SHH.Contracts.Grpc.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -31,16 +31,14 @@ public static class Bootstrapper
"--appid", "CameraApp_01",
// 视频流地址 (格式: IP,Port,Type,Desc)
"--uris", "localhost,9001,video,调试PC;",
"--uris", "localhost,9002,video,调试PC-2;",
// 指令通道
"--uris", "localhost,9001,command,调试PC;",
"--uris", "localhost,9001,调试PC;",
"--uris", "localhost,9002,调试PC;",
// 日志中心配置 (格式: IP,Port,Desc)
"--sequris", "58.216.225.5,20026,日志处置中心;",
"--seqkey", "Shine899195994250;",
"--seqkey", "Shine978697953780;",
//"--seqkey", "Shine899195994250;",
// 端口策略
"--mode", "1",
"--ports", "5000,100"
@@ -245,31 +243,71 @@ public static class Bootstrapper
if (!config.CommandEndpoints.Any()) return;
var gRpcLog = Log.ForContext("SourceContext", LogModules.gRpc);
try
// // Optimized: 并发任务集合,实现多目标同时注册
var registrationTasks = config.CommandEndpoints.Select(async endpoint =>
{
// 将 tcp:// 转换为 http:// 以适配 gRpc
string targetUrl = config.CommandEndpoints.First().Uri.Replace("tcp://", "http://");
string targetUrl = endpoint.Uri.Replace("tcp://", "http://");
using var channel = GrpcChannel.ForAddress(targetUrl);
var client = new GatewayProvider.GatewayProviderClient(channel);
gRpcLog.Information($"[gRpc] 正在执行预注册: {targetUrl}");
var resp = await client.RegisterInstanceAsync(new RegisterRequest
// // Modified: 将 try-catch 移入内部,确保单个端点失败不影响其他端点
try
{
InstanceId = config.AppId,
Version = "2.0.0-grpc",
ServerIp = "127.0.0.1",
WebapiPort = config.BasePort, // 使用扫描后的新端口
StartTimeTicks = DateTime.Now.Ticks,
ProcessId = Environment.ProcessId,
Description = ""
});
gRpcLog.Information($"[gRpc] 💡预注册成功: {resp.Message}");
}
catch (Exception ex)
{
gRpcLog.Error($"[gRpc] ⚠️ 预注册尝试失败: {ex.Message}");
}
using var channel = GrpcChannel.ForAddress(targetUrl);
var client = new GatewayProvider.GatewayProviderClient(channel);
gRpcLog.Information($"[gRpc] 正在执行预注册: {targetUrl}");
var resp = await client.RegisterInstanceAsync(new RegisterRequest
{
InstanceId = config.AppId,
Version = "2.0.0-grpc",
ServerIp = "127.0.0.1",
WebapiPort = config.BasePort,
StartTimeTicks = DateTime.Now.Ticks,
ProcessId = Environment.ProcessId,
Description = endpoint.Description // 携带备注信息
});
gRpcLog.Information($"[gRpc] 💡预注册成功: {targetUrl} -> {resp.Message}");
}
catch (Exception ex)
{
// // Optimized: 记录具体哪个端点失败,但不阻断流程
gRpcLog.Error($"[gRpc] ⚠️ 预注册尝试失败 ({targetUrl}): {ex.Message}");
}
});
// 等待所有注册任务完成
await Task.WhenAll(registrationTasks);
//try
//{
// var cfgEndpoints = config.CommandEndpoints;
// for(var i=0; i<cfgEndpoints.Count; i++)
// {
// // 将 tcp:// 转换为 http:// 以适配 gRpc
// string targetUrl = cfgEndpoints[i].Uri.Replace("tcp://", "http://");
// using var channel = GrpcChannel.ForAddress(targetUrl);
// var client = new GatewayProvider.GatewayProviderClient(channel);
// gRpcLog.Information($"[gRpc] 正在执行预注册: {targetUrl}");
// var resp = await client.RegisterInstanceAsync(new RegisterRequest
// {
// InstanceId = config.AppId,
// Version = "2.0.0-grpc",
// ServerIp = "127.0.0.1",
// WebapiPort = config.BasePort, // 使用扫描后的新端口
// StartTimeTicks = DateTime.Now.Ticks,
// ProcessId = Environment.ProcessId,
// Description = ""
// });
// gRpcLog.Information($"[gRpc] 💡预注册成功: {resp.Message}");
// }
//}
//catch (Exception ex)
//{
// gRpcLog.Error($"[gRpc] ⚠️ 预注册尝试失败: {ex.Message}");
//}
}
#endregion

View File

@@ -18,7 +18,7 @@ public class DeviceConfigHandler : ICommandHandler
/// <summary>
/// 命令名称
/// </summary>
public string ActionName => ProtocolHeaders.Sync_Camera;
public string ActionName => ProtocolCodes.Sync_Camera;
/// <summary>
/// 构造函数

View File

@@ -24,6 +24,9 @@ public class DeviceStatusHandler : BackgroundService
// 状态存储CameraId -> 状态载荷
private readonly ConcurrentDictionary<string, StatusEventPayload> _stateStore = new();
// 记录上一次成功发送的状态快照,用于增量日志对比
private readonly Dictionary<string, bool> _lastPublishedStates = new();
private volatile bool _isDirty = false;
private long _lastSendTick = 0;
@@ -40,7 +43,7 @@ public class DeviceStatusHandler : BackgroundService
// 1. 初始化本地状态缓存
foreach (var dev in _manager.GetAllDevices())
{
UpdateLocalState(dev.Id, false, "Service Init");
UpdateLocalState(dev.Id, dev.Config.IpAddress, false, "Service Init");
}
// 2. 订阅 SDK 状态变更事件
@@ -71,17 +74,18 @@ public class DeviceStatusHandler : BackgroundService
/// <summary>
/// SDK 状态变更回调
/// </summary>
private void OnSdkStatusChanged(long deviceId, bool isOnline, string reason)
private void OnSdkStatusChanged(long deviceId, string ipAddress, bool isOnline, string reason)
{
UpdateLocalState(deviceId, isOnline, reason);
UpdateLocalState(deviceId, ipAddress, isOnline, reason);
_isDirty = true;
}
private void UpdateLocalState(long deviceId, bool isOnline, string reason)
private void UpdateLocalState(long deviceId, string ipAddress, bool isOnline, string reason)
{
var evt = new StatusEventPayload
{
CameraId = deviceId.ToString(),
IpAddress = ipAddress,
IsOnline = isOnline,
Reason = reason,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
@@ -133,8 +137,7 @@ public class DeviceStatusHandler : BackgroundService
// 这就是客户端尝试调用的真实路径:/包名.服务名/方法名
var serviceName = client.GetType().DeclaringType?.Name ?? "Unknown";
_gRpcLog.Debug("[gRpc] 准备调用端点: {Url}", grpcUrl);
_gRpcLog.Debug("[gRpc] 客户端契约服务名: {Service}", serviceName);
_gRpcLog.Debug("[gRpc] 准备调用端点: {Url}, 客户端契约服务名: {Service}", grpcUrl, serviceName);
// 执行调用
var response = await client.ReportStatusBatchAsync(request,
@@ -142,8 +145,69 @@ public class DeviceStatusHandler : BackgroundService
if (response.Success)
{
_gRpcLog.Information("[gRpc] 设备状态上报成功, 共计: {Count} 个, Url: {Url}", request.Items.Count, grpcUrl);
_gRpcLog.Debug("[gRpc] 设备状态上报成功: {Url} Items:{Items}", grpcUrl, request.Items);
// 1. 处理变更日志 (Information)
var diffList = new List<string>();
foreach (var item in request.Items)
{
// 只有状态翻转时才记录变更
if (!_lastPublishedStates.TryGetValue(item.CameraId, out bool lastStatus) || lastStatus != item.IsOnline)
{
// 从内存 Store 中抓取带有 IP 的原始对象
_stateStore.TryGetValue(item.CameraId, out var payload);
string ip = payload?.IpAddress ?? "Unknown IP";
string statusText = item.IsOnline ? "上线" : "离线";
diffList.Add($"[{item.CameraId}({ip})] {statusText}");
// // Modified: 记录当前状态供下次对比
_lastPublishedStates[item.CameraId] = item.IsOnline;
}
}
if (diffList.Any())
{
_gRpcLog.Information("[gRpc] 设备状态变更: {DiffDetails}, Url: {Url}",
string.Join(", ", diffList), grpcUrl);
}
// 2. 处理详细统计日志 (Debug)
// Optimized: 通过映射获取 IP不修改 StatusEventItem 契约
var onlineDetails = request.Items
.Where(x => x.IsOnline)
.Select(x => {
_stateStore.TryGetValue(x.CameraId, out var p);
return $"{x.CameraId}({p?.IpAddress ?? "N/A"})";
}).ToList();
var offlineDetails = request.Items
.Where(x => !x.IsOnline)
.Select(x => {
_stateStore.TryGetValue(x.CameraId, out var p);
return $"{x.CameraId}({p?.IpAddress ?? "N/A"})";
}).ToList();
var detailParts = new List<string>();
detailParts.Add($"其中在线 {onlineDetails.Count} 个");
detailParts.Add($"离线 {offlineDetails.Count} 个");
if (offlineDetails.Any())
{
detailParts.Add($"离线设备【{string.Join(",", offlineDetails)}】");
}
if (onlineDetails.Any())
{
detailParts.Add($"在线设备【{string.Join(",", onlineDetails)}】");
}
string detailMsg = string.Join("", detailParts);
// // Optimized: 最终输出格式化的详细日志
_gRpcLog.Debug("[gRpc] 设备状态上报详细: {Url} 总数:{Count} {Detail}",
grpcUrl,
request.Items.Count,
detailMsg);
_isDirty = false;
_lastSendTick = Environment.TickCount64;
}

View File

@@ -18,7 +18,7 @@ namespace SHH.CameraService
/// <summary>
/// 指令名称
/// </summary>
public string ActionName => ProtocolHeaders.Remove_Camera;
public string ActionName => ProtocolCodes.Remove_Camera;
/// <summary>
/// 构造函数

View File

@@ -107,6 +107,19 @@ public class Program
await manager.StartAsync();
// 2. Optimized: 主动拉起所有已加载设备的物理连接
// 理由:当本地配置了 video 推送目标时,不再等待远端 command 下发启动指令
var allDevices = manager.GetAllDevices();
foreach (var device in allDevices)
{
if (device.IsRunning && !device.IsActived)
{
logger.Information($"[Core] 🚀 自动激活设备流: ID:{device.Id} IP:{device.Config.IpAddress}");
// 使用 Fire-and-forget 启动,避免阻塞主线程
_ = device.StartAsync();
}
}
var sysLog = Log.ForContext("SourceContext", LogModules.Core);
sysLog.Information($"[Core] 🚀 核心业务逻辑已激活, 设备管理器已就绪.");
}

View File

@@ -6,6 +6,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PlatformTarget>x64</PlatformTarget>
<ApplicationIcon>notifyIcon.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
@@ -14,6 +15,10 @@
<None Remove="Dels\**" />
</ItemGroup>
<ItemGroup>
<Content Include="notifyIcon.ico" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Grpc.Net.Client" Version="2.76.0" />
<PackageReference Include="Grpc.Tools" Version="2.76.0">
@@ -26,7 +31,6 @@
<ProjectReference Include="..\Ayay.SerilogLogs\Ayay.SerilogLogs.csproj" />
<ProjectReference Include="..\SHH.CameraSdk\SHH.CameraSdk.csproj" />
<ProjectReference Include="..\SHH.Contracts.Grpc\SHH.Contracts.Grpc.csproj" />
<ProjectReference Include="..\SHH.Contracts\SHH.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.OpenApi.Models;
using SHH.CameraSdk;
@@ -93,12 +94,24 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IEnumerable<StreamTarget>>(netTargets);
services.AddHostedService<ImageMonitorController>();
// 动态注册 Sender Worker
//// 动态注册 Sender Worker
//foreach (var target in netTargets)
//{
// // 注意:这里需要使用 Microsoft.Extensions.Logging.ILogger 来适配构造函数
// services.AddHostedService(sp =>
// new GrpcSenderWorker(target));
//}
foreach (var target in netTargets)
{
// 注意:这里需要使用 Microsoft.Extensions.Logging.ILogger 来适配构造函数
services.AddHostedService(sp =>
new GrpcSenderWorker(target));
// Modified: 显式声明局部变量,防止 Lambda 捕获循环变量导致的引用重复
var currentTarget = target;
logger.Information("[DI] 准备启动 Worker 实例: {Name} -> {Url}",
currentTarget.Config.Name, currentTarget.Config.Endpoint);
// 使用工厂模式注册,确保传入的是当前的 currentTarget
services.AddSingleton<IHostedService>(sp => new GrpcSenderWorker(currentTarget));
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

@@ -1,5 +1,4 @@
using Newtonsoft.Json;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace SHH.Contracts
@@ -11,166 +10,96 @@ namespace SHH.Contracts
public class CameraConfigDto
{
// --- 基础身份 (Identity) ---
/// <summary>
/// 设备唯一标识
/// </summary>
/// <summary>设备唯一标识</summary>
[Required(ErrorMessage = "设备ID不能为空")]
[Range(1, long.MaxValue, ErrorMessage = "设备ID必须为正整数")]
public long Id { get; set; }
/// <summary>
/// 设备友好名称
/// </summary>
/// <summary>设备友好名称</summary>
[MaxLength(64, ErrorMessage = "设备名称长度不能超过64个字符")]
public string Name { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>
/// 摄像头品牌类型 (0:HikVision, 1:Dahua, 2:RTSP...)
/// </summary>
/// <summary>摄像头品牌类型 (0:HikVision, 1:Dahua, 2:RTSP...)</summary>
[Range(0, 10, ErrorMessage = "品牌类型值必须在0-10范围内")]
public int Brand { get; set; }
/// <summary>
/// 设备安装位置描述
/// </summary>
/// <summary>设备安装位置描述</summary>
[MaxLength(128, ErrorMessage = "安装位置长度不能超过128个字符")]
public string Location { get; set; }
public string Location { get; set; } = string.Empty;
// --- 主板关联信息 (Metadata) ---
/// <summary>
/// 关联主板IP地址
/// </summary>
/// <summary>关联主板IP地址</summary>
[RegularExpression(@"^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)?$",
ErrorMessage = "请输入合法的IPv4地址")]
public string MainboardIp { get; set; } = string.Empty;
/// <summary>
/// 关联主板端口
/// </summary>
/// <summary>关联主板端口</summary>
[Range(0, 65535, ErrorMessage = "主板端口号必须在1-65535范围内")]
public int MainboardPort { get; set; } = 0;
// --- 核心连接 (Connectivity) - 修改此类参数触发冷重启 ---
/// <summary>
/// 摄像头IP地址
/// </summary>
/// <summary>摄像头IP地址</summary>
[Required(ErrorMessage = "IP地址不能为空")]
[RegularExpression(@"^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$",
ErrorMessage = "请输入合法的IPv4地址")]
public string IpAddress { get; set; }
public string IpAddress { get; set; } = string.Empty;
/// <summary>
/// 登录用户名
/// </summary>
/// <summary>登录用户名</summary>
[MaxLength(32, ErrorMessage = "用户名长度不能超过32个字符")]
public string Username { get; set; }
public string Username { get; set; } = string.Empty;
/// <summary>
/// 登录密码
/// </summary>
/// <summary>登录密码</summary>
[MaxLength(64, ErrorMessage = "密码长度不能超过64个字符")]
public string Password { get; set; }
public string Password { get; set; } = string.Empty;
/// <summary>
/// SDK端口 (如海康默认8000)
/// </summary>
/// <summary>SDK端口 (如海康默认8000)</summary>
[Range(1, 65535, ErrorMessage = "端口号必须在1-65535范围内")]
public ushort Port { get; set; }
/// <summary>
/// 通道号 (通常为1)
/// </summary>
/// <summary>通道号 (通常为1)</summary>
[Range(0, 256, ErrorMessage = "通道号必须在0-256范围内")]
public int ChannelIndex { get; set; }
/// <summary>
/// 码流类型 (0:主码流, 1:子码流)
/// </summary>
/// <summary>码流类型 (0:主码流, 1:子码流)</summary>
[Range(0, 1, ErrorMessage = "码流类型只能是0(主码流)或1(子码流)")]
public int StreamType { get; set; }
// 渲染句柄 (通常下发时为0由本地窗口绑定时再指定或者此处仅作占位)
/// <summary>渲染句柄 (通常下发时为0由本地窗口绑定时再指定或者此处仅作占位)</summary>
public long RenderHandle { get; set; }
/// <summary>
/// RTSP流路径 (备用或非SDK模式使用)
/// </summary>
/// <summary>RTSP流路径 (备用或非SDK模式使用)</summary>
[MaxLength(256, ErrorMessage = "RTSP地址长度不能超过256个字符")]
public string RtspPath { get; set; }
public string RtspPath { get; set; } = string.Empty;
// --- 运行时参数 (Runtime Options) - 支持热更新 ---
/// <summary>
/// 是否使用灰度图 (用于AI分析场景加速)
/// </summary>
/// <summary>是否使用灰度图 (用于AI分析场景加速)</summary>
public bool UseGrayscale { get; set; } = false;
/// <summary>
/// 是否启用图像增强 (去噪/锐化等)
/// </summary>
/// <summary>是否启用图像增强 (去噪/锐化等)</summary>
public bool EnhanceImage { get; set; } = true;
// --- 画面变换 (Transform) - 支持热更新 ---
/// <summary>
/// 是否允许图像压缩 (降低带宽占用)
/// </summary>
/// <summary>是否允许图像压缩 (降低带宽占用)</summary>
public bool AllowCompress { get; set; } = true;
/// <summary>
/// 是否允许图像放大 (提升渲染质量)
/// </summary>
/// <summary>是否允许图像放大 (提升渲染质量)</summary>
public bool AllowExpand { get; set; } = false;
/// <summary>
/// 目标分辨率 (格式如 1920x1080空则保持原图)
/// </summary>
/// <summary>目标分辨率 (格式如 1920x1080空则保持原图)</summary>
[RegularExpression(@"^\d+x\d+$", ErrorMessage = "分辨率格式必须为 宽度x高度 (如 1920x1080)")]
public string TargetResolution { get; set; } = string.Empty;
/// <summary>
/// 随配置一并下发的自动订阅请求
/// </summary>
/// <summary>随配置一并下发的自动订阅请求</summary>
public List<CameraConfigSubscribeDto> AutoSubscriptions { get; set; }
= new List<CameraConfigSubscribeDto>();
/// <summary>
/// 是否立即执行
/// </summary>
/// <summary>是否立即执行</summary>
[JsonProperty("ImmediateExecution")] // 确保 JSON 里的这个 key 能精准对应到这个属性
public bool ImmediateExecution { get; set; }
}
/// <summary>
/// 订阅项
/// </summary>
public class CameraConfigSubscribeDto
{
/// <summary>
/// 订阅标识
/// </summary>
public string AppId { get; set; }
/// <summary>
/// 订阅业务类型 SubscriptionType
/// </summary>
public int Type { get; set; }
/// <summary>
/// 要求的帧率8帧或1帧
/// </summary>
public int TargetFps { get; set; }
/// <summary>
/// 备注
/// </summary>
public string Memo { get; set; }
/// <summary>
/// 是否需要高清晰度
/// </summary>
public bool NeedHighDefinition { get; set; }
= false;
}
}

View File

@@ -0,0 +1,22 @@
namespace SHH.Contracts
{
/// <summary>订阅项</summary>
public class CameraConfigSubscribeDto
{
/// <summary>订阅标识 例如: "UI_Display" (界面显示), "AI_Analysis" (算法分析)</summary>
public string AppId { get; set; } = string.Empty;
/// <summary>订阅业务类型 对应枚举 SubscriptionType 的整型值</summary>
public int Type { get; set; }
/// <summary>要求的传输帧率 要求的帧率8 帧或 1 帧</summary>
public int TargetFps { get; set; }
/// <summary>是否需要高清晰度流(主码流) true: 请求高分辨率主码流; false: 请求低分辨率子码流(默认)</summary>
public string Memo { get; set; } = string.Empty;
/// <summary>是否需要高清晰度</summary>
public bool NeedHighDefinition { get; set; }
= false;
}
}

View File

@@ -1,13 +1,9 @@
using MessagePack;
using System;
namespace SHH.Contracts
namespace SHH.Contracts
{
/// <summary>
/// 通用指令请求载体 (Request)
/// <para>用于 NetMQ 的 Request-Reply 或 Router-Dealer 模式</para>
/// </summary>
[MessagePackObject]
public class CommandPayload
{
#region --- 0. ---
@@ -16,7 +12,6 @@ namespace SHH.Contracts
/// 协议类型标识
/// <para>建议值: "COMMAND" 或 "指令包"</para>
/// </summary>
[Key(0)]
public string Protocol { get; set; } = "COMMAND";
#endregion
@@ -27,22 +22,19 @@ namespace SHH.Contracts
/// 指令代码 (路由键)
/// <para>示例: "PTZ", "RECORD_START", "SERVER_REGISTER"</para>
/// </summary>
[Key(1)]
public string CmdCode { get; set; }
public string CmdCode { get; set; } = string.Empty;
/// <summary>
/// 目标对象 ID
/// <para>示例: 摄像头ID "101",或者系统级指令填 "SYSTEM"</para>
/// </summary>
[Key(2)]
public string TargetId { get; set; }
public string TargetId { get; set; } = string.Empty;
/// <summary>
/// 业务参数 (JSON 字符串)
/// <para>根据 CmdCode 的不同,反序列化为不同的 DTO (如 PtzControlDto)</para>
/// </summary>
[Key(3)]
public string JsonParams { get; set; }
public string JsonParams { get; set; } = string.Empty;
#endregion
@@ -52,13 +44,11 @@ namespace SHH.Contracts
/// 请求追踪 ID (UUID)
/// <para>核心字段:用于实现异步等待 (await)。回执包必须携带此 ID。</para>
/// </summary>
[Key(4)]
public string RequestId { get; set; } = Guid.NewGuid().ToString("N");
/// <summary>
/// 发送时间戳
/// </summary>
[Key(5)]
public DateTime Timestamp { get; set; } = DateTime.Now;
#endregion
@@ -70,7 +60,6 @@ namespace SHH.Contracts
/// <para>true: 发送端会 await 等待结果 (默认)</para>
/// <para>false: 发后即忘 (Fire-and-Forget),服务端收到后不回发任何消息,减少带宽</para>
/// </summary>
[Key(6)]
public bool RequireAck { get; set; } = true;
/// <summary>
@@ -79,14 +68,12 @@ namespace SHH.Contracts
/// <para>1, 2...: 第N次重试</para>
/// <para>服务端据此判断是否需要查重 (幂等性处理)</para>
/// </summary>
[Key(7)]
public int RetryCount { get; set; } = 0;
/// <summary>
/// 消息过期时间 (Unix时间戳)
/// <para>如果接收端收到时已经超过此时间,直接丢弃,不处理也不回复</para>
/// </summary>
[Key(8)]
public long ExpireTime { get; set; }
#endregion

View File

@@ -1,6 +1,4 @@
using MessagePack;
using Newtonsoft.Json;
using System.Collections.Generic;
using Newtonsoft.Json;
// 注意:如果不想依赖 Newtonsoft也可以用 System.Text.Json但 Newtonsoft 在 Std 2.0 中兼容性更好
namespace SHH.Contracts
@@ -8,9 +6,11 @@ namespace SHH.Contracts
/// <summary>
/// 视频数据传输契约(纯净版 POCO
/// </summary>
[MessagePackObject]
public class VideoPayload
{
/// <summary>
/// 构造函数
/// </summary>
public VideoPayload()
{
SubscriberIds = new List<string>(16);
@@ -19,49 +19,40 @@ namespace SHH.Contracts
#region --- 1. (Metadata) ---
[Key(0)]
public string CameraId { get; set; }
public string CameraId { get; set; } = string.Empty;
/// <summary>
/// 采集时间戳 (Unix 毫秒)
/// </summary>
[Key(1)]
/// <summary>采集时间戳 (Unix 毫秒)</summary>
public long CaptureTimestamp { get; set; }
/// <summary>
/// 分发时间戳 (Unix 毫秒)
/// </summary>
[Key(2)]
/// <summary>分发时间戳 (Unix 毫秒)</summary>
public long DispatchTimestamp { get; set; }
[Key(3)]
/// <summary>原始图像宽度</summary>
public int OriginalWidth { get; set; }
[Key(4)]
/// <summary>原始图像高度</summary>
public int OriginalHeight { get; set; }
[Key(5)]
/// <summary>目标图像宽度</summary>
public int TargetWidth { get; set; }
[Key(6)]
/// <summary>目标图像高度</summary>
public int TargetHeight { get; set; }
[Key(7)]
/// <summary>订阅Ids</summary>
public List<string> SubscriberIds { get; set; }
[Key(8)]
/// <summary>诊断信息</summary>
public Dictionary<string, object> Diagnostics { get; set; }
/// <summary>
/// 指示标志:是否存在原始图
/// </summary>
[Key(9)]
public bool HasOriginalImage { get; set; }
/// <summary>
/// 指示标志:是否存在处理图
/// </summary>
[Key(10)]
public bool HasTargetImage { get; set; }
#endregion
@@ -70,12 +61,10 @@ namespace SHH.Contracts
// 标记 JsonIgnore防止被错误序列化
[JsonIgnore]
[IgnoreMember]
public byte[] OriginalImageBytes { get; set; }
public byte[]? OriginalImageBytes { get; set; }
[JsonIgnore]
[IgnoreMember]
public byte[] TargetImageBytes { get; set; }
public byte[]? TargetImageBytes { get; set; }
#endregion
@@ -87,13 +76,13 @@ namespace SHH.Contracts
public string GetMetadataJson()
{
// 在序列化前自动更新标志位,防止逻辑不同步
this.HasOriginalImage = (OriginalImageBytes != null && OriginalImageBytes.Length > 0);
this.HasTargetImage = (TargetImageBytes != null && TargetImageBytes.Length > 0);
HasOriginalImage = (OriginalImageBytes != null && OriginalImageBytes.Length > 0);
HasTargetImage = (TargetImageBytes != null && TargetImageBytes.Length > 0);
return JsonConvert.SerializeObject(this);
}
public static VideoPayload FromMetadataJson(string json)
public static VideoPayload? FromMetadataJson(string json)
{
return JsonConvert.DeserializeObject<VideoPayload>(json);
}

View File

@@ -0,0 +1,47 @@
namespace SHH.Contracts
{
/// <summary>
/// 协议代码定义常量类
/// <para>职责:统一管理 gRpc 通讯中所涉及的协议大类 (Protocol) 与具体业务指令码 (CmdCode)</para>
/// </summary>
public static class ProtocolCodes
{
#region --- 1. ( Protocol ) ---
/// <summary>
/// 基础指令协议头
/// <para>用于标记该消息是一个业务控制指令</para>
/// </summary>
public const string Command = "Command";
/// <summary>
/// 指令执行结果反馈协议头
/// <para>用于分析节点执行完指令后,向主控端回执操作结果</para>
/// </summary>
public const string Command_Result = "Command_Result";
#endregion
#region --- 2. ( CmdCode ) ---
/// <summary>
/// 服务器注册指令
/// <para>触发场景:节点启动时向主控端注册自身信息</para>
/// </summary>
public const string ServerRegister = "SERVER_REGISTER";
/// <summary>
/// 同步摄像头配置指令
/// <para>触发场景:节点上线全量同步、数据库摄像头信息变更增量同步</para>
/// </summary>
public static string Sync_Camera { get; } = "Sync_Camera";
/// <summary>
/// 移除摄像头指令
/// <para>触发场景:本地数据库删除摄像头后,通知远程节点停止相关流采集与分析</para>
/// </summary>
public static string Remove_Camera { get; } = "Remove_Camera";
#endregion
}
}

View File

@@ -1,44 +1,33 @@
using MessagePack;
using System;
namespace SHH.Contracts
namespace SHH.Contracts
{
/// <summary>
/// 服务端身份注册信息 (DTO)
/// <para>用于服务端主动连上客户端后,上报自身的端口和身份信息</para>
/// </summary>
[MessagePackObject]
public class RegisterPayload
{
#region --- 0. ---
/// <summary>
/// 协议类型标识 (人工可读)
/// </summary>
[Key(0)]
public string Protocol { get; set; } = ProtocolHeaders.ServerRegister;
/// <summary>协议类型标识 (人工可读)</summary>
public string Protocol { get; set; } = ProtocolCodes.ServerRegister;
#endregion
#region --- 1. ---
/// <summary>
/// 进程 ID (用于区分同一台机器上的多个实例)
/// </summary>
[Key(1)]
/// <summary>进程 ID (用于区分同一台机器上的多个实例)</summary>
public int ProcessId { get; set; }
/// <summary>调用进程 ID (用于区分同一台机器上的多个实例)</summary>
public int InvokeProcId { get; set; }
/// <summary>
/// 实例唯一标识符
/// <para>启动时通过命令行传入,例如 "Gateway_Factory_A"</para>
/// </summary>
[Key(2)]
public string InstanceId { get; set; }
public string InstanceId { get; set; } = string.Empty;
/// <summary>
/// 服务端版本号
/// </summary>
[Key(3)]
/// <summary>服务端版本号</summary>
public string Version { get; set; } = "1.0.0";
#endregion
@@ -49,43 +38,26 @@ namespace SHH.Contracts
/// 服务端所在的局域网 IP
/// <para>客户端无法直接连接此IP(因为可能是内网),但运维人员需要知道</para>
/// </summary>
[Key(4)]
public string ServerIp { get; set; }
public string ServerIp { get; set; } = string.Empty;
/// <summary>
/// WebAPI 监听端口 (HTTP)
/// <para>用于运维人员打开 Swagger 进行调试</para>
/// </summary>
[Key(5)]
public int WebApiPort { get; set; }
/// <summary>
/// 视频流端口 (ZeroMQ Publisher/Push)
/// </summary>
[Key(6)]
public int VideoPort { get; set; }
/// <summary>
/// 指令流端口 (ZeroMQ Response)
/// </summary>
[Key(7)]
public int CmdPort { get; set; }
/// <summary>Grpc通讯端口</summary>
public int GrpcPort { get; set; }
#endregion
#region --- 3. ---
/// <summary>
/// 启动时间
/// </summary>
[Key(8)]
/// <summary>启动时间</summary>
public DateTime StartTime { get; set; }
/// <summary>
/// 描述信息 (可选)
/// </summary>
[Key(9)]
public string Description { get; set; }
/// <summary>描述信息 (可选)</summary>
public string Description { get; set; } = string.Empty;
#endregion
}

View File

@@ -0,0 +1,23 @@
namespace SHH.Contracts
{
/// <summary>
/// [控制面] 设备状态变更通知包
/// </summary>
public class StatusEventPayload
{
/// <summary>摄像头ID</summary>
public string CameraId { get; set; } = string.Empty;
/// <summary>IP地址</summary>
public string IpAddress { get; set; } = string.Empty;
/// <summary>true: 上线/活跃, false: 离线/超时</summary>
public bool IsOnline { get; set; }
/// <summary>变更原因 (e.g. "Ping Success", "Frame Timeout")</summary>
public string Reason { get; set; } = string.Empty;
/// <summary>时间戳</summary>
public long Timestamp { get; set; }
}
}

View File

@@ -23,6 +23,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
</Project>

View File

@@ -1,86 +0,0 @@
using MessagePack;
namespace SHH.Contracts
{
/// <summary>
/// 通用指令执行结果 (Response)
/// </summary>
[MessagePackObject]
public class CommandResult
{
#region --- 0. ---
[Key(0)]
public string Protocol { get; set; } = "COMMAND_RESULT";
#endregion
#region --- ---
/// <summary>
/// 回执 ID (必须与请求包的 RequestId 一致)
/// <para>客户端靠这个 ID 来找到对应的 await Task</para>
/// </summary>
[Key(1)]
public string RequestId { get; set; }
#endregion
#region --- ---
/// <summary>
/// 执行是否成功
/// </summary>
[Key(2)]
public bool Success { get; set; }
/// <summary>
/// 结果消息 (成功提示或错误原因)
/// </summary>
[Key(3)]
public string Message { get; set; }
/// <summary>
/// 返回的数据 (JSON 或 Base64 字符串)
/// <para>示例: 截图的 Base64或者查询到的设备列表 JSON</para>
/// </summary>
[Key(4)]
public string Data { get; set; }
#endregion
#region --- ---
/// <summary>
/// 全链路耗时 (毫秒)
/// <para>从客户端发出指令,到收到服务端回执的总时长</para>
/// <para>注意:该字段由客户端收到回执后自动计算填充,服务端不需要赋值</para>
/// </summary>
[Key(5)]
public double ElapsedMilliseconds { get; set; }
#endregion
/// <summary>
/// 时间戳
/// </summary>
[Key(6)]
public long Timestamp { get; set;}
#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
}
}

View File

@@ -1,49 +0,0 @@
using MessagePack;
using System.Collections.Generic;
namespace SHH.Contracts
{
/// <summary>
/// [控制面] 状态全量快照包
/// </summary>
[MessagePackObject]
public class StatusBatchPayload
{
// [新增] 协议类型标识 (人工可读)
// 建议值: "STATUS_BATCH" 或 "设备状态全量包"
[Key(0)]
public string Protocol { get; set; } = "STATUS_BATCH";
[Key(1)]
public List<StatusEventPayload> Items { get; set; }
= new List<StatusEventPayload>();
[Key(2)]
public long Timestamp { get; set; }
}
/// <summary>
/// [控制面] 设备状态变更通知包
/// </summary>
[MessagePackObject]
public class StatusEventPayload
{
[Key(0)]
public string CameraId { get; set; }
/// <summary>
/// true: 上线/活跃, false: 离线/超时
/// </summary>
[Key(1)]
public bool IsOnline { get; set; }
/// <summary>
/// 变更原因 (e.g. "Ping Success", "Frame Timeout")
/// </summary>
[Key(2)]
public string Reason { get; set; }
[Key(3)]
public long Timestamp { get; set; }
}
}

View File

@@ -1,15 +0,0 @@
namespace SHH.Contracts
{
public static class ProtocolHeaders
{
// 核心协议头定义
public const string ServerRegister = "SERVER_REGISTER";
public const string StatusBatch = "STATUS_BATCH";
public const string Command = "COMMAND";
public const string CommandResult = "COMMAND_RESULT";
public const string Sync_Camera = "Sync_Camera";
public const string Remove_Camera = "Remove_Camera";
}
}

View File

@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,172 @@
using Ayay.SerilogLogs;
using CoreWCF;
using CoreWCF.Configuration;
using CoreWCF.Description;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Player.MJPEG;
using Serilog;
using System.Diagnostics;
using System.Net;
namespace SHH.MjpegPlayer
{
public static class Bootstrapper
{
private static readonly ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core);
#region LoadConfig
/// <summary>
/// 加载配置文件
/// </summary>
/// <returns></returns>
public static MjpegConfig LoadConfig()
{
// [修复] 路径处理脆弱性:使用 BaseDirectory 拼接,避免相对路径替换的风险
// 生产环境:强制使用绝对路径确保能找到配置文件
if (!Debugger.IsAttached)
{
JsonConfigUris.MjpegConfig = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Path.GetFileName(JsonConfigUris.MjpegConfig));
}
// 加载配置文件
var cfg = JsonConfig.Load<MjpegConfig>(JsonConfigUris.MjpegConfig);
if (cfg == null)
{
cfg = new MjpegConfig();
JsonConfig.Save(cfg, JsonConfigUris.MjpegConfig, "MjpegServer配置项");
_sysLog.Warning("未找到配置文件,已生成默认配置: {Path}", JsonConfigUris.MjpegConfig);
}
MjpegStatics.Cfg = cfg;
return cfg;
}
#endregion
#region ValidateEnvironment
/// <summary>
/// 检查 IP 与端口
/// </summary>
public static void ValidateEnvironment()
{
var cfg = MjpegStatics.Cfg;
// IP 地址检查
IPAddress? ipAddress;
if (!IPAddress.TryParse(cfg.SvrMjpegIp, out ipAddress))
{
ipAddress = IPAddress.Any;
_sysLog.Warning("配置的 IP 地址非法,将使用 IPAddress. Any: {Ip}.", cfg.SvrMjpegIp);
}
// 端口检查 => Wcf 接收图片接口
var portsToCheck = new List<int> { cfg.WcfPushImagePort, cfg.SvrMjpegPortBegin, cfg.SvrMjpegPortEnd };
foreach (var port in portsToCheck)
{
if (!port.IsServerPort())
{
_sysLog.Error("端口配置无效, Port: {Port}.", port);
ExitApp($"端口配置无效, Port: {port}");
}
}
// 端口检查 => Mjpeg 服务端口
if (!cfg.SvrMjpegPortBegin.IsServerPort())
{
_sysLog.Fatal("WCF 接收端口被占用, Port:{Port}.", cfg.WcfPushImagePort);
// 退出应用
ExitApp("端口占用.");
}
// [修复] 循环逻辑错误:将 < 改为 <=,确保最后一个端口也被检测
for (var i = cfg.SvrMjpegPortBegin; i <= cfg.SvrMjpegPortEnd; i++)
{
if (!i.PortOccupiedProc())
{
// 退出应用
_sysLog.Fatal("MJPEG 监听端口被占用, Port:{Port}", i);
ExitApp($"MJPEG 监听端口被占用, Port:{i}");
}
}
}
#endregion
#region StartWcfEngine
/// <summary>
/// 内部 WCF 引擎初始化 (CoreWCF)
/// </summary>
public static void StartWcfEngine(MjpegConfig cfg)
{
// Optimized: 内存监控提升
MemoryWatchdog.Start(300, 2048);
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls($"http://*:{cfg.WcfPushImagePort}");
// 托管日志
builder.Host.UseSerilog(_sysLog);
builder.Services.AddServiceModelServices();
builder.Services.AddServiceModelMetadata();
builder.Services.AddSingleton<IServiceBehavior, UseRequestHeadersForMetadataAddressBehavior>()
.AddSingleton<MjpegImagesService>();
var app = builder.Build();
var wsBinding = new WSHttpBinding(SecurityMode.None);
wsBinding.MaxReceivedMessageSize = cfg.SvrPushImageMaxRecMsgSize;
// Modified: [原因] 强制转换 IApplicationBuilder 修复 UseServiceModel 的二义性
((IApplicationBuilder)app).UseServiceModel(serviceBuilder =>
{
serviceBuilder.AddService<MjpegImagesService>(opt =>
{
opt.BaseAddresses.Add(new Uri($"http://0.0.0.0:{cfg.WcfPushImagePort}"));
})
.AddServiceEndpoint<MjpegImagesService, IMjpegImagesService>(
wsBinding,
$"/{cfg.SvrNamePushImage}",
new Uri($"http://0.0.0.0:{cfg.WcfPushImagePort}/{cfg.SvrNamePushImage}")
);
});
// 关闭元数据暴露增强安全性
var meta = app.Services.GetRequiredService<ServiceMetadataBehavior>();
meta.HttpGetEnabled = false;
meta.HttpsGetEnabled = false;
Task.Run(() => app.Run());
}
#endregion
#region ExitApp
/// <summary>
/// 应用程序退出
/// </summary>
/// <param name="exitMsg"></param>
/// <param name="waitSeconds"></param>
public static void ExitApp(string exitMsg, int waitSeconds = 5)
{
// [修复] 尝试停止所有 MjpegServer 监听
try { MjpegServer.StopAll(); } catch { }
var iSleep = waitSeconds * 2;
for (var i = 0; i < iSleep; i++)
{
Thread.Sleep(500);
}
// 退出程序
Environment.Exit(0);
}
#endregion
}
}

View File

@@ -0,0 +1,120 @@
using Ayay.SerilogLogs;
using Newtonsoft.Json;
using Serilog;
using System.Text;
namespace SHH.MjpegPlayer;
/// <summary>
/// 扩展 HttpClient 的 PostJson 方法,用于发送 JSON 格式的数据
/// </summary>
public static class NetHttpExtension
{
// Optimized: 统一日志对象
private static readonly ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core);
// Optimized: 使用静态单例 HttpClient 防止套接字耗尽。注意:生产环境建议配合 SocketsHttpHandler
private static readonly HttpClient _httpClient = new HttpClient();
#region (Sync-over-Async, 使)
/// <summary>
/// 发送 JSON 格式的 POST 请求 (同步)
/// </summary>
public static string PostJson(this object jsonData, string url, int timeout = 2000)
{
try
{
// Optimized: 显式调用异步版本并等待,注意在某些上下文可能死锁
return PostJsonAsync(jsonData, url, timeout).GetAwaiter().GetResult();
}
catch (Exception ex)
{
_sysLog.Error(ex, "Post 同步请求异常: {Url}", url);
return string.Empty;
}
}
/// <summary>
/// 发送 JSON 格式的 POST 请求并反序列化 (同步)
/// </summary>
public static T? PostJson<T>(this object jsonData, string url, int timeout = 2000)
{
try
{
var msg = PostJson(jsonData, url, timeout);
return string.IsNullOrWhiteSpace(msg) ? default : JsonConvert.DeserializeObject<T>(msg);
}
catch (Exception ex)
{
_sysLog.Error(ex, "Post 同步请求并解析 JSON 异常: {Url}", url);
return default;
}
}
#endregion
#region (使)
/// <summary>
/// 发送 JSON 格式的 POST 请求 (异步)
/// </summary>
/// <param name="jsonData">要发送的对象</param>
/// <param name="url">目标地址</param>
/// <param name="timeout">超时(ms)</param>
public static async Task<string> PostJsonAsync(this object jsonData, string url, int timeout = 2000)
{
string jsonString = string.Empty;
try
{
// Optimized: 序列化处理
jsonString = jsonData is string s ? s : JsonConvert.SerializeObject(jsonData);
using var content = new StringContent(jsonString, Encoding.UTF8, "application/json");
// Optimized: 设置请求级别的超时处理HttpClient.Timeout 是全局的,此处利用 CancellationTokenSource
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(timeout));
var response = await _httpClient.PostAsync(url, content, cts.Token);
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadAsStringAsync();
}
_sysLog.Warning("Post 请求状态异常: {Url}, StatusCode: {Code}", url, response.StatusCode);
return string.Empty;
}
catch (OperationCanceledException)
{
_sysLog.Warning("Post 请求超时: {Url}, Timeout: {Timeout}ms", url, timeout);
return string.Empty;
}
catch (Exception ex)
{
// Modified: 使用结构化日志记录错误
_sysLog.Error(ex, "Post 异步请求发生故障: {Url}", url);
return string.Empty;
}
}
/// <summary>
/// 发送 JSON 格式的 POST 请求并反序列化 (异步)
/// </summary>
public static async Task<T?> PostJsonAsync<T>(this object jsonData, string url, int timeout = 2000)
{
try
{
var result = await PostJsonAsync(jsonData, url, timeout);
if (string.IsNullOrWhiteSpace(result)) return default;
return JsonConvert.DeserializeObject<T>(result);
}
catch (Exception ex)
{
_sysLog.Error(ex, "Post 异步请求解析 JSON 失败: {Url}", url);
return default;
}
}
#endregion
}

View File

@@ -0,0 +1,190 @@
using Ayay.SerilogLogs;
using Serilog;
using System.Diagnostics;
using System.Net;
using System.Net.NetworkInformation;
using System.Text.RegularExpressions;
namespace SHH.MjpegPlayer
{
/// <summary>
/// 网口占用检测
/// </summary>
public static class NetPortExtension
{
private static ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core);
#region IsServerPort
/// <summary>
/// 是否端口
/// </summary>
/// <param name="value"></param>
/// <returns></returns>
public static bool IsServerPort(this int value)
{
if (value > 0 && value < 65535)
return true;
return false;
}
#endregion
#region IsPortOccupied
/// <summary>
/// 端口占用检测
/// </summary>
/// <param name="port"></param>
/// <returns></returns>
public static bool IsPortOccupied(this int port)
{
var ipProperties = IPGlobalProperties.GetIPGlobalProperties();
IPEndPoint[] activeListeners = ipProperties.GetActiveTcpListeners();
foreach (var endPoint in activeListeners)
{
if (endPoint.Port == port)
return true; // 端口被占用
}
return false; // 端口可用
}
#endregion
#region GetProcessIdByPort
/// <summary>
/// 查询端口占用进程 Pid
/// </summary>
/// <param name="port"></param>
/// <returns></returns>
public static int GetProcessIdByPort(this int port)
{
try
{
using (Process proc = new Process())
{
proc.StartInfo.FileName = "cmd.exe";
proc.StartInfo.Arguments = $"/c netstat -ano | findstr :{port}";
proc.StartInfo.UseShellExecute = false;
proc.StartInfo.RedirectStandardOutput = true;
proc.StartInfo.CreateNoWindow = true;
proc.Start();
string output = proc.StandardOutput.ReadToEnd();
proc.WaitForExit();
// 解析输出示例TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 1234
Match match = Regex.Match(output, @":\d+\s+.*?LISTENING\s+(\d+)");
if (match.Success && int.TryParse(match.Groups[1].Value, out int pid))
return pid;
}
return 0;
}
catch (Exception ex)
{
_sysLog.Warning("查询端口占用进程出错", ex.Message, ex.StackTrace);
return 0;
}
}
#endregion
#region GetProcessIdByPort
/// <summary>
/// 查询端口占用进程 Pid
/// </summary>
/// <param name="port"></param>
/// <returns></returns>
public static string GetProcessNameIdByPort(this int port)
{
try
{
using (Process proc = new Process())
{
proc.StartInfo.FileName = "cmd.exe";
proc.StartInfo.Arguments = $"/c netstat -ano | findstr :{port}";
proc.StartInfo.UseShellExecute = false;
proc.StartInfo.RedirectStandardOutput = true;
proc.StartInfo.CreateNoWindow = true;
proc.Start();
string output = proc.StandardOutput.ReadToEnd();
proc.WaitForExit();
// 解析输出示例TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 1234
Match match = Regex.Match(output, @":\d+\s+.*?LISTENING\s+(\d+)");
if (match.Success && int.TryParse(match.Groups[1].Value, out int pid))
{
using (Process process = Process.GetProcessById(pid))
return process.ProcessName;
}
}
return string.Empty;
}
catch (Exception ex)
{
_sysLog.Warning($"查询端口占用进程出错, 错误信息:{ex.Message} {ex.StackTrace}");
return string.Empty;
}
}
#endregion
#region PortOccupiedProc
/// <summary>
/// 端口占用检测并杀掉进程
/// </summary>
/// <param name="port"></param>
/// <returns>返回占用端口清理结果</returns>
public static bool PortOccupiedProc(this int port)
{
if (port.IsPortOccupied())
{
_sysLog.Warning("服务器端口被占用, Port: {port}");
// 等待 5 秒
for (var i = 0; i < 10; i++)
Thread.Sleep(500);
// 查找占用端口的进程
var pid = port.GetProcessIdByPort();
if (pid != 0)
{
// 获取进程名
string procName = pid.GetProcessName();
// 找到占用端口的进程
_sysLog.Warning($"找到占用端口进程 Pid: {pid} 进程名:{procName}, 5 秒后即将尝试杀掉占用端口的进程.");
// 等待 5 秒
for (var i = 0; i < 10; i++)
Thread.Sleep(500);
// 杀掉指定进程
if (!pid.KillProcessByPid(procName))
{
// 退出应用
return false;
}
// 等待 2 秒
Thread.Sleep(2000);
return true;
}
return false;
}
return true;
}
#endregion
}
}

View File

@@ -0,0 +1,197 @@
using Ayay.SerilogLogs;
using Serilog;
using System.Diagnostics;
namespace SHH.MjpegPlayer
{
/// <summary>
/// 进程扩展
/// </summary>
public static class ProcessExtension
{
private static ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core);
#region GetProcessName
/// <summary>
/// 获取进程名称
/// </summary>
/// <param name="pid"></param>
/// <returns></returns>
public static string GetProcessName(this int pid)
{
try
{
var process = Process.GetProcessById(pid);
return process.ProcessName;
}
catch (Exception ex)
{
_sysLog.Error(ex, "查询进程名出错, Pid: {Pid}", pid);
return string.Empty;
}
}
#endregion
#region KillProcessByPid
/// <summary>
/// 杀掉进程
/// </summary>
/// <param name="pid"></param>
/// <param name="procName"></param>
/// <returns></returns>
public static bool KillProcessByPid(this int pid, string procName = "")
{
try
{
var process = Process.GetProcessById(pid);
if (process != null)
{
procName = process.ProcessName;
process.Kill();
_sysLog.Warning("拒绝停止高权限系统进程: {Pid} - {Name}", pid, process.ProcessName);
return true;
}
else
{
// 找不到 ID 对应的进程,应该是进异常不会进这里
_sysLog.Information("成功杀掉进程 - Pid: {Pid}", pid);
return false;
}
}
catch (ArgumentException)
{
_sysLog.Warning("杀掉进程失败Pid: {Pid} 不存在", pid);
return false;
}
catch (Exception ex)
{
_sysLog.Error(ex, "杀掉进程异常, Pid: {Pid}", pid);
return false;
}
}
#endregion
#region KillProcessByName
/// <summary>
/// 杀掉进程
/// </summary>
/// <param name="pid"></param>
/// <param name="procName"></param>
/// <returns></returns>
public static int KillProcessByName(this string procName)
{
if (string.IsNullOrWhiteSpace(procName)) return 0;
int killCount = 0;
try
{
var processes = Process.GetProcessesByName(procName);
foreach (var proc in processes)
{
using (proc) // Optimized: 确保 Process 资源被释放
{
try
{
if (proc.IsHighPrivilegeProcess()) continue;
int currentId = proc.Id;
proc.Kill();
killCount++;
_sysLog.Information("成功通过名称杀掉进程 - Pid: {Pid}, Name: {Name}", currentId, procName);
}
catch (Exception ex)
{
_sysLog.Error(ex, "通过名称杀掉单个进程失败: {Name}", procName);
}
}
}
return killCount;
}
catch (Exception ex)
{
_sysLog.Error(ex, "通过名称杀掉进程列表异常: {Name}", procName);
return 0;
}
}
#endregion
#region StartProcess
/// <summary>
/// 开启进程
/// </summary>
/// <param name="procPath"></param>
public static bool StartProcess(this string procPath)
{
try
{
if (!File.Exists(procPath))
{
_sysLog.Error("启动进程失败,路径不存在: {Path}", procPath);
return false;
}
// Optimized: 显式记录启动行为
var process = Process.Start(procPath);
if (process != null)
{
_sysLog.Information("进程启动成功: {Path}, Pid: {Pid}", procPath, process.Id);
return true;
}
return false;
}
catch (Exception ex)
{
_sysLog.Error(ex, "启动进程异常: {Path}", procPath);
return false;
}
}
#endregion
#region IsHighPrivilegeProcess
/// <summary>
/// 检测是否高权限等级
/// </summary>
/// <param name="proc"></param>
/// <returns></returns>
public static bool IsHighPrivilegeProcess(this Process proc)
{
// 典型的高权限进程列表(可根据实际需求扩展)
string[] highPrivilegeProcesses = new[] {
"System", "smss.exe", "csrss.exe", "wininit.exe", "services.exe",
"lsass.exe", "winlogon.exe", "spoolsv.exe", "svchost.exe",
"csrss", "msedge"
};
// 检查进程名称是否在高权限列表中
foreach (string name in highPrivilegeProcesses)
{
if (proc.ProcessName.Equals(name, StringComparison.OrdinalIgnoreCase))
return true;
}
// 检查进程是否属于系统会话Session 0
try
{
return proc.SessionId == 0;
}
catch
{
// 如果无法获取 SessionId保守返回 true
return true;
}
}
#endregion
}
}

View File

@@ -0,0 +1,61 @@
namespace SHH.MjpegPlayer
{
/// <summary>图片通道</summary>
public class ImageChannel
{
/// <summary>进程 ID</summary>
public Int32 ProcId { get; set; }
/// <summary>设备 ID</summary>
public Int64 DeviceId { get; set; }
/// <summary>设备 IP</summary>
public string IpAddress { get; set; } = string.Empty;
/// <summary>名称</summary>
public string Name { get; set; } = string.Empty;
/// <summary>类型</summary>
public string Type { get; set; } = string.Empty;
/// <summary>图像宽度</summary>
public int ImageWidth { get; set; }
/// <summary>图像高度</summary>
public int ImageHeight { get; set; }
/// <summary>更新时间</summary>
public DateTime UpdateTime { get; set; }
/// <summary>是否正在播放</summary>
public bool IsPlaying { get; set; }
/// <summary>是否需要推流到 Rtmp 服务器</summary>
public bool UseRtmp { get; set; } = true;
#region RtmpUri
private string _rtmpUri = string.Empty;
/// <summary>Rtmp 推流地址</summary>
public string RtmpUri
{
get => _rtmpUri;
set
{
if (_rtmpUri == value)
return;
_rtmpUri = value;
}
}
#endregion
#region TestUri
/// <summary>测试地址</summary>
public string TestUri => $"?id={DeviceId}&typeCode={Type}";
#endregion
}
}

View File

@@ -0,0 +1,72 @@
using Core.WcfProtocol;
using System.Collections.Concurrent;
namespace SHH.MjpegPlayer
{
/// <summary>图片通道集合</summary>
public class ImageChannels
{
#region Channels
/// <summary>
/// 通道信息 (线程安全版本)
/// </summary>
// [修复] 使用 ConcurrentDictionary 替代 Dictionary防止多线程读写如推流和接收图片同时进行时崩溃
public ConcurrentDictionary<string, ImageChannel> Channels { get; set; }
= new ConcurrentDictionary<string, ImageChannel>();
#endregion
#region Do
/// <summary>
/// 处置图片
/// </summary>
/// <param name="req"></param>
/// <param name="key"></param>
public ImageChannel? Do(UploadImageRequest req, string key)
{
// [修复] 使用 GetOrAdd 原子操作,无需 lock彻底解决并发冲突
// 如果 key 不存在,则创建新通道;如果存在,则返回现有通道
var chn = Channels.GetOrAdd(key, k => new ImageChannel
{
DeviceId = req.Id,
Name = req.Name,
Type = req.Type,
});
// 更新指定信息 (直接属性赋值是原子性的,无需锁)
chn.IpAddress = req.IpAddress;
chn.ProcId = req.ProcId;
chn.ImageWidth = req.ImageWidth;
chn.ImageHeight = req.ImageHeight;
chn.UpdateTime = req.Time;
return chn;
}
#endregion
#region Get
/// <summary>
/// 获取通道信息
/// </summary>
/// <param name="deviceId"></param>
/// <param name="aiTypeCode"></param>
/// <returns></returns>
public ImageChannel? Get(string deviceId, string aiTypeCode)
{
string key = $"{deviceId}#{aiTypeCode}";
// [修复] ConcurrentDictionary 读取原本就是线程安全的
if (Channels.TryGetValue(key, out var val))
{
return val;
}
return null;
}
#endregion
}
}

View File

@@ -0,0 +1,94 @@
using Newtonsoft.Json;
namespace SHH.MjpegPlayer;
/// <summary>
/// Json 配置文件
/// </summary>
public class JsonConfig
{
#region Load
/// <summary>
/// 加载配置
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="path"></param>
/// <returns></returns>
public static T? Load<T>(string path)
{
try
{
var newPath = $"{Environment.CurrentDirectory}\\{path}";
path = newPath.Replace("Res\\Plugins\\", "");
var sr = new StreamReader(path);
var data = sr.ReadToEnd();
sr.Close();
sr = null;
data = data.Replace(@"""$schema"": ""https://gitee.com/dotnetchina/Furion/raw/v4/schemas/v4/furion-schema.json"",", "");
var obj = JsonConvert.DeserializeObject<T>(data);
//Logs.LogInformation<JsonConfig>(EIdFiles.LoadSucceed,
// $"配置{EIdFiles.LoadSucceed.GetDescription()}, Path:{path} 类型:{typeof(T).FullName}.");
return obj;
}
catch (Exception ex)
{
//Logs.LogWarning<JsonConfig>(EIdFiles.LoadFailed,
// $"配置{EIdFiles.LoadSucceed.GetDescription()}, Path:{path} 类型:{typeof(T).FullName}.", ex.Message, ex.StackTrace);
return default(T);
}
}
#endregion
#region Save
/// <summary>
/// 保存配置
/// </summary>
/// <param name="obj"></param>
/// <param name="path"></param>
/// <param name="caption"></param>
/// <returns></returns>
public static bool Save(object obj, string path, string caption)
{
try
{
var newPath = Path.GetFullPath(path);
if (File.Exists(newPath))
File.Delete(newPath);
var loc = newPath.LastIndexOf("\\");
if (loc > 0)
{
var newDir = newPath.Substring(0, loc);
Directory.CreateDirectory(newDir);
}
var msg = JsonConvert.SerializeObject(obj, Formatting.Indented);
msg = msg.Insert(1, "\"$schema\": \"https://gitee.com/dotnetchina/Furion/raw/v4/schemas/v4/furion-schema.json\",\r\n");
var sw = new StreamWriter(newPath);
sw.Write(msg);
sw.Flush();
sw.Close();
sw = null;
//Logs.LogInformation<JsonConfig>(EIdFiles.SaveSucceed,
// $"配置{EIdFiles.SaveSucceed.GetDescription()}, Path:{path}\r\n\t\t\tCaption:{caption} 类型:{obj.GetType().FullName}.");
return true;
}
catch (Exception ex)
{
//Logs.LogInformation<JsonConfig>(EIdFiles.SaveFailed,
// $"配置{EIdFiles.SaveFailed.GetDescription()}, Path:{path}\r\n\t\t\tCaption:{caption} 类型:{obj.GetType().FullName}.", ex.Message, ex.StackTrace);
return false;
}
}
#endregion
}

View File

@@ -0,0 +1,17 @@
namespace SHH.MjpegPlayer;
/// <summary>RTMP 配置响应类</summary>
public class CfgRtmpReply
{
/// <summary>响应消息</summary>
public string msg { get; set; } = string.Empty;
/// <summary>响应状态码</summary>
public int code { get; set; }
/// <summary>RTMP 推流地址列表</summary>
public RtmpVo[]? rtmpVoList { get; set; }
/// <summary>是否成功(状态码为 200 时返回 true</summary>
public bool IsSuccess => code == 200;
}

View File

@@ -0,0 +1,35 @@
using System.ComponentModel;
namespace SHH.MjpegPlayer
{
public enum EIdSys
{
/// <summary>根据PID杀掉进程成功</summary>
[Description("根据PID杀掉进程成功")]
KillProcByIdSucceed = 1000101,
/// <summary>按秒统计汇总</summary>
[Description("按秒统计汇总")]
TotalBySecond = 100701,
/// <summary>按分钟统计汇总</summary>
[Description("按分钟统计汇总")]
TotalByMinute = 100702,
/// <summary>按小时统计汇总</summary>
[Description("按小时统计汇总")]
TotalByHour = 100703,
/// <summary>查询进程名出错</summary>
[Description("查询进程名出错")]
SearchProcNameError = 1000901,
/// <summary>根据PID杀掉进程出错</summary>
[Description("根据PID杀掉进程出错")]
KillProcByIdError = 1000902,
/// <summary>启动进程出错</summary>
[Description("启动进程出错")]
StartProcessError = 1000903,
}
}

View File

@@ -0,0 +1,18 @@
namespace SHH.MjpegPlayer
{
public class JsonConfigUris
{
public static string DispatcherConfig;
public static string RtspRtcConfig;
public static string RtspRtcPortsConfig;
public static string MjpegConfig;
public static string CloudServerConfig;
public static string CloudServerSessionsConfig;
public static string CloudAgentConfig;
public static string CloudAITerminalConfig;
public static string VirtualCameraConfig;
public static string AIMainConfig;
public static string AIDbConfig;
public static string ToolLogConfig;
}
}

View File

@@ -0,0 +1,43 @@
namespace SHH.MjpegPlayer;
/// <summary>
/// Mjpeg 配置
/// </summary>
public class MjpegConfig
{
/// <summary>Mjpeg 服务 IP 地址</summary>
public string SvrMjpegIp
= "0.0.0.0";
/// <summary>Mjpeg 服务端口开始</summary>
public int SvrMjpegPortBegin
= 25031;
/// <summary>Mjpeg 服务端口结束</summary>
public int SvrMjpegPortEnd
= 25300;
/// <summary>帧间隔, 单位毫秒 (值为 125, 每秒 8 帧)</summary>
public int FrameInterval { get; set; }
= 125;
/// <summary>Mjpeg Wcf 接收图片接口</summary>
public int WcfPushImagePort
= 25030;
/// <summary>接收图片的服务器名称</summary>
public string SvrNamePushImage { get; set; }
= "ImageService.svc";
/// <summary>最大接收数据大小</summary>
public int SvrPushImageMaxRecMsgSize { get; set; }
= 2000 * 1024 * 1024;
/// <summary>Rtmp 服务地址</summary>
public string RtmpServerDjhUri { get; set; }
= "http://172.16.41.108:8889/intellect/nvr/getRtmp";
/// <summary>是否使用 Rtmp 服务</summary>
public bool UseRtmpServer { get; set; }
= false;
}

View File

@@ -0,0 +1,17 @@
namespace SHH.MjpegPlayer;
/// <summary>RTMP 推流对象类</summary>
public class RtmpVo
{
/// <summary>算法代码</summary>
public string algCode { get; set; } = string.Empty;
/// <summary>设备ID</summary>
public string deviceId { get; set; } = string.Empty;
/// <summary>设备IP地址</summary>
public string deviceIp { get; set; } = string.Empty;
/// <summary>RTMP 推流地址</summary>
public string rtmp { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,100 @@
namespace SHH.MjpegPlayer
{
/// <summary>
/// 会话信息
/// </summary>
public class SessionInfo
{
#region Key
/// <summary>流标识</summary>
public string? Key => $"{DeviceId}#{TypeCode}";
#endregion
#region DeviceId
/// <summary>设备类型</summary>
public string? DeviceId { get; set; }
#endregion
#region TypeCode
/// <summary>类型编码</summary>
public string? TypeCode { get; set; }
#endregion
#region ClientIp
/// <summary>客户端 IP</summary>
public string? ClientIp { get; set; }
#endregion
#region ClientPort
/// <summary>客户端端口</summary>
public int ClientPort { get; set; }
#endregion
#region Message
/// <summary>消息</summary>
public string? Message { get; set; }
#endregion
#region AcceptTime
/// <summary>接入时间</summary>
public DateTime AcceptTime { get; set; }
#endregion
#region Counter
/// <summary>计数器</summary>
public SumByTime? Counter { get; init; }
#endregion
// =======================================================
// [新增] 专门给诊断大屏用的属性,前端可直接读取数值
// =======================================================
/// <summary>接收帧率 (源头健康度)</summary>
public int RecvFps
{
get
{
if (Counter == null || Counter.TotalSecond == null) return 0;
// 从字典中安全获取 "接收帧数"
if (Counter.TotalSecond.TryGetValue("接收帧数", out uint val))
{
return (int)val;
}
return 0;
}
}
/// <summary>播放/发送帧率 (客户端健康度)</summary>
public int PlayFps
{
get
{
if (Counter == null || Counter.TotalSecond == null) return 0;
// 从字典中安全获取 "播放帧数"
if (Counter.TotalSecond.TryGetValue("播放帧数", out uint val))
{
return (int)val;
}
return 0;
}
}
}
}

View File

@@ -0,0 +1,190 @@
using System.Text;
namespace SHH.MjpegPlayer
{
/// <summary>
/// 按时间统计
/// </summary>
public class SumByTime
{
#region Defines
/// <summary>最近刷新在哪一秒</summary>
private int LastRefreshSecond = DateTime.Now.Second;
/// <summary>最近刷新在哪一分钟</summary>
private int LastRefreshMinute = DateTime.Now.Minute;
/// <summary>最近刷新在哪一小时</summary>
private int LastRefreshHour = DateTime.Now.Minute;
/// <summary>秒统计</summary>
private Dictionary<string, uint> _second
= new Dictionary<string, uint>();
/// <summary>分钟统计</summary>
private Dictionary<string, uint> _minute
= new Dictionary<string, uint>();
/// <summary>小时统计</summary>
private Dictionary<string, uint> _hour
= new Dictionary<string, uint>();
/// <summary>累计统计</summary>
public Dictionary<string, ulong> All { get; init; }
= new Dictionary<string, ulong>();
#endregion
#region TotalSecond
/// <summary>秒统计</summary>
public Dictionary<string, uint> TotalSecond { get; init; }
= new Dictionary<string, uint>();
#endregion
#region TotalMinute
/// <summary>分统计</summary>
public Dictionary<string, uint> TotalMinute { get; init; }
= new Dictionary<string, uint>();
#endregion
#region TotalHour
/// <summary>小时统计</summary>
public Dictionary<string, uint> TotalHour { get; init; }
= new Dictionary<string, uint>();
#endregion
#region Refresh
/// <summary>
/// 刷新方法调用次数
/// </summary>
/// <param name="logger"></param>
/// <param name="methodName"></param>
/// <param name="count"></param>
public void Refresh(string methodName, uint count = 1)
{
try
{
#region
// 加入集合
lock (_second)
{
if (!_second.ContainsKey(methodName))
_second.Add(methodName, 0);
}
// 加入集合
lock (_minute)
{
if (!_minute.ContainsKey(methodName))
_minute.Add(methodName, 0);
}
lock (_hour)
{
if (!_hour.ContainsKey(methodName))
_hour.Add(methodName, 0);
}
// 加入集合
lock (All)
{
if (!All.ContainsKey(methodName))
All.Add(methodName, 0);
}
#endregion
#region
// 秒刷新
if (!LastRefreshSecond.Equals(DateTime.Now.Second))
{
LastRefreshSecond = DateTime.Now.Second;
var sb = new StringBuilder();
foreach (var de in _second)
{
// 更新输出用统计信息
if (!TotalSecond.ContainsKey(de.Key))
TotalSecond.Add(de.Key, de.Value);
else
TotalSecond[de.Key] = de.Value;
sb.Append($"\r\n\t{de.Key} => 执行 {de.Value} 次");
_second[de.Key] = 0;
}
var logMsg = $"统计 => SumBySecond 统计时间:{DateTime.Now.ToString("yyyy-MM-dd HH:mm")}{sb.ToString()}";
//Logs.LogInformation<SumByTime>(EIdSys.TotalBySecond, logMsg);
}
// 分钟刷新
if (!LastRefreshMinute.Equals(DateTime.Now.Minute))
{
LastRefreshMinute = DateTime.Now.Minute;
var sb = new StringBuilder();
foreach (var de in _minute)
{
// 更新输出用统计信息
if (!TotalMinute.ContainsKey(de.Key))
TotalMinute.Add(de.Key, de.Value);
else
TotalMinute[de.Key] = de.Value;
sb.Append($"\r\n\t{de.Key} => 执行 {de.Value} 次, 平均每秒 {Math.Round((double)de.Value / 60, 2)} 次");
_minute[de.Key] = 0;
}
var logMsg = $"统计 => SumByMinute 统计时间:{DateTime.Now.ToString("yyyy-MM-dd HH:mm")}{sb.ToString()}";
//Logs.LogInformation<SumByTime>(EIdSys.TotalByMinute, logMsg);
}
// 小时刷新
if (!LastRefreshHour.Equals(DateTime.Now.Hour))
{
LastRefreshHour = DateTime.Now.Hour;
var sb = new StringBuilder();
foreach (var de in _hour)
{
// 更新输出用统计信息
if (!TotalHour.ContainsKey(de.Key))
TotalHour.Add(de.Key, de.Value);
else
TotalHour[de.Key] = de.Value;
sb.Append($"\r\n\t{de.Key} => 执行 {de.Value} 次, 平均每秒 {Math.Round((double)de.Value / 60, 2)} 次");
_hour[de.Key] = 0;
}
var logMsg = $"统计 => SumByHour 统计时间:{DateTime.Now.ToString("yyyy-MM-dd HH:mm")}{sb.ToString()}";
//Logs.LogInformation<SumByTime>(EIdSys.TotalByHour, logMsg);
}
#endregion
#region
_second[methodName] += count;
_minute[methodName] += count;
_hour[methodName] += count;
All[methodName] += count;
#endregion
}
catch (Exception ex)
{
//Logs.LogWarning<SumByTime>(ex.Message);
}
}
#endregion
}
}

View File

@@ -0,0 +1,68 @@
using Prism.Events;
namespace SHH.MjpegPlayer;
/// <summary>Prism 消息框架</summary>
public class PrismMsg<T>
{
#region Defines
public IEventAggregator _ea;
private static PrismMsg<T>? _instance = null;
#endregion
#region Constructor
/// <summary>构造函数</summary>
private PrismMsg()
{
_ea = new EventAggregator();
}
#endregion
#region Instance
/// <summary>获取实例信息</summary>
public static PrismMsg<T> Instance
{
get
{
if (_instance == null)
_instance = new PrismMsg<T>();
return _instance;
}
}
#endregion
#region Publish
/// <summary>发送消息</summary>
public static void Publish(T msg)
{
if (Instance == null)
return;
dynamic? data = msg;
Instance._ea.GetEvent<PubSubEvent<T>>().Publish(data);
}
#endregion
#region Subscribe
/// <summary>订阅消息</summary>
public static void Subscribe(Action<T> method)
{
if (Instance == null || Instance._ea == null)
return;
Instance._ea.GetEvent<PubSubEvent<T>>().Subscribe(method);
}
#endregion
}

View File

@@ -0,0 +1,101 @@
using Ayay.SerilogLogs;
using Serilog;
using System.Diagnostics;
using System.Timers;
namespace SHH.MjpegPlayer;
/// <summary>
/// 内存监控
/// </summary>
public static class MemoryWatchdog
{
private static readonly ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core);
private static System.Timers.Timer? _timer;
private static long _thresholdBytes;
private static ILogger _logger => Log.Logger;
/// <summary>
/// 启动内存监控
/// </summary>
/// <param name="intervalSeconds">检查间隔(秒)默认60秒</param>
/// <param name="limitMB">内存阈值(MB)超过此值自动退出默认800MB</param>
public static void Start(int intervalSeconds = 60, int limitMB = 800)
{
// 1. 参数安全检查
if (intervalSeconds < 1) intervalSeconds = 1; // 至少 1 秒
if (limitMB < 100) limitMB = 100; // 至少100MB防止误杀
// 2. 转换单位
// MB -> Bytes
_thresholdBytes = (long)limitMB * 1024 * 1024;
// 秒 -> 毫秒
double intervalMs = intervalSeconds * 1000.0;
// 3. 初始化定时器
Stop(); // 防止重复启动
_timer = new System.Timers.Timer(intervalMs);
_timer.Elapsed += CheckMemoryUsage;
_timer.AutoReset = true; // 循环执行
_timer.Start();
// 可选:记录启动日志
if (_logger != null)
{
_sysLog.Warning($"[系统] 内存看门狗已启动。每 {intervalSeconds} 秒检查一次,阈值: {limitMB} MB.");
}
}
private static void CheckMemoryUsage(object sender, ElapsedEventArgs e)
{
try
{
Process currentProc = Process.GetCurrentProcess();
// 【重要】刷新快照
currentProc.Refresh();
long currentUsage = currentProc.WorkingSet64;
if (currentUsage > _thresholdBytes)
{
double currentMB = currentUsage / 1024.0 / 1024.0;
double limitMB = _thresholdBytes / 1024.0 / 1024.0;
if (_logger != null)
{
_sysLog.Warning($"[严重] 内存占用 ({currentMB:F2} MB) 超过阈值 ({limitMB} MB),程序即将自杀重启或退出.");
}
// 等待日志输出
for (var i = 0; i < 10; i++)
{
System.Threading.Thread.Sleep(100);
}
// 强制退出
Environment.Exit(0);
}
}
catch (Exception ex)
{
if (_logger != null)
{
_sysLog.Warning($"[严重] 内存检查出错.");
}
}
}
public static void Stop()
{
if (_timer != null)
{
_timer.Stop();
_timer.Dispose();
_timer = null;
}
}
}

View File

@@ -0,0 +1,22 @@
using System.Collections.Concurrent;
namespace SHH.MjpegPlayer
{
/// <summary>
/// 辅助类:线程安全集合
/// </summary>
public class ConcurrentHashSet<T> : IEnumerable<T>
{
private readonly ConcurrentDictionary<T, byte> _dict = new ConcurrentDictionary<T, byte>();
public void Add(T item) => _dict.TryAdd(item, 0);
public void Remove(T item) => _dict.TryRemove(item, out _);
public bool IsEmpty => _dict.IsEmpty;
public IEnumerator<T> GetEnumerator() => _dict.Keys.GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
}

View File

@@ -0,0 +1,106 @@
using System.Collections.Concurrent;
using System.Diagnostics;
namespace SHH.MjpegPlayer
{
/// <summary>
/// 设备配置同步处理器 (原 ConfigSyncManager 瘦身版)
/// 职责仅负责确保远程分析节点Instance的摄像头配置与本地数据库一致。
/// 逻辑:通过 5 秒初始化冷却期避开抖动,并利用配置快照对比实现增量同步。
/// </summary>
public class DeviceConfigHandler
{
#region
/// <summary>
/// 获取配置处理器的全局单例实例
/// </summary>
public static DeviceConfigHandler Instance { get; } = new DeviceConfigHandler();
/// <summary>
/// 活跃服务实例 ID 集合 (InstanceId)
/// 用于记录当前所有已建立 gRpc 长连接的远程节点
/// </summary>
private readonly ConcurrentHashSet<string> _activeServiceIds = new ConcurrentHashSet<string>();
/// <summary>
/// 配置快照缓存:用于防止重复下发相同的配置
/// Key 格式: "InstanceId_CameraId"
/// Value: 该摄像头配置的 JSON 字符串快照
/// </summary>
private readonly ConcurrentDictionary<string, string> _lastSentConfigCache = new ConcurrentDictionary<string, string>();
/// <summary>
/// 后台监控任务的任务取消令牌源
/// </summary>
private CancellationTokenSource _cts;
/// <summary>
/// 初始化完成时间戳:用于 5 秒冷却期判定
/// 防止在服务刚启动或节点刚连接时,由于数据库加载延迟导致误判设备被移除
/// </summary>
private DateTime _initCompleteTime = DateTime.MaxValue;
#endregion
#region
/// <summary>
/// 私有构造函数:订阅消息总线并启动监控任务
/// </summary>
private DeviceConfigHandler()
{
// 订阅总线:仅关注节点注册事件,以此作为触发初始化全量同步的开关
MessageBus.Instance.OnServerRegistered += async (payload) =>
{
await HandleServiceOnlineAsync(payload.InstanceId);
};
// 启动后台轮询监控任务 (检测 Add/Update/Remove)
StartMonitorTask();
}
#endregion
#region (线)
/// <summary>
/// 处理新节点上线:执行全量同步
/// </summary>
/// <param name="instanceId">远程服务实例唯一标识</param>
private async Task HandleServiceOnlineAsync(string instanceId)
{
// 1. 将新实例记录到活跃列表
_activeServiceIds.Add(instanceId);
// 2. 预留 1 秒等待期,确保 gRpc 双向通道完全稳定
await Task.Delay(1000);
//// 3. 从数据库拍摄当前所有摄像头的快照
//var snapshot = CSdkStatics.DbCameras.ToList();
//// 4. 对新节点执行全量下发
//foreach (var cam in snapshot)
//{
// await SendSyncCommandAsync(instanceId, cam);
//}
// 5. 更新冷却期起始点
_initCompleteTime = DateTime.Now;
Debug.WriteLine($"[ConfigHandler] 节点 {instanceId} 初始化全量同步已完成。");
}
#endregion
#region ()
/// <summary>
/// 启动后台增量监控任务
/// </summary>
private void StartMonitorTask()
{
}
#endregion
}
}

View File

@@ -0,0 +1,48 @@
using SHH.Contracts;
namespace SHH.MjpegPlayer
{
/// <summary>
/// 设备状态处理器
/// 职责:监听消息总线发出的状态主题事件,负责将远程节点上报的相机在线/离线状态实时同步至本地管理中心。
/// 架构说明:此类实现了业务逻辑的彻底解耦,不涉及 gRpc 通讯细节,也不涉及复杂的配置下发逻辑。
/// </summary>
public class DeviceStatusHandler
{
#region
/// <summary>
/// 获取设备状态处理器的全局单例实例。
/// 由 GrpcServerManager 在系统启动时显式调用以完成初始化。
/// </summary>
public static DeviceStatusHandler Instance { get; } = new DeviceStatusHandler();
/// <summary>
/// 私有构造函数:在此处完成对消息总线事件的订阅。
/// </summary>
private DeviceStatusHandler()
{
// 订阅 MessageBus 的状态报告主题,当总线收到状态更新包时自动触发 SyncToLocal
MessageBus.Instance.OnDeviceStatusReport += SyncToLocal;
}
#endregion
#region
/// <summary>
/// 执行状态同步:将收到的 Payload 数据精确映射回本地 SDK 管理的摄像头集合中。
/// </summary>
/// <param name="items">包含 CameraId 和在线状态的业务载荷列表</param>
private void SyncToLocal(List<StatusEventPayload> items)
{
// 1. 基础校验:若无数据则不执行后续逻辑
if (items == null || items.Count == 0) return;
// 2. 性能优化:将上报列表转换为字典,利用哈希查找提升大数据量下的匹配效率 (Key: CameraId 字符串)
var stateMap = items.ToDictionary(k => k.CameraId, v => v);
}
#endregion
}
}

View File

@@ -0,0 +1,156 @@
using Grpc.Core;
using SHH.Contracts;
using SHH.Contracts.Grpc;
namespace SHH.MjpegPlayer
{
/// <summary>
/// gRpc 网关服务
/// 职责:作为服务端通讯入口,负责接收客户端(分析节点)的所有 gRpc 请求,将其转译为内部业务载荷,
/// 并通过消息总线 MessageBus 分发至对应的业务处理器。
/// </summary>
public class GatewayService : GatewayProvider.GatewayProviderBase
{
#region 1. (Unary )
/// <summary>
/// 处理分析节点的注册请求
/// </summary>
/// <param name="request">包含节点实例 ID 和服务器 IP 的请求对象</param>
/// <param name="context">gRpc 调用上下文</param>
/// <returns>操作成功响应</returns>
public override Task<GenericResponse> RegisterInstance(RegisterRequest request, ServerCallContext context)
{
// 1. 将 Protobuf 契约对象转换为业务层的 RegisterPayload (DTO)
// 职责:将外部传输格式映射为内部业务模型,实现协议与业务逻辑的解耦
var payload = new RegisterPayload
{
// 身份标识映射
ProcessId = request.ProcessId,
InvokeProcId = request.InvokeProcessId,
InstanceId = request.InstanceId,
Version = request.Version,
// 网络诊断信息映射
ServerIp = request.ServerIp,
WebApiPort = request.WebapiPort,
GrpcPort = request.GrpcPort,
// 运行时状态映射
// 注意:将 int64 类型的 Ticks 转换为 C# 的 DateTime 对象
StartTime = new DateTime(request.StartTimeTicks),
Description = request.Description
};
// 2. 将注册载荷抛给总线,触发如 DeviceConfigHandler 的配置初始化逻辑
// 职责:通过中介者模式分发事件,网关层不需要知道谁在处理这些数据
MessageBus.Instance.RaiseServerRegistered(payload);
return Task.FromResult(new GenericResponse { Success = true });
}
#endregion
#region 2. (Server Streaming)
/// <summary>
/// 建立并维持一个从服务器向客户端单向推送指令的长连接通道
/// </summary>
/// <param name="request">连接请求(包含 InstanceId</param>
/// <param name="responseStream">响应流,用于后续异步推送指令</param>
/// <param name="context">gRpc 调用上下文</param>
/// <returns>异步任务</returns>
public override async Task OpenCommandChannel(CommandStreamRequest request, IServerStreamWriter<CommandPayloadProto> responseStream, ServerCallContext context)
{
// 1. 物理流登记:将此响应流句柄存入 GrpcSessionManager以便 MessageBus 随时调用
GrpcSessionManager.Instance.RegisterSession(request.InstanceId, responseStream);
try
{
// 2. 挂起连接:利用 Task.Delay(-1) 配合取消令牌无限期挂起连接,直到客户端断开
await Task.Delay(-1, context.CancellationToken);
}
catch (OperationCanceledException)
{
// 客户端主动取消连接属于正常预期,无需抛出异常
}
finally
{
// 3. 物理流清理:当连接断开时,必须从会话管理器中移除,防止下发指令时产生死连接
GrpcSessionManager.Instance.RemoveSession(request.InstanceId);
}
}
#endregion
#region 3. (Unary )
/// <summary>
/// 接收来自分析节点的相机在线/离线状态批量上报
/// </summary>
/// <param name="request">包含多个设备状态项的请求对象</param>
/// <param name="context">gRpc 调用上下文</param>
/// <returns>操作成功响应</returns>
public override Task<GenericResponse> ReportStatusBatch(StatusBatchRequest request, ServerCallContext context)
{
if (request.Items == null || !request.Items.Any())
return Task.FromResult(new GenericResponse { Success = true });
// 1. 数据映射:将 Proto 集合转换为业务层的 StatusEventPayload 列表
var payloads = request.Items.Select(item => new StatusEventPayload
{
CameraId = item.CameraId,
IsOnline = item.IsOnline,
Reason = item.Reason,
Timestamp = request.Timestamp
}).ToList();
// 2. 路由分发:通过总线发布状态主题,驱动 DeviceStatusHandler 执行同步
MessageBus.Instance.RaiseDeviceStatusReport(payloads);
return Task.FromResult(new GenericResponse { Success = true });
}
#endregion
#region 4. (Client Streaming)
/// <summary>
/// 接收分析节点持续推送的视频帧数据流
/// </summary>
/// <param name="requestStream">客户端异步流读取器</param>
/// <param name="context">gRpc 调用上下文</param>
/// <returns>流关闭后的最终响应</returns>
public override async Task<GenericResponse> UploadVideoStream(IAsyncStreamReader<VideoFrameRequest> requestStream, ServerCallContext context)
{
// 1. 持续读取客户端推送的每一帧数据,直到流关闭或被取消
while (await requestStream.MoveNext(context.CancellationToken))
{
var frame = requestStream.Current;
// 2. 将 Protobuf 帧数据转换为业务视频载荷 VideoPayload
// 注意ByteString 需要显式调用 ToByteArray 转换
var videoPayload = new VideoPayload
{
CameraId = frame.CameraId,
CaptureTimestamp = frame.CaptureTimestamp,
OriginalWidth = frame.OriginalWidth,
OriginalHeight = frame.OriginalHeight,
OriginalImageBytes = frame.OriginalImageBytes.ToByteArray(),
TargetImageBytes = frame.TargetImageBytes.ToByteArray(),
TargetWidth = frame.TargetWidth,
TargetHeight = frame.TargetHeight,
SubscriberIds = frame.SubscriberIds.ToList(),
HasOriginalImage = true
};
// 3. 导流:将图像数据直接投递给图像分发控制器进行 UI 渲染或二次处理
ImageMonitorController.Instance.ReceivePayload(videoPayload);
}
return new GenericResponse { Success = true, Message = "Video stream ended" };
}
#endregion
}
}

View File

@@ -0,0 +1,108 @@
using Grpc.Core;
using SHH.Contracts.Grpc;
using System.Collections.Concurrent;
namespace SHH.MjpegPlayer
{
/// <summary>
/// gRpc 会话管理器
/// 职责:专门负责维护、检索和清理所有远程客户端(分析节点)的 gRpc 指令下发物理通道 (Stream)。
/// 它是连接“业务逻辑”与“物理传输”的桥梁,确保指令能准确投递到对应的连接流中。
/// </summary>
public class GrpcSessionManager
{
#region
/// <summary>
/// 获取会话管理器的全局单例实例。
/// </summary>
public static GrpcSessionManager Instance { get; } = new GrpcSessionManager();
/// <summary>
/// 私有构造函数,防止外部实例化。
/// </summary>
private GrpcSessionManager() { }
#endregion
#region
/// <summary>
/// 物理流存储字典
/// Key: 远程服务实例唯一 ID (InstanceId)
/// Value: gRpc 双向流或服务端推送流的写入器句柄 (IServerStreamWriter)
/// 使用 ConcurrentDictionary 确保在多客户端并发连接/断开时的线程安全性。
/// </summary>
private readonly ConcurrentDictionary<string, IServerStreamWriter<CommandPayloadProto>> _sessionStreams
= new ConcurrentDictionary<string, IServerStreamWriter<CommandPayloadProto>>();
#endregion
#region
/// <summary>
/// 注册/更新物理物理通道。
/// 当客户端调用 OpenCommandChannel 并成功建立 Server Streaming 连接时,由 GatewayService 调用此方法。
/// </summary>
/// <param name="instanceId">客户端实例唯一标识</param>
/// <param name="responseStream">该客户端对应的 gRpc 响应流句柄</param>
public void RegisterSession(string instanceId, IServerStreamWriter<CommandPayloadProto> responseStream)
{
// 1. 参数校验:无效 ID 不予处理
if (string.IsNullOrEmpty(instanceId)) return;
// 2. 登记或覆盖物理流:
// 如果客户端异常断开后迅速重连,此处会覆盖旧的流句柄,确保指令始终通过最新的管道下发。
_sessionStreams[instanceId] = responseStream;
// 3. 记录日志:便于运维监控连接状态
Console.WriteLine($"[Session] 物理通道就绪通知 -> 节点 ID: {instanceId}, 当前在线总数: {_sessionStreams.Count}");
}
/// <summary>
/// 移除物理通道。
/// 当 gRpc 连接由于网络波动、客户端崩溃或主动关闭而断开时,由 GatewayService 的 finally 块调用。
/// </summary>
/// <param name="instanceId">要注销的客户端实例 ID</param>
public void RemoveSession(string instanceId)
{
// 1. 参数校验
if (string.IsNullOrEmpty(instanceId)) return;
// 2. 安全移除:若 ID 存在则移除并释放相关内部引用
if (_sessionStreams.TryRemove(instanceId, out _))
{
Console.WriteLine($"[Session] 物理通道移除通知 -> 节点 ID: {instanceId}, 剩余在线总数: {_sessionStreams.Count}");
}
}
/// <summary>
/// 检索目标节点的物理流句柄。
/// 供 MessageBus 使用,它是指令下发前定位物理路径的关键步骤。
/// </summary>
/// <param name="instanceId">目标节点的唯一 ID</param>
/// <returns>返回对应的 IServerStreamWriter 实例;若节点不在线则返回 null</returns>
public IServerStreamWriter<CommandPayloadProto> GetSession(string instanceId)
{
// 1. 参数校验
if (string.IsNullOrEmpty(instanceId)) return null;
// 2. 尝试从缓存字典中获取流句柄
_sessionStreams.TryGetValue(instanceId, out var stream);
return stream;
}
/// <summary>
/// 检查指定节点是否处于物理连接状态。
/// </summary>
/// <param name="instanceId">实例 ID</param>
/// <returns>True 表示物理通道已建立</returns>
public bool IsSessionActive(string instanceId)
{
return !string.IsNullOrEmpty(instanceId) && _sessionStreams.ContainsKey(instanceId);
}
#endregion
}
}

View File

@@ -0,0 +1,137 @@
using SHH.Contracts;
using SHH.Contracts.Grpc;
using System.Diagnostics;
namespace SHH.MjpegPlayer
{
/// <summary>
/// 消息总线中心 (纯 gRpc 架构)
/// 职责:解耦 gRpc 接收端与业务处理层,提供基于主题(Topic)的事件发布与统一的指令下发路由。
/// </summary>
public class MessageBus : IDisposable
{
#region
/// <summary>
/// 消息总线全局唯一实例
/// </summary>
public static MessageBus Instance { get; } = new MessageBus();
/// <summary>
/// 私有构造函数
/// </summary>
private MessageBus() { }
#endregion
#region (Topics)
/// <summary>
/// 1. 注册主题:当远程分析节点成功建立逻辑连接时触发。
/// 订阅者通常为 DeviceConfigHandler用于启动初始化配置同步。
/// </summary>
public event Action<RegisterPayload>? OnServerRegistered;
/// <summary>
/// 2. 状态主题:当收到远程节点批量上报的设备在线/离线状态时触发。
/// 订阅者通常为 DeviceStatusHandler用于更新 UI 状态。
/// </summary>
public event Action<List<StatusEventPayload>>? OnDeviceStatusReport;
#endregion
#region ( GatewayService )
/// <summary>
/// 发布节点注册事件:将 gRpc 接收到的原始注册请求推送到业务层
/// </summary>
/// <param name="p">注册载荷信息</param>
public void RaiseServerRegistered(RegisterPayload p)
{
if (p == null) return;
// 调试日志:跟踪节点上线流程
Debug.WriteLine($"[Bus] 发布注册事件: 节点ID = {p.InstanceId}");
// 执行所有已订阅该主题的业务逻辑
OnServerRegistered?.Invoke(p);
}
/// <summary>
/// 发布状态报告事件:将 gRpc 接收到的设备状态批量推送到业务层
/// </summary>
/// <param name="items">设备状态变更列表</param>
public void RaiseDeviceStatusReport(List<StatusEventPayload> items)
{
if (items == null || items.Count == 0) return;
// 执行所有已订阅状态同步的业务逻辑
OnDeviceStatusReport?.Invoke(items);
}
#endregion
#region ( Handler )
/// <summary>
/// 统一指令下发路由:自动定位目标节点的物理 gRpc 流并推送指令载荷
/// </summary>
/// <param name="instanceId">目标分析节点的唯一识别码</param>
/// <param name="payload">要发送的业务指令负载</param>
/// <returns>异步任务</returns>
public async Task SendInternalAsync(string instanceId, CommandPayload payload)
{
// 1. 获取由 GrpcSessionManager 维护的物理长连接流
var stream = GrpcSessionManager.Instance.GetSession(instanceId);
// 2. 健壮性检查:若连接不存在则终止下发
if (stream == null)
{
Debug.WriteLine($"[Bus Warning] 指令下发终止:节点 {instanceId} 尚未建立物理连接。");
return;
}
try
{
// 3. 契约转换:将业务层 CommandPayload 转换为 gRpc 生成的 Protobuf 契约对象
var protoMsg = new CommandPayloadProto
{
Protocol = payload.Protocol,
CmdCode = payload.CmdCode,
JsonParams = payload.JsonParams,
RequestId = payload.RequestId,
TimestampTicks = payload.Timestamp.Ticks
};
// 4. 执行异步推送
await stream.WriteAsync(protoMsg);
Debug.WriteLine($"[Bus] 指令推送成功 -> 目标: {instanceId}, 指令码: {payload.CmdCode}");
}
catch (Exception ex)
{
// 5. 异常处理:若推送失败,通常意味着网络链路已断开
Debug.WriteLine($"[Bus Error] 推送异常: {ex.Message},正在执行物理连接清理...");
// 立即移除失效会话,防止后续指令继续掉入“黑洞”
GrpcSessionManager.Instance.RemoveSession(instanceId);
}
}
#endregion
#region
/// <summary>
/// 释放总线资源
/// </summary>
public void Dispose()
{
// 清理所有事件订阅,防止内存泄漏
OnServerRegistered = null;
OnDeviceStatusReport = null;
}
#endregion
}
}

View File

@@ -0,0 +1,37 @@
using SHH.Contracts;
using System.Diagnostics;
namespace SHH.MjpegPlayer
{
/// <summary>
/// AI 视频流监控控制器
/// 职责:接收 gRpc 转换后的 Payload -> 业务转换 -> 分发 UI/AI
/// </summary>
public class ImageMonitorController
{
public static ImageMonitorController Instance { get; } = new ImageMonitorController();
private ImageMonitorController() { }
/// <summary>
/// 统一接收入口:由 GatewayProviderImpl.UploadVideoStream 调用
/// </summary>
public void ReceivePayload(VideoPayload payload)
{
if (payload == null) return;
// 1. 过滤 2 秒外的过期数据
if ((DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - payload.CaptureTimestamp) > 2000)
return;
try
{
ImagePayloadConverter.ToXWcfMsg(payload);
}
catch (Exception ex)
{
Debug.WriteLine($"[Controller Error] {ex.Message}");
}
}
}
}

View File

@@ -0,0 +1,78 @@
using Core.WcfProtocol;
using SHH.Contracts;
namespace SHH.MjpegPlayer
{
/// <summary>
/// 图像载荷转换器 (原 PayloadConverter)
/// 职责:抹平传输契约与业务契约之间的差异。
/// </summary>
public static class ImagePayloadConverter
{
/// <summary>
/// 将视频负载转换为 XWcf 协议并分发至会话池
/// </summary>
/// <param name="payload">VideoPayload 纯净版契约对象</param>
public static void ToXWcfMsg(VideoPayload payload)
{
if (payload == null) return;
try
{
// 1. 自动选择图像源逻辑
// Optimized: 优先使用 TargetImage若为空则退而求其次使用 OriginalImage
bool isOriginal = false;
byte[]? activeBytes;
activeBytes = payload.TargetImageBytes;
if (payload.TargetImageBytes == null || payload.TargetImageBytes.Length == 0)
{
isOriginal = true;
activeBytes = payload.OriginalImageBytes;
}
// 如果两者都为空,则不进行分发
if (activeBytes == null || activeBytes.Length == 0) return;
// 同理处理宽高Target 为 0 则使用 Original
int activeWidth = !isOriginal ? payload.TargetWidth : payload.OriginalWidth;
int activeHeight = !isOriginal ? payload.TargetHeight : payload.OriginalHeight;
// 2. 构造分发所需的 UploadImageRequest
// Modified: [原因] 适配最新的 VideoPayload 契约字段
var req = new UploadImageRequest
{
// 解析 CameraId。由于旧 req 是 Int64 Id若 CameraId 是数字字符串则解析,否则处理 Hash
Id = long.TryParse(payload.CameraId, out long id) ? id : 0,
Name = payload.CameraId, // 将原始 CameraId 存入 Name 字段保留引用
// 默认类型处理 (可根据 Diagnostics 中的信息动态调整)
Type = "0",
Order = (ulong)payload.CaptureTimestamp, // 使用采集时间戳作为序号
Time = UnixMillisecondsToDateTime(payload.CaptureTimestamp),
ImageBytes = activeBytes, // 零拷贝引用
ImageWidth = activeWidth,
ImageHeight = activeHeight
};
// 3. 执行核心分发逻辑
// 此处调用你之前提供的 O(1) 检索分发方法,确保画面最终流向 DoImageProc
MjpegStatics.Sessions.ProcUploadImageRequest(req);
}
catch (Exception ex)
{
// 统一使用项目规范的 _sysLog
//_sysLog.Error(ex, "VideoPayload 转换分发失败. CameraId: {CameraId}", payload.CameraId);
}
}
/// <summary>
/// 辅助方法Unix 毫秒时间戳转 DateTime
/// </summary>
private static DateTime UnixMillisecondsToDateTime(long timestamp)
{
return DateTimeOffset.FromUnixTimeMilliseconds(timestamp).LocalDateTime;
}
}
}

View File

@@ -0,0 +1,62 @@
using Grpc.Core;
using SHH.Contracts.Grpc;
namespace SHH.MjpegPlayer
{
/// <summary>
/// gRpc 服务宿主管理器
/// 职责:初始化业务处理器、配置并启动 gRpc 监听服务。
/// </summary>
public static class GrpcServerManager
{
private static Server? _server;
/// <summary>
/// 启动 gRpc 服务并初始化业务 Handler
/// </summary>
public static void Start()
{
try
{
// 1. 显式初始化业务处理器 (确保单例构造函数执行,完成事件订阅)
// 必须在服务启动前完成,否则可能丢失首批注册事件
_ = DeviceConfigHandler.Instance;
_ = DeviceStatusHandler.Instance;
Console.WriteLine("[System] 业务处理器 (Config/Status) 已初始化。");
// 2. 配置 gRpc 服务器
_server = new Server
{
// 绑定重构后的 GatewayService
Services = { GatewayProvider.BindService(new GatewayService()) },
// 监听 9002 端口
Ports = { new ServerPort("[::]", 9002, ServerCredentials.Insecure) }
};
// 3. 开启服务
_server.Start();
Console.WriteLine("======================================");
Console.WriteLine("gRpc 服务端启动成功!端口: 9002");
Console.WriteLine("======================================");
}
catch (Exception ex)
{
Console.WriteLine($"[Critical] gRpc 启动失败: {ex.Message}");
// 此处建议记录到本地错误日志文件
}
}
/// <summary>
/// 停止服务并释放资源
/// </summary>
public static void Stop()
{
if (_server != null)
{
_server.ShutdownAsync().Wait();
Console.WriteLine("[System] gRpc 服务已停止。");
}
}
}
}

View File

@@ -0,0 +1,37 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.36623.8
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.Contracts.Grpc", "..\SHH.Contracts.Grpc\SHH.Contracts.Grpc.csproj", "{5CBDD688-1CD0-4E63-81C5-8E18750D891A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ayay.SerilogLogs", "..\Ayay.SerilogLogs\Ayay.SerilogLogs.csproj", "{0AC10F89-F938-4EA2-BC9F-63CB02BFB067}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHH.MjpegPlayer", "SHH.MjpegPlayer.csproj", "{13828F44-AC67-4DFE-A3BC-3F1CD153A59A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{5CBDD688-1CD0-4E63-81C5-8E18750D891A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5CBDD688-1CD0-4E63-81C5-8E18750D891A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5CBDD688-1CD0-4E63-81C5-8E18750D891A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5CBDD688-1CD0-4E63-81C5-8E18750D891A}.Release|Any CPU.Build.0 = Release|Any CPU
{0AC10F89-F938-4EA2-BC9F-63CB02BFB067}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0AC10F89-F938-4EA2-BC9F-63CB02BFB067}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0AC10F89-F938-4EA2-BC9F-63CB02BFB067}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0AC10F89-F938-4EA2-BC9F-63CB02BFB067}.Release|Any CPU.Build.0 = Release|Any CPU
{13828F44-AC67-4DFE-A3BC-3F1CD153A59A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{13828F44-AC67-4DFE-A3BC-3F1CD153A59A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{13828F44-AC67-4DFE-A3BC-3F1CD153A59A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{13828F44-AC67-4DFE-A3BC-3F1CD153A59A}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {64321063-16F8-41E4-9595-E85C32FE4FDC}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,26 @@
namespace SHH.MjpegPlayer
{
/// <summary>
/// 静态参数集合
/// </summary>
public class MjpegStatics
{
/// <summary>
/// 配置项
/// </summary>
public static MjpegConfig Cfg { get; set; }
= new MjpegConfig();
/// <summary>
/// 会话集合
/// </summary>
public static MjpegSessions Sessions { get; private set; }
= new MjpegSessions();
/// <summary>
/// 图片通道集合
/// </summary>
public static ImageChannels ImageChannels { get; private set; }
= new ImageChannels();
}
}

107
SHH.MjpegPlayer/Program.cs Normal file
View File

@@ -0,0 +1,107 @@
using Ayay.SerilogLogs;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
namespace SHH.MjpegPlayer
{
internal class Program
{
private static ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core);
static void Main(string[] args)
{
_sysLog.Information("MjpegPlayer 正在初始化...");
var builder = WebApplication.CreateBuilder(args);
// 1. 注册 gRpc 服务
builder.Services.AddGrpc(options => {
options.MaxReceiveMessageSize = 10 * 1024 * 1024; // 针对工业视频流,建议放宽至 10MB
});
// 2. 注册业务单例(如你之前的 Handler
builder.Services.AddSingleton<DeviceConfigHandler>();
builder.Services.AddSingleton<DeviceStatusHandler>();
var app = builder.Build();
// 3. 映射服务路(将逻辑与端口绑定)
app.MapGrpcService<GatewayService>();
new Thread(StartServer).Start();
GrpcServerManager.Start();
// 4. 启动监听(代替 Console.ReadLine
// 建议端口与 CameraService 配置的 9002 保持一致
_sysLog.Information("MjpegPlayer gRPC 服务启动于端口 9002");
app.Run("http://0.0.0.0:9002");
}
#region StartServer
/// <summary>
/// 开启服务监听
/// </summary>
static void StartServer()
{
try
{
// 加载配置文件
var cfg = Bootstrapper.LoadConfig();
// 检查 IP 与端口
Bootstrapper.ValidateEnvironment();
// 开启 Wcf 服务
StartWcfServer();
_sysLog.Information("WCF 推流接口已就绪, 端口: {Port}", cfg.WcfPushImagePort);
// 会话开启
// [修复] 端口循环逻辑:同样改为 <= 以匹配检测逻辑
for (var i = cfg.SvrMjpegPortBegin; i <= cfg.SvrMjpegPortEnd; i++)
{
MjpegServer.Start(i);
}
_sysLog.Information("MJPEG 服务池已开启: {Begin} -> {End}", cfg.SvrMjpegPortBegin, cfg.SvrMjpegPortEnd);
// 开启 RTMP 服务
RtmpPushServer.Instance.Start();
}
catch (Exception ex)
{
//Logs.LogCritical<Program>(ex.Message, ex.StackTrace);
// 退出应用
Bootstrapper.ExitApp("应用程序崩溃.");
}
}
#endregion
#region StartWcfServer
/// <summary>
/// 开启 Wcf 服务
/// </summary>
private static void StartWcfServer()
{
try
{
var cfg = MjpegStatics.Cfg;
Bootstrapper.StartWcfEngine(cfg);
}
catch (Exception ex)
{
_sysLog.Fatal(ex, "应用程序崩溃.");
// 退出应用
Bootstrapper.ExitApp("应用程序崩溃.");
}
}
#endregion
}
}

View File

@@ -0,0 +1,378 @@
using System.Runtime.Serialization;
namespace Core.Protocol
{
/// <summary>
/// 基础响应分页
/// </summary>
public class BaseReplyPagination
{
/// <summary>
/// 当前页
/// </summary>
[DataMember]
public int Current_Page { get; set; }
= 1;
/// <summary>
/// 每页数量
/// </summary>
[DataMember]
public int Page_Size { get; set; }
= 1000;
/// <summary>
/// 总记录数
/// </summary>
[DataMember]
public int Total { get; set; }
= 0;
}
#region BaseReply
/// <summary>
/// 基础响应
/// </summary>
[DataContract]
public class BaseReply
{
/// <summary>
/// 是否成功
/// </summary>
[DataMember]
public Guid ExecGuid { get; set; }
/// <summary>
/// 执行码
/// </summary>
[DataMember]
public int Code { get; set; }
/// <summary>
/// 是否成功
/// </summary>
[DataMember]
public bool Success { get; set; }
/// <summary>
/// 执行消息
/// </summary>
[DataMember]
public string Msg { get; set; }
= string.Empty;
/// <summary>
/// 数据API
/// </summary>
[DataMember]
public string? DataApi { get; set; }
/// <summary>
/// 数据主体
/// </summary>
[DataMember]
public object? DataTable { get; set; }
/// <summary>
/// 数据对象
/// </summary>
[DataMember]
public object? DataObject { get; set; }
/// <summary>
/// 列信息
/// </summary>
[DataMember]
public List<ReplyColumn>? Columns { get; set; }
= new List<ReplyColumn>();
/// <summary>
/// 分页信息
/// </summary>
[DataMember]
public BaseReplyPagination Pagination { get; set; }
= new BaseReplyPagination();
#region Create
/// <summary>
/// 创建基础响应对象
/// </summary>
/// <param name="msg"></param>
/// <returns></returns>
public static BaseReply Create(string msg)
{
var reply = new BaseReply();
reply.Msg = msg;
reply.ReplySuccess();
return reply;
}
#endregion
#region Create
/// <summary>
/// 创建基础响应对象
/// </summary>
/// <returns></returns>
public static BaseReply Create(List<object> data, List<ReplyColumn>? columns = null)
{
var reply = new BaseReply();
reply.DataTable = data;
reply.Columns = columns;
if (data != null)
reply.Pagination.Total = data.Count;
reply.ReplySuccess();
return reply;
}
#endregion
#region Create
/// <summary>
/// 创建基础响应对象
/// </summary>
/// <returns></returns>
public static BaseReply Create<T>(List<T> data, List<ReplyColumn>? columns = null)
{
var reply = new BaseReply();
reply.DataTable = data;
reply.Columns = columns;
if (data != null)
reply.Pagination.Total = data.Count;
reply.ReplySuccess();
return reply;
}
#endregion
#region CreateFalt
/// <summary>
/// 创建基础响应对象
/// </summary>
/// <param name="msg"></param>
/// <returns></returns>
public static BaseReply CreateFalt(string msg = "失败")
{
var reply = new BaseReply();
reply.Success = false;
reply.Code = -1;
reply.Msg = msg;
return reply;
}
#endregion
#region ReplySuccess
/// <summary>
/// 成功
/// </summary>
public void ReplySuccess()
{
Success = true;
Code = 200;
if (string.IsNullOrEmpty(Msg))
Msg = "成功";
}
#endregion
#region ReplyFalt
/// <summary>
/// 失败
/// </summary>
public void ReplyFalt(string msg = "失败", int code = -1)
{
Success = false;
Code = code;
Msg = msg;
}
#endregion
}
#endregion
/// <summary>
/// 基础响应
/// </summary>
[DataContract]
public class Base2Reply
{
/// <summary>
/// 是否成功
/// </summary>
[DataMember]
public bool Success { get; set; }
/// <summary>
/// 执行码
/// </summary>
[DataMember]
public int Code { get; set; }
/// <summary>
/// 执行消息
/// </summary>
[DataMember]
public string Msg { get; set; }
= string.Empty;
/// <summary>
/// 数据类型
/// </summary>
[DataMember]
public ReplyDataType DataType { get; set; }
= ReplyDataType.Object;
/// <summary>
/// 数据主体
/// </summary>
[DataMember]
public object? Data { get; set; }
/// <summary>
/// 成功
/// </summary>
/// <param name="data"></param>
public void ReplySuccess(string data)
{
Success = true;
Code = 0;
Msg = "成功";
Data = data;
}
/// <summary>
/// 成功
/// </summary>
public void ReplySuccess()
{
Success = true;
Code = 0;
Msg = "成功";
}
/// <summary>
/// 失败
/// </summary>
public void ReplyFalt()
{
Success = false;
Code = -1;
Msg = "失败";
}
/// <summary>
/// 失败
/// </summary>
/// <param name="msg"></param>
/// <param name="data"></param>
public void ReplyFalt(string msg, string? data = null)
{
Success = false;
Code = -1;
Msg = msg;
Data = data;
}
}
/// <summary>
/// 响应数据类型
/// </summary>
public enum ReplyDataType
{
/// <summary>
/// 空类型
/// </summary>
Empty,
/// <summary>
/// 字符串类型
/// </summary>
String,
/// <summary>
/// 对象类型
/// </summary>
Object,
/// <summary>
/// 列表类型
/// </summary>
ObjectList,
/// <summary>
/// 动态对象类型
/// </summary>
ExpandoObject,
/// <summary>
/// 动态对象类型
/// </summary>
ExpandoObjectList,
}
/// <summary>
/// 响应列
/// </summary>
public class ReplyColumn
{
public ReplyColumn()
{
}
public ReplyColumn(string name, string caption)
{
Name = name;
Caption = caption;
}
/// <summary>
/// 列名
/// </summary>
[DataMember]
public string Name { get; set; }
= string.Empty;
/// <summary>
/// 列标题
/// </summary>
[DataMember]
public string Caption { get; set; }
= string.Empty;
/// <summary>
/// 列宽度
/// </summary>
[DataMember]
public double Width { get; set; }
/// <summary>
/// 是否可见
/// </summary>
[DataMember]
public bool IsVisible { get; set; }
= true;
/// <summary>
/// 格式化字符串
/// </summary>
[DataMember]
public string Format { get; set; }
= string.Empty;
}
}

View File

@@ -0,0 +1,129 @@
using Core.Protocol;
using System.Runtime.Serialization;
namespace Core.WcfProtocol
{
[DataContract]
public class RegisterModelRequest
{
/// <summary>
/// 进程Id
/// </summary>
[DataMember]
public Int32 ProcId { get; set; }
/// <summary>
/// 进程类型
/// </summary>
[DataMember]
public Int32 ProcType { get; set; }
/// <summary>
/// 进程通信号
/// </summary>
[DataMember]
public Int32 ProcChannel { get; set; }
/// <summary>
/// 进程启动时间
/// </summary>
[DataMember]
public Int64 ProcStartTime { get; set; }
/// <summary>
/// 接收消息端口
/// </summary>
[DataMember]
public Int32 AcceptPort { get; set; }
}
/// <summary>
/// 注册结果
/// </summary>
[DataContract]
public class RegisterModelReply : Base2Reply
{
}
[DataContract]
public class UploadImageRequest
{
/// <summary>
/// 唯一标识
/// </summary>
[DataMember]
public Int64 Id { get; set; }
/// <summary>
/// 设备 IP
/// </summary>
[DataMember]
public string IpAddress { get; set; }
= string.Empty;
/// <summary>
/// 进程 ID
/// </summary>
[DataMember]
public Int32 ProcId { get; set; }
/// <summary>
/// 图片序号
/// </summary>
[DataMember]
public UInt64 Order { get; set; }
/// <summary>
/// 名称
/// </summary>
[DataMember]
public string Name { get; set; }
= string.Empty;
/// <summary>
/// 类型
/// </summary>
[DataMember]
public string Type { get; set; }
= string.Empty;
/// <summary>
/// 时间
/// </summary>
[DataMember]
public DateTime Time { get; set; }
/// <summary>
/// 图片数据
/// </summary>
[DataMember]
public byte[]? ImageBytes { get; set; }
/// <summary>
/// 图像宽度
/// </summary>
[DataMember]
public int ImageWidth { get; set; }
/// <summary>
/// 图像高度
/// </summary>
[DataMember]
public int ImageHeight { get; set; }
/// <summary>
/// 图片数据
/// </summary>
[DataMember]
public string ImageData { get; set; }
= string.Empty;
}
/// <summary>
/// 图片上传回复
/// </summary>
[DataContract]
public class UploadImageReply : Base2Reply
{
}
}

View File

@@ -0,0 +1,26 @@
using CoreWCF;
namespace Core.WcfProtocol
{
/// <summary>
/// CoreImagesService 接口
/// </summary>
[ServiceContract]
public interface ICoreImagesService
{
/// <summary>
/// 上传图片
/// </summary>
/// <param name="req"></param>
/// <returns></returns>
[OperationContract]
UploadImageReply UploadImage(UploadImageRequest req);
/// <summary>
/// 上传图片无返回结果
/// </summary>
/// <param name="req"></param>
[OperationContract(IsOneWay = true)]
void UploadImageOneWay(UploadImageRequest req);
}
}

View File

@@ -0,0 +1,21 @@
using Core.WcfProtocol;
using CoreWCF;
using SHH.MjpegPlayer;
namespace Player.MJPEG
{
/// <summary>
/// IMjpegImagesService 接口
/// </summary>
[ServiceContract]
public interface IMjpegImagesService : ICoreImagesService
{
/// <summary>
/// 注册模型
/// </summary>
/// <param name="req"></param>
/// <returns></returns>
[OperationContract]
MjpegPlayInfoReply GetRtspRtcPlayInfo();
}
}

View File

@@ -0,0 +1,61 @@
using Core.Protocol;
using System.Runtime.Serialization;
namespace SHH.MjpegPlayer
{
/// <summary>
/// Mjpeg播放信息回复
/// </summary>
[DataContract]
public class MjpegPlayInfoReply : BaseReply
{
/// <summary>
/// 返回的信息集合
/// </summary>
[DataMember]
public List<MjpegPlayInfo> Infos { get; set; }
= new List<MjpegPlayInfo>();
}
public class MjpegPlayInfo
{
/// <summary>
/// 摄像头 ID
/// </summary>
[DataMember]
public Int32 CameraId { get; set; }
/// <summary>
/// 分析类型代码
/// </summary>
[DataMember]
public int AITypeCode { get; set; }
/// <summary>
/// 分析类型
/// </summary>
[DataMember]
public string AIType { get; set; }
= string.Empty;
/// <summary>
/// Rtsp 端口
/// </summary>
[DataMember]
public Int32 RtspPort { get; set; }
/// <summary>
/// 用户名
/// </summary>
[DataMember]
public string Account { get; set; }
= string.Empty;
/// <summary>
/// 密码
/// </summary>
[DataMember]
public string Password { get; set; }
= string.Empty;
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CoreWCF.Http" Version="1.8.0" />
<PackageReference Include="CoreWCF.Primitives" Version="1.8.0" />
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
<PackageReference Include="Prism.Core" Version="8.1.97" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Ayay.SerilogLogs\Ayay.SerilogLogs.csproj" />
<ProjectReference Include="..\SHH.Contracts.Grpc\SHH.Contracts.Grpc.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,75 @@
using Ayay.SerilogLogs;
using Core.WcfProtocol;
using Serilog;
namespace SHH.MjpegPlayer
{
/// <summary>
/// CoreImagesService 服务
/// </summary>
public class CoreImagesService : ICoreImagesService
{
private static readonly ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core);
#region Defines
/// <summary>
/// 按秒统计
/// </summary>
public static SumByTime _sumBySecond = new SumByTime();
#endregion
#region UploadImage
/// <summary>
/// 上传图片
/// </summary>
/// <param name="req"></param>
/// <returns></returns>
public UploadImageReply UploadImage(UploadImageRequest req)
{
var reply = new UploadImageReply();
try
{
// 日志准备
_sumBySecond.Refresh("UploadImage");
PrismMsg<UploadImageRequest>.Publish(req);
}
catch (Exception ex)
{
_sysLog.Warning($"上传图片失败, {ex.Message} {ex.StackTrace}");
reply.ReplyFalt(ex.Message, ex.Source);
}
reply.ReplySuccess();
return reply;
}
#endregion
#region UploadImageOneWay
/// <summary>
/// 上传图片
/// </summary>
/// <param name="req"></param>
public void UploadImageOneWay(UploadImageRequest req)
{
try
{
_sumBySecond.Refresh("UploadImage");
PrismMsg<UploadImageRequest>.Publish(req);
}
catch (Exception ex)
{
_sysLog.Warning($"上传图片失败, {ex.Message} {ex.StackTrace}");
}
}
#endregion
}
}

View File

@@ -0,0 +1,163 @@
using Newtonsoft.Json;
using System.Net.Sockets;
using System.Text;
namespace SHH.MjpegPlayer
{
/// <summary>
/// MJPEG HTTP命令类
/// </summary>
public class MjpegHttpCmd
{
#region DoHttpCmd
public static bool DoHttpCmd(NetworkStream stream,
SessionInfo Info, string Cmd)
{
try
{
switch (Cmd)
{
case "view":
Info.Message = "执行 view 命令.";
DoHttpCmdView(stream, Info);
return true;
case "task":
Info.Message = "执行 task 命令.";
DoHttpCmdTask(stream, Info);
return true;
}
return false;
}
catch (Exception ex)
{
SendJson(stream, $"Command Failed: {ex.Message}", 400);
return true;
}
}
#endregion
#region DoHttpCmdView
private static void DoHttpCmdView(NetworkStream stream, SessionInfo Info)
{
var sessions = new List<SessionInfo>();
int iSessionCount = 0;
var allSessions = MjpegStatics.Sessions.GetAllSessionInfos();
foreach (var sessionInfo in allSessions)
{
if (sessionInfo == null) continue;
if (!string.IsNullOrEmpty(Info.DeviceId))
{
if (!string.IsNullOrEmpty(sessionInfo.DeviceId)
&& !sessionInfo.DeviceId.Equals(Info.DeviceId))
continue;
}
if (!string.IsNullOrEmpty(Info.TypeCode))
{
if (!string.IsNullOrEmpty(sessionInfo.TypeCode)
&& !sessionInfo.TypeCode.Equals(Info.TypeCode))
continue;
}
iSessionCount++;
sessions.Add(sessionInfo);
}
var chns = new List<ImageChannel>();
var imgChns = MjpegStatics.ImageChannels;
int iImgChanCount = 0;
foreach (var kvp in imgChns.Channels)
{
var imgChannel = kvp.Value;
if (imgChannel == null) continue;
if (!string.IsNullOrEmpty(Info.DeviceId))
{
if (!imgChannel.DeviceId.ToString().Equals(Info.DeviceId))
continue;
}
if (!string.IsNullOrEmpty(Info.TypeCode))
{
if (!imgChannel.Type.Equals(Info.TypeCode))
continue;
}
iImgChanCount++;
chns.Add(imgChannel);
}
var result = new
{
webAccessCount = iSessionCount,
deviceChannelCount = iImgChanCount,
webAccessItems = sessions,
deviceChannels = chns
};
SendJson(stream, JsonConvert.SerializeObject(result, Formatting.Indented));
}
#endregion
#region DoHttpCmdTask
private static void DoHttpCmdTask(NetworkStream stream, SessionInfo Info)
{
// [Optimized]: 直接从 TaskManager 获取实时任务快照,避免遍历旧的静态字典
var activeTasks = TaskManager.RunningTasks.Values.ToList();
int iTaskCount = activeTasks.Count;
int iMjpegServerListenCount = activeTasks.Count(t => t.Name.Contains("MjpegServer-"));
var result = new
{
taskCount = iTaskCount,
portListenCount = iMjpegServerListenCount,
// 映射为前端需要的格式
taskItems = activeTasks.Select(t => new { t.Name, t.Type })
};
// 使用 Newtonsoft.Json 或 System.Text.Json 输出
SendJson(stream, JsonConvert.SerializeObject(result, Formatting.Indented));
}
#endregion
#region Helper
private static void SendJson(NetworkStream stream, string json, int code = 200)
{
try
{
string statusLine = code == 200 ? "200 OK" : "400 Bad Request";
// [修复] 添加 CORS 头,允许诊断页面跨域访问
byte[] response = Encoding.UTF8.GetBytes(
$"HTTP/1.1 {statusLine}\r\n" +
"Access-Control-Allow-Origin: *\r\n" +
"Access-Control-Allow-Methods: GET, POST\r\n" +
"Content-Type: application/json; charset=utf-8\r\n" +
$"Content-Length: {Encoding.UTF8.GetByteCount(json)}\r\n\r\n" +
json
);
stream.Write(response, 0, response.Length);
stream.Flush();
stream.Close();
}
catch { }
}
#endregion
}
}

View File

@@ -0,0 +1,28 @@
using Player.MJPEG;
namespace SHH.MjpegPlayer
{
/// <summary>
/// MjpegImagesService 服务
/// </summary>
public class MjpegImagesService : CoreImagesService, IMjpegImagesService
{
#region GetRtspRtcPlayInfo
/// <summary>
/// 获取 RtspRtc 播放信息
/// </summary>
/// <returns></returns>
public MjpegPlayInfoReply GetRtspRtcPlayInfo()
{
var reply = new MjpegPlayInfoReply();
// 发送消息
PrismMsg<MjpegPlayInfoReply>.Publish(reply);
return reply;
}
#endregion
}
}

View File

@@ -0,0 +1,98 @@
using System.Net;
using System.Net.Sockets;
namespace SHH.MjpegPlayer
{
/// <summary>
/// Mjpeg 服务
/// </summary>
public class MjpegServer
{
// [修复] 静态列表管理监听器,支持优雅停止
private static readonly List<TcpListener> _listeners = new List<TcpListener>();
private static readonly object _lock = new object();
/// <summary>
/// 启动服务
/// </summary>
/// <param name="port"></param>
public static void Start(int port)
{
try
{
// 示例:在 MjpegServer 初始化循环中调用
TaskManager.Run($"MjpegServer-{port}", "Network", async (token) =>
{
// [Modified]: 使用 TaskManager 托管,支持外部取消令牌 token
try
{
var cfg = MjpegStatics.Cfg;
IPAddress ipAddress = IPAddress.Any;
if (!string.IsNullOrEmpty(cfg.SvrMjpegIp) && IPAddress.TryParse(cfg.SvrMjpegIp, out var parsedIp))
{
ipAddress = parsedIp;
}
var server = new TcpListener(ipAddress, port);
lock (_lock) _listeners.Add(server);
server.Start();
// Logs.LogInformation...
try
{
// [Modified]: 检查取消令牌和全局运行状态
while (!token.IsCancellationRequested)
{
try
{
// 使用 AcceptTcpClientAsync 的重载或在外部检查
var client = await server.AcceptTcpClientAsync();
if (client == null) continue;
var session = new MjpegSession();
session.Create(client);
}
catch (Exception ex)
{
// [修复] 异常防暴
await Task.Delay(1000, token);
}
}
}
finally
{
// [修复] 任务退出清理
try { server.Stop(); } catch { }
lock (_lock) _listeners.Remove(server);
}
}
catch (Exception ex)
{
// Logs.LogError... 捕获初始化异常
}
});
}
catch (Exception ex)
{
//Logs.LogError<MjpegServer>(ex.Message, ex.StackTrace);
}
}
/// <summary>
/// 停止所有服务 (新增)
/// </summary>
public static void StopAll()
{
lock (_lock)
{
foreach (var server in _listeners)
{
try { server.Stop(); } catch { }
}
_listeners.Clear();
}
}
}
}

View File

@@ -0,0 +1,341 @@
using Ayay.SerilogLogs;
using Core.WcfProtocol;
using Serilog;
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace SHH.MjpegPlayer
{
/// <summary>
/// Mjpeg 会话工作单元
/// </summary>
public class MjpegSession : IDisposable
{
private static readonly ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core);
#region Counter
private SumByTime _sumBySecond = new SumByTime();
/// <summary>
/// 计数器
/// </summary>
public SumByTime Counter => _sumBySecond;
#endregion
#region Info
/// <summary>
/// 基础信息
/// </summary>
public SessionInfo Info { get; private set; }
#endregion
#region Cmd
/// <summary>
/// 命令
/// </summary>
public string? Cmd { get; set; }
#endregion
// [修复] 引入 Disposed 标志位
private volatile bool _isDisposed = false;
#region Constructor
/// <summary>
/// 构造函数
/// </summary>
public MjpegSession()
{
Info = new SessionInfo
{
Counter = _sumBySecond
};
}
#endregion
#region DoHttpHeader
private bool DoHttpHeader(NetworkStream stream)
{
try
{
byte[] buffer = new byte[4096];
int totalBytesRead = 0;
string httpRequest = string.Empty;
while (totalBytesRead < buffer.Length)
{
int bytesRead = stream.Read(buffer, totalBytesRead, buffer.Length - totalBytesRead);
if (bytesRead == 0) return false;
totalBytesRead += bytesRead;
httpRequest = Encoding.ASCII.GetString(buffer, 0, totalBytesRead);
if (httpRequest.Contains("\r\n\r\n")) break;
}
if (!httpRequest.Contains("\r\n\r\n")) return false;
if (!string.IsNullOrEmpty(httpRequest))
{
int queryStartIndex = httpRequest.IndexOf('?');
if (queryStartIndex > -1)
{
int spaceIndex = httpRequest.IndexOf(' ', queryStartIndex);
if (spaceIndex == -1) spaceIndex = httpRequest.Length;
string queryString = httpRequest.Substring(queryStartIndex + 1, spaceIndex - queryStartIndex - 1);
var queryParams = System.Web.HttpUtility.ParseQueryString(queryString);
if (queryParams != null)
{
Info.DeviceId = queryParams["id"];
Info.TypeCode = queryParams["typeCode"];
Cmd = queryParams["cmd"];
}
}
}
if (string.IsNullOrEmpty(Cmd))
{
if (string.IsNullOrEmpty(Info.DeviceId) || string.IsNullOrEmpty(Info.TypeCode))
{
SendErrorResponse(stream, "错误缺少必要参数id 或 typeCode");
return false;
}
}
else
{
if (MjpegHttpCmd.DoHttpCmd(stream, Info, Cmd)) return false;
}
return true;
}
catch (Exception ex)
{
SendErrorResponse(stream, $"解析异常: {ex.Message}");
return false;
}
}
private void SendErrorResponse(NetworkStream stream, string msg)
{
try
{
byte[] response = Encoding.UTF8.GetBytes(
$"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n{msg}");
stream.Write(response, 0, response.Length);
stream.Flush();
}
catch { }
}
#endregion
#region Create
/// <summary>
/// 创建会话
/// </summary>
public void Create(TcpClient client)
{
try
{
if (Info == null) return;
Info.AcceptTime = DateTime.Now;
// 初始化最近接收时间,避免刚连接就被判定为超时
LastRecImgTime = DateTime.Now;
Task.Run(() => { DoWorkTask(client); });
}
catch (Exception ex)
{
_sysLog.Error($"MjpegSession Create Exception, 异常信息:{ex.Message}, {ex.StackTrace}");
}
}
#endregion
#region DoWorkTask
private void DoWorkTask(TcpClient client)
{
try
{
using (var stream = client.GetStream())
{
// 设置写入超时 3秒
stream.WriteTimeout = 3000;
#region ,
int iLoc = 0;
while (!client.Connected)
{
Thread.Sleep(50);
if (++iLoc > 60) return;
}
try
{
if (client.Client?.RemoteEndPoint is IPEndPoint endpoint)
{
Info.ClientIp = endpoint.Address.ToString();
Info.ClientPort = endpoint.Port;
}
}
catch { }
if (!DoHttpHeader(stream)) return;
#endregion
MjpegStatics.Sessions.AddSession(this);
byte[] header = Encoding.ASCII.GetBytes(
"HTTP/1.1 200 OK\r\n" +
"Content-Type: multipart/x-mixed-replace; boundary=frame\r\n\r\n");
stream.Write(header, 0, header.Length);
var frameInterval = MjpegStatics.Cfg.FrameInterval;
if (frameInterval < 1 || frameInterval > 500) frameInterval = 125;
Stopwatch stopwatch = Stopwatch.StartNew();
UploadImageRequest? lastProcItem = null;
byte[] boundaryBytes = Encoding.ASCII.GetBytes("\r\n--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ");
byte[] doubleNewLine = Encoding.ASCII.GetBytes("\r\n\r\n");
while (client.Connected && !_isDisposed)
{
try
{
// [新增] 僵尸连接熔断机制:如果源头超过 60 秒没有新图片,主动断开连接
if ((DateTime.Now - LastRecImgTime).TotalSeconds > 60)
{
_sysLog.Warning($"会话超时断开 (源头无数据 > 60s): {Info.Key} - Client: {Info.ClientIp}");
break;
}
stopwatch.Restart();
if (_lastRecObj == null || _lastRecObj.ImageBytes == null)
{
Info.Message = "等待图片数据抵达";
Thread.Sleep(40);
continue;
}
Info.Message = "视频流播放中";
if (lastProcItem != null && lastProcItem != _lastRecObj)
{
_sumBySecond.Refresh("有效帧数");
}
lastProcItem = _lastRecObj;
// 如果图片太旧超过3秒认为是滞后数据暂时不发
if ((DateTime.Now - _lastRecObj.Time).TotalSeconds > 3)
{
Thread.Sleep(40);
continue;
}
byte[] imageData = _lastRecObj.ImageBytes;
stream.Write(boundaryBytes, 0, boundaryBytes.Length);
byte[] lengthBytes = Encoding.ASCII.GetBytes(imageData.Length.ToString());
stream.Write(lengthBytes, 0, lengthBytes.Length);
stream.Write(doubleNewLine, 0, doubleNewLine.Length);
stream.Write(imageData, 0, imageData.Length);
_sumBySecond.Refresh("播放帧数");
stopwatch.Stop();
var needSleep = frameInterval - (int)stopwatch.ElapsedMilliseconds;
if (needSleep > 0) Thread.Sleep(needSleep);
}
catch (Exception ex)
{
_sysLog.Warning($"播放连接断开, : {ex.Message}");
break;
}
}
}
}
catch (Exception ex)
{
_sysLog.Error($"异常信息:{ex.Message}, {ex.StackTrace}");
}
finally
{
Dispose();
if (client != null)
{
try { client.Close(); client.Dispose(); } catch { }
}
}
}
#endregion
#region DoImageProc
private UInt64 _lastPlayImageOrder = 0;
private UploadImageRequest? _lastRecObj = null;
/// <summary>
/// 处置图片
/// </summary>
public void DoImageProc(UploadImageRequest req)
{
try
{
LastRecImgTime = DateTime.Now;
_sumBySecond.Refresh("接收帧数");
if (req == null || req.ImageBytes == null || req.ImageBytes.Length < 100) return;
// 防止死锁:序号重置检测
if (req.Order < _lastPlayImageOrder)
{
if (_lastPlayImageOrder > 100 && req.Order < 100)
{
_lastPlayImageOrder = req.Order;
}
else
{
return;
}
}
_lastPlayImageOrder = req.Order;
_lastRecObj = req;
}
catch (Exception ex)
{
_sysLog.Error($"异常信息:{ex.Message}, {ex.StackTrace}");
}
}
/// <summary>
/// 最近收到图片时间
/// </summary>
public DateTime LastRecImgTime { get; set; } = DateTime.MinValue;
#endregion
#region Dispose
public void Dispose()
{
if (_isDisposed) return;
_isDisposed = true;
MjpegStatics.Sessions.RemoveSession(this);
}
#endregion
}
}

View File

@@ -0,0 +1,116 @@
using Core.WcfProtocol;
using System.Collections.Concurrent;
namespace SHH.MjpegPlayer;
/// <summary>
/// 服务器会话集合 (线程安全重构版)
/// </summary>
public class MjpegSessions
{
// 核心改变使用字典建立索引Key = DeviceId#TypeCode
// 这样可以将查找特定摄像头的复杂度从 O(N) 降低到 O(1)
private readonly ConcurrentDictionary<string, List<MjpegSession>> _sessionMap
= new ConcurrentDictionary<string, List<MjpegSession>>();
/// <summary>
/// 构造函数
/// </summary>
public MjpegSessions()
{
PrismMsg<UploadImageRequest>.Subscribe(ProcUploadImageRequest);
}
/// <summary>
/// 优化后的图片分发逻辑 (解决 O(N) 和 线程安全问题)
/// </summary>
/// <param name="req"></param>
public void ProcUploadImageRequest(UploadImageRequest req)
{
try
{
string key = $"{req.Id}#{req.Type}";
// 1. 更新通道信息 (ImageChannels 现已是线程安全的)
var chn = MjpegStatics.ImageChannels.Do(req, key);
// 2. O(1) 快速查找关注该流的会话列表
if (_sessionMap.TryGetValue(key, out var targetSessions))
{
// 必须加锁,防止遍历时 List 被其他线程(如 AddSession/RemoveSession) 修改
lock (targetSessions)
{
// 倒序遍历,方便在需要时移除失效会话
for (var i = targetSessions.Count - 1; i >= 0; i--)
{
var session = targetSessions[i];
session.DoImageProc(req);
}
}
if (chn != null) chn.IsPlaying = targetSessions.Count > 0;
}
else
{
if (chn != null) chn.IsPlaying = false;
}
}
catch (Exception ex)
{
//Logs.LogWarning<MjpegSessions>(ex.Message, ex.StackTrace);
}
}
/// <summary>
/// 添加会话
/// </summary>
/// <param name="session"></param>
public void AddSession(MjpegSession session)
{
if (session?.Info?.Key == null) return;
// 使用 GetOrAdd 确保线程安全地获取或创建 List
var list = _sessionMap.GetOrAdd(session.Info.Key, _ => new List<MjpegSession>());
lock (list)
{
list.Add(session);
}
}
/// <summary>
/// 移除会话
/// </summary>
/// <param name="session"></param>
public void RemoveSession(MjpegSession session)
{
if (session?.Info?.Key == null) return;
if (_sessionMap.TryGetValue(session.Info.Key, out var list))
{
lock (list)
{
list.Remove(session);
}
}
}
/// <summary>
/// 获取当前所有会话信息的快照 (用于 HTTP API 统计与展示)
/// [新增] 此方法替代旧版直接访问 Sessions 列表,防止 HTTP 线程与 MJPEG 线程发生冲突
/// </summary>
public List<SessionInfo> GetAllSessionInfos()
{
var result = new List<SessionInfo>();
// 遍历字典,线程安全地收集所有 Info
foreach (var kvp in _sessionMap)
{
// 对内部 List 加锁,确保复制过程不被打断
lock (kvp.Value)
{
result.AddRange(kvp.Value.Select(s => s.Info));
}
}
return result;
}
}

View File

@@ -0,0 +1,163 @@
using Ayay.SerilogLogs;
using Serilog;
namespace SHH.MjpegPlayer
{
/// <summary>
/// RTMP 推流参数同步服务器
/// 职责:定期将本地图片通道信息同步至流媒体服务器,并获取最新的 RTMP 推流地址。
/// </summary>
public class RtmpPushServer
{
private static ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core);
#region Instance
/// <summary>
/// 获取 RTMP 推流处理器的全局单例实例
/// </summary>
public static RtmpPushServer Instance { get; } = new RtmpPushServer();
// 私有构造函数防止外部 new
private RtmpPushServer() { }
#endregion
#region Start
/// <summary>
/// 启动 RTMP 推流任务 (对接新架构 TaskManager)
/// </summary>
public void Start()
{
// Optimized: 使用 TaskManager.Run 替代旧的线程启动方式,实现任务可视化管理
TaskManager.Run("RtmpPushSync", "Monitor", async (token) =>
{
try
{
_sysLog.Information("RTMP 推流同步任务正在启动...");
// 1. 初始化延迟:稍作延迟,等待系统其他组件初始化完成
await Task.Delay(2000, token);
// 2. 配置校验
if (!MjpegStatics.Cfg.UseRtmpServer)
{
_sysLog.Warning("配置未启用 RTMP 服务,推流任务已跳过");
return;
}
#region
bool isConnected = false;
while (!isConnected && !token.IsCancellationRequested)
{
try
{
var cfg = MjpegStatics.Cfg;
var testItems = new List<object> { new { deviceIp = "", deviceId = "", algCode = "" } };
var result = testItems.PostJson<CfgRtmpReply>(cfg.RtmpServerDjhUri);
if (result != null && result.IsSuccess)
{
_sysLog.Information("流媒体服务接口检测通过: {Uri}", cfg.RtmpServerDjhUri);
isConnected = true;
}
else
{
_sysLog.Warning("检测流媒体服务接口失败, 30秒后再试... Uri: {Uri}", cfg.RtmpServerDjhUri);
await Task.Delay(30000, token);
}
}
catch (Exception ex)
{
_sysLog.Error(ex, "流媒体服务接口检测异常");
await Task.Delay(5000, token);
}
}
#endregion
#region ( OnDoTaskAction)
while (!token.IsCancellationRequested)
{
// 执行业务同步逻辑
await SyncRtmpParametersAsync(token);
// 每 50ms 执行一次 (对应原 Start 中的 50ms 频率)
await Task.Delay(50, token);
}
#endregion
}
catch (OperationCanceledException)
{
_sysLog.Information("RTMP 推流同步任务已正常取消");
}
catch (Exception ex)
{
_sysLog.Fatal(ex, "RTMP 推流任务发生致命错误");
}
});
}
#endregion
#region
private async Task SyncRtmpParametersAsync(CancellationToken token)
{
try
{
var cfg = MjpegStatics.Cfg;
var imgChns = MjpegStatics.ImageChannels;
var pushItems = new List<object>();
// 构建上报数据
foreach (var kvp in imgChns.Channels)
{
if (token.IsCancellationRequested) return;
var imgChn = kvp.Value;
if (imgChn == null || !imgChn.UseRtmp) continue;
pushItems.Add(new
{
deviceIp = string.IsNullOrEmpty(imgChn.IpAddress) ? "127.0.0.1" : imgChn.IpAddress,
deviceId = imgChn.DeviceId.ToString(),
algCode = imgChn.Type
});
}
if (pushItems.Count > 0)
{
// 使用 await 配合异步扩展,释放线程池线程
// 如果你的 NetHttpExtension 已支持异步,则直接 await。
// 否则使用 await Task.Run(() => pushItems.PostJson(...)) 暂时过渡。
var result = await pushItems.PostJsonAsync<CfgRtmpReply>(cfg.RtmpServerDjhUri, 2000);
if (result?.rtmpVoList != null && result.IsSuccess)
{
foreach (var item in result.rtmpVoList)
{
var channel = imgChns.Get(item.deviceId, item.algCode);
if (channel != null && channel.RtmpUri != item.rtmp)
{
// 假设后续我们会统计实例中的成功次数
channel.RtmpUri = item.rtmp;
}
}
}
}
}
catch (Exception ex)
{
// 使用统一的 Serilog 对象输出结构化日志
_sysLog.Error(ex, "SyncRtmpParametersAsync 执行异常");
}
}
#endregion
}
}

View File

@@ -0,0 +1,48 @@
using System.Collections.Concurrent;
namespace SHH.MjpegPlayer;
/// <summary>
/// 任务状态信息载荷
/// </summary>
public record TaskMetadata(string Name, string Type, DateTime StartTime);
/// <summary>
/// 任务管理器:替代原 CoreTaskRun 功能
/// 职责:记录运行中的异步任务,支持状态检索和统一取消
/// </summary>
public static class TaskManager
{
// 存储运行中的任务及其元数据
public static readonly ConcurrentDictionary<string, TaskMetadata> RunningTasks = new();
// 存储取消令牌,用于停止特定任务
private static readonly ConcurrentDictionary<string, CancellationTokenSource> _tokens = new();
/// <summary>
/// 注册并运行一个受控任务
/// </summary>
public static void Run(string taskName, string taskType, Func<CancellationToken, Task> action)
{
var cts = new CancellationTokenSource();
_tokens[taskName] = cts;
var metadata = new TaskMetadata(taskName, taskType, DateTime.Now);
RunningTasks[taskName] = metadata;
// 启动异步任务
Task.Run(async () =>
{
try
{
await action(cts.Token);
}
finally
{
// Optimized: 任务结束(无论正常还是异常)必须清理资源
RunningTasks.TryRemove(taskName, out _);
_tokens.TryRemove(taskName, out _);
}
}, cts.Token);
}
}