From 35647d21bb3b6457232a471989b86de0b05097ba Mon Sep 17 00:00:00 2001 From: SikongJueluo Date: Mon, 4 Aug 2025 13:26:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0Hdmi=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E4=B8=B2=E6=B5=81=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/Program.cs | 2 + .../Controllers/HdmiVideoStreamController.cs | 63 +++++ server/src/Peripherals/HdmiInClient.cs | 4 +- .../Services/HttpHdmiVideoStreamService.cs | 234 ++++++++++++++++++ src/APIClient.ts | 165 ++++++++++++ 5 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 server/src/Controllers/HdmiVideoStreamController.cs create mode 100644 server/src/Services/HttpHdmiVideoStreamService.cs diff --git a/server/Program.cs b/server/Program.cs index 3901dfd..ee23ac0 100644 --- a/server/Program.cs +++ b/server/Program.cs @@ -145,6 +145,8 @@ try // 添加 HTTP 视频流服务 builder.Services.AddSingleton(); builder.Services.AddHostedService(provider => provider.GetRequiredService()); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(provider => provider.GetRequiredService()); // Application Settings var app = builder.Build(); diff --git a/server/src/Controllers/HdmiVideoStreamController.cs b/server/src/Controllers/HdmiVideoStreamController.cs new file mode 100644 index 0000000..82025a2 --- /dev/null +++ b/server/src/Controllers/HdmiVideoStreamController.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Authorization; +using System.Security.Claims; +using server.Services; +using Database; + +namespace server.Controllers; + +[ApiController] +[Route("api/[controller]")] +[EnableCors("Users")] +public class HdmiVideoStreamController : ControllerBase +{ + private readonly HttpHdmiVideoStreamService _videoStreamService; + private readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); + + public HdmiVideoStreamController(HttpHdmiVideoStreamService videoStreamService) + { + _videoStreamService = videoStreamService; + } + + // 管理员获取所有板子的 endpoints + [HttpGet("AllEndpoints")] + [Authorize("Admin")] + public ActionResult> GetAllEndpoints() + { + var endpoints = _videoStreamService.GetAllVideoEndpoints(); + if (endpoints == null) + return NotFound("No boards found."); + return Ok(endpoints); + } + + // 用户获取自己板子的 endpoint + [HttpGet("MyEndpoint")] + [Authorize] + public ActionResult GetMyEndpoint() + { + var userName = User.FindFirstValue(ClaimTypes.Name); + if (string.IsNullOrEmpty(userName)) + return Unauthorized("User name not found in claims."); + + var db = new AppDataConnection(); + if (db == null) + return NotFound("Database connection failed."); + + var userRet = db.GetUserByName(userName); + if (!userRet.IsSuccessful || !userRet.Value.HasValue) + return NotFound("User not found."); + + var user = userRet.Value.Value; + var boardId = user.BoardID; + if (boardId == Guid.Empty) + return NotFound("No board bound to this user."); + + var boardRet = db.GetBoardByID(boardId); + if (!boardRet.IsSuccessful || !boardRet.Value.HasValue) + return NotFound("Board not found."); + + var endpoint = _videoStreamService.GetVideoEndpoint(boardId.ToString()); + return Ok(endpoint); + } +} diff --git a/server/src/Peripherals/HdmiInClient.cs b/server/src/Peripherals/HdmiInClient.cs index 4fd6e35..1c1090f 100644 --- a/server/src/Peripherals/HdmiInClient.cs +++ b/server/src/Peripherals/HdmiInClient.cs @@ -32,14 +32,16 @@ class HdmiIn /// /// HDMI输入设备IP地址 /// HDMI输入设备端口 + /// 任务ID /// 超时时间(毫秒) - public HdmiIn(string address, int port, int timeout = 500) + public HdmiIn(string address, int port, int taskID, int timeout = 500) { if (timeout < 0) throw new ArgumentException("Timeout couldn't be negative", nameof(timeout)); this.address = address; this.port = port; this.ep = new IPEndPoint(IPAddress.Parse(address), port); + this.taskID = taskID; this.timeout = timeout; } diff --git a/server/src/Services/HttpHdmiVideoStreamService.cs b/server/src/Services/HttpHdmiVideoStreamService.cs new file mode 100644 index 0000000..d1fbbdf --- /dev/null +++ b/server/src/Services/HttpHdmiVideoStreamService.cs @@ -0,0 +1,234 @@ +using System.Net; +using System.Collections.Concurrent; +using Peripherals.HdmiInClient; + +namespace server.Services; + +public class HdmiVideoStreamEndpoint +{ + public string BoardId { get; set; } = ""; + public string MjpegUrl { get; set; } = ""; + public string VideoUrl { get; set; } = ""; + public string SnapshotUrl { get; set; } = ""; +} + +public class HttpHdmiVideoStreamService : BackgroundService +{ + private readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); + private HttpListener? _httpListener; + private readonly int _serverPort = 4322; + private readonly ConcurrentDictionary _hdmiInDict = new(); + private bool _isEnabled = true; + + public override async Task StartAsync(CancellationToken cancellationToken) + { + _httpListener = new HttpListener(); + _httpListener.Prefixes.Add($"http://*:{_serverPort}/"); + _httpListener.Start(); + logger.Info($"HDMI Video Stream Service started on port {_serverPort}"); + + await base.StartAsync(cancellationToken); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + while (!stoppingToken.IsCancellationRequested) + { + HttpListenerContext? context = null; + try + { + context = await _httpListener.GetContextAsync(); + } + catch (ObjectDisposedException) + { + // Listener closed, exit loop + break; + } + catch (HttpListenerException) + { + // Listener closed, exit loop + break; + } + catch (Exception ex) + { + logger.Error(ex, "Error in GetContextAsync"); + break; + } + if (context != null) + _ = HandleRequestAsync(context, stoppingToken); + } + } + finally + { + _httpListener?.Close(); + logger.Info("HDMI Video Stream Service stopped."); + } + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + logger.Info("Stopping HDMI Video Stream Service..."); + _isEnabled = false; + _httpListener?.Close(); // 立即关闭监听器,唤醒阻塞 + await base.StopAsync(cancellationToken); + } + + // 获取/创建 HdmiIn 实例 + private HdmiIn? GetOrCreateHdmiIn(string boardId) + { + if (_hdmiInDict.TryGetValue(boardId, out var hdmiIn)) + return hdmiIn; + + var db = new Database.AppDataConnection(); + if (db == null) + { + logger.Error("Failed to create HdmiIn instance"); + return null; + } + + var boardRet = db.GetBoardByID(Guid.Parse(boardId)); + if (!boardRet.IsSuccessful || !boardRet.Value.HasValue) + { + logger.Error($"Failed to get board with ID {boardId}"); + return null; + } + + var board = boardRet.Value.Value; + + hdmiIn = new HdmiIn(board.IpAddr, board.Port, 0); // taskID 可根据实际需求调整 + _hdmiInDict[boardId] = hdmiIn; + return hdmiIn; + } + + private async Task HandleRequestAsync(HttpListenerContext context, CancellationToken cancellationToken) + { + var path = context.Request.Url?.AbsolutePath ?? "/"; + var boardId = context.Request.QueryString["boardId"]; + if (string.IsNullOrEmpty(boardId)) + { + await SendErrorAsync(context.Response, "Missing boardId"); + return; + } + + var hdmiIn = GetOrCreateHdmiIn(boardId); + if (hdmiIn == null) + { + await SendErrorAsync(context.Response, "Invalid boardId or board not available"); + return; + } + + if (path == "/snapshot") + { + await HandleSnapshotRequestAsync(context.Response, hdmiIn, cancellationToken); + } + else if (path == "/mjpeg") + { + await HandleMjpegStreamAsync(context.Response, hdmiIn, cancellationToken); + } + else if (path == "/video") + { + await SendVideoHtmlPageAsync(context.Response, boardId); + } + else + { + await SendIndexHtmlPageAsync(context.Response, boardId); + } + } + + private async Task HandleSnapshotRequestAsync(HttpListenerResponse response, HdmiIn hdmiIn, CancellationToken cancellationToken) + { + var frameResult = await hdmiIn.ReadFrame(); + if (frameResult.IsSuccessful) + { + response.ContentType = "image/jpeg"; + await response.OutputStream.WriteAsync(frameResult.Value, 0, frameResult.Value.Length, cancellationToken); + } + else + { + response.StatusCode = 500; + await response.OutputStream.WriteAsync(System.Text.Encoding.UTF8.GetBytes("Failed to get snapshot")); + } + response.Close(); + } + + private async Task HandleMjpegStreamAsync(HttpListenerResponse response, HdmiIn hdmiIn, CancellationToken cancellationToken) + { + response.ContentType = "multipart/x-mixed-replace; boundary=--frame"; + while (!cancellationToken.IsCancellationRequested && _isEnabled) + { + var frameResult = await hdmiIn.ReadFrame(); + if (frameResult.IsSuccessful) + { + var header = $"--frame\r\nContent-Type: image/jpeg\r\nContent-Length: {frameResult.Value.Length}\r\n\r\n"; + await response.OutputStream.WriteAsync(System.Text.Encoding.ASCII.GetBytes(header)); + await response.OutputStream.WriteAsync(frameResult.Value, 0, frameResult.Value.Length, cancellationToken); + await response.OutputStream.WriteAsync(System.Text.Encoding.ASCII.GetBytes("\r\n")); + } + await Task.Delay(33, cancellationToken); // ~30fps + } + response.Close(); + } + + private async Task SendVideoHtmlPageAsync(HttpListenerResponse response, string boardId) + { + string html = $@" +

HDMI Video Stream for Board {boardId}

+ + "; + response.ContentType = "text/html"; + await response.OutputStream.WriteAsync(System.Text.Encoding.UTF8.GetBytes(html)); + response.Close(); + } + + private async Task SendIndexHtmlPageAsync(HttpListenerResponse response, string boardId) + { + string html = $@" +

Welcome to HDMI Video Stream Service

+ View Video Stream for Board {boardId} + "; + response.ContentType = "text/html"; + await response.OutputStream.WriteAsync(System.Text.Encoding.UTF8.GetBytes(html)); + response.Close(); + } + + private async Task SendErrorAsync(HttpListenerResponse response, string message) + { + response.StatusCode = 400; + await response.OutputStream.WriteAsync(System.Text.Encoding.UTF8.GetBytes(message)); + response.Close(); + } + + public List? GetAllVideoEndpoints() + { + var db = new Database.AppDataConnection(); + var boards = db?.GetAllBoard(); + if (boards == null) + return null; + + var endpoints = new List(); + foreach (var board in boards) + { + endpoints.Add(new HdmiVideoStreamEndpoint + { + BoardId = board.ID.ToString(), + MjpegUrl = $"http://{Global.localhost}:{_serverPort}/mjpeg?boardId={board.ID}", + VideoUrl = $"http://{Global.localhost}:{_serverPort}/video?boardId={board.ID}", + SnapshotUrl = $"http://{Global.localhost}:{_serverPort}/snapshot?boardId={board.ID}" + }); + } + return endpoints; + } + + public HdmiVideoStreamEndpoint GetVideoEndpoint(string boardId) + { + return new HdmiVideoStreamEndpoint + { + BoardId = boardId, + MjpegUrl = $"http://{Global.localhost}:{_serverPort}/mjpeg?boardId={boardId}", + VideoUrl = $"http://{Global.localhost}:{_serverPort}/video?boardId={boardId}", + SnapshotUrl = $"http://{Global.localhost}:{_serverPort}/snapshot?boardId={boardId}" + }; + } +} diff --git a/src/APIClient.ts b/src/APIClient.ts index e90c3c8..474a4b2 100644 --- a/src/APIClient.ts +++ b/src/APIClient.ts @@ -2944,6 +2944,123 @@ export class ExamClient { } } +export class HdmiVideoStreamClient { + protected instance: AxiosInstance; + protected baseUrl: string; + protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; + + constructor(baseUrl?: string, instance?: AxiosInstance) { + + this.instance = instance || axios.create(); + + this.baseUrl = baseUrl ?? "http://127.0.0.1:5000"; + + } + + getAllEndpoints( cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/HdmiVideoStream/AllEndpoints"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: AxiosRequestConfig = { + method: "GET", + url: url_, + headers: { + "Accept": "application/json" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processGetAllEndpoints(_response); + }); + } + + protected processGetAllEndpoints(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200) { + const _responseText = response.data; + let result200: any = null; + let resultData200 = _responseText; + if (Array.isArray(resultData200)) { + result200 = [] as any; + for (let item of resultData200) + result200!.push(HdmiVideoStreamEndpoint.fromJS(item)); + } + else { + result200 = null; + } + return Promise.resolve(result200); + + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + getMyEndpoint( cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/HdmiVideoStream/MyEndpoint"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: AxiosRequestConfig = { + method: "GET", + url: url_, + headers: { + "Accept": "application/json" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processGetMyEndpoint(_response); + }); + } + + protected processGetMyEndpoint(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200) { + const _responseText = response.data; + let result200: any = null; + let resultData200 = _responseText; + result200 = HdmiVideoStreamEndpoint.fromJS(resultData200); + return Promise.resolve(result200); + + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } +} + export class JtagClient { protected instance: AxiosInstance; protected baseUrl: string; @@ -8019,6 +8136,54 @@ export interface ICreateExamRequest { isVisibleToUsers: boolean; } +export class HdmiVideoStreamEndpoint implements IHdmiVideoStreamEndpoint { + boardId!: string; + mjpegUrl!: string; + videoUrl!: string; + snapshotUrl!: string; + + constructor(data?: IHdmiVideoStreamEndpoint) { + if (data) { + for (var property in data) { + if (data.hasOwnProperty(property)) + (this)[property] = (data)[property]; + } + } + } + + init(_data?: any) { + if (_data) { + this.boardId = _data["boardId"]; + this.mjpegUrl = _data["mjpegUrl"]; + this.videoUrl = _data["videoUrl"]; + this.snapshotUrl = _data["snapshotUrl"]; + } + } + + static fromJS(data: any): HdmiVideoStreamEndpoint { + data = typeof data === 'object' ? data : {}; + let result = new HdmiVideoStreamEndpoint(); + result.init(data); + return result; + } + + toJSON(data?: any) { + data = typeof data === 'object' ? data : {}; + data["boardId"] = this.boardId; + data["mjpegUrl"] = this.mjpegUrl; + data["videoUrl"] = this.videoUrl; + data["snapshotUrl"] = this.snapshotUrl; + return data; + } +} + +export interface IHdmiVideoStreamEndpoint { + boardId: string; + mjpegUrl: string; + videoUrl: string; + snapshotUrl: string; +} + /** 逻辑分析仪运行状态枚举 */ export enum CaptureStatus { None = 0,