FPGA_WebLab/server/src/Services/HttpVideoStreamService.cs

826 lines
30 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Net;
using System.Text;
using System.Collections.Concurrent;
using DotNext;
using DotNext.Threading;
#if USB_CAMERA
using OpenCvSharp;
#endif
namespace server.Services;
public class VideoStreamClient
{
public string? ClientId { get; set; } = string.Empty;
public bool IsEnabled { get; set; } = true;
public int FrameWidth { get; set; }
public int FrameHeight { get; set; }
public int FrameRate { get; set; }
public Peripherals.CameraClient.Camera Camera { get; set; }
public CancellationTokenSource CTS { get; set; }
public readonly AsyncReaderWriterLock Lock = new();
public VideoStreamClient(
string clientId, int width, int height, Peripherals.CameraClient.Camera camera)
{
ClientId = clientId;
FrameWidth = width;
FrameHeight = height;
FrameRate = 0;
Camera = camera;
CTS = new CancellationTokenSource();
}
}
/// <summary>
/// 表示摄像头连接状态信息
/// </summary>
public class VideoStreamEndpoint
{
public required string BoardId { get; set; } = "";
public required string MjpegUrl { get; set; } = "";
public required string VideoUrl { get; set; } = "";
public required string SnapshotUrl { get; set; } = "";
public required string HtmlUrl { get; set; } = "";
public required string UsbCameraUrl { get; set; } = "";
public required bool IsEnabled { get; set; }
/// <summary>
/// 视频流的帧率FPS
/// </summary>
public required int FrameRate { get; set; }
public int FrameWidth { get; set; }
public int FrameHeight { get; set; }
/// <summary>
/// 视频分辨率(如 640x480
/// </summary>
public string Resolution => $"{FrameWidth}x{FrameHeight}";
}
/// <summary>
/// 表示视频流服务的运行状态
/// </summary>
public class VideoStreamServiceStatus
{
/// <summary>
/// 服务是否正在运行
/// </summary>
public bool IsRunning { get; set; }
/// <summary>
/// 服务监听的端口号
/// </summary>
public int ServerPort { get; set; }
/// <summary>
/// 当前连接的客户端端点列表
/// </summary>
public List<VideoStreamEndpoint> ClientEndpoints { get; set; } = new();
/// <summary>
/// 当前连接的客户端数量
/// </summary>
public int ConnectedClientsNum => ClientEndpoints.Count;
}
/// <summary>
/// HTTP 视频流服务,用于从 FPGA 获取图像数据并推送到前端网页
/// 支持动态配置摄像头地址和端口
/// </summary>
public class HttpVideoStreamService : BackgroundService
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly Database.UserManager _userManager;
private HttpListener? _httpListener;
private readonly int _serverPort = 4321;
private readonly ConcurrentDictionary<string, VideoStreamClient> _clientDict = new();
// USB Camera 相关
#if USB_CAMERA
private VideoCapture? _usbCamera;
private bool _usbCameraEnable = false;
private readonly object _usbCameraLock = new object();
#endif
public HttpVideoStreamService(Database.UserManager userManager)
{
_userManager = userManager;
}
private Optional<VideoStreamClient> TryGetClient(string boardId)
{
if (_clientDict.TryGetValue(boardId, out var client))
{
return client;
}
return null;
}
private async Task<VideoStreamClient?> GetOrCreateClientAsync(string boardId, int initWidth, int initHeight)
{
if (_clientDict.TryGetValue(boardId, out var client))
{
// 可在此处做分辨率/Camera等配置更新
return client;
}
var boardRet = _userManager.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;
var camera = new Peripherals.CameraClient.Camera(board.IpAddr, board.Port);
var ret = await camera.Init();
if (!ret.IsSuccessful || !ret.Value)
{
logger.Error("Camera Init Failed!");
return null;
}
client = new VideoStreamClient(boardId, initWidth, initHeight, camera);
_clientDict[boardId] = client;
return client;
}
/// <summary>
/// 初始化 HttpVideoStreamService
/// </summary>
public override async Task StartAsync(CancellationToken cancellationToken)
{
_httpListener = new HttpListener();
_httpListener.Prefixes.Add($"http://{Global.LocalHost}:{_serverPort}/");
_httpListener.Start();
logger.Info($"Video Stream Service started on port {_serverPort}");
await base.StartAsync(cancellationToken);
}
/// <summary>
/// 停止 HTTP 视频流服务
/// </summary>
public override async Task StopAsync(CancellationToken cancellationToken)
{
foreach (var clientKey in _clientDict.Keys)
{
var client = _clientDict[clientKey];
client.CTS.Cancel();
using (await client.Lock.AcquireWriteLockAsync(cancellationToken))
{
await client.Camera.EnableHardwareTrans(false);
}
}
_clientDict.Clear();
await base.StopAsync(cancellationToken);
}
/// <summary>
/// 执行 HTTP 视频流服务
/// </summary>
/// <param name="stoppingToken">取消令牌</param>
/// <returns>任务</returns>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (_httpListener == null) continue;
try
{
logger.Debug("Waiting for HTTP request...");
var contextTask = _httpListener.GetContextAsync();
var completedTask = await Task.WhenAny(contextTask, Task.Delay(-1, stoppingToken));
if (completedTask == contextTask)
{
var context = contextTask.Result;
logger.Debug($"Received request: {context.Request.Url?.AbsolutePath}");
if (context != null)
_ = HandleRequestAsync(context, stoppingToken);
}
else
{
break;
}
}
catch (Exception ex)
{
logger.Error(ex, "Error in GetContextAsync");
break;
}
}
}
private async Task HandleRequestAsync(HttpListenerContext context, CancellationToken cancellationToken)
{
var path = context.Request.Url?.AbsolutePath ?? "/";
var boardId = context.Request.QueryString["board"];
var width = int.TryParse(context.Request.QueryString["width"], out var w) ? w : 640;
var height = int.TryParse(context.Request.QueryString["height"], out var h) ? h : 480;
if (string.IsNullOrEmpty(boardId))
{
await SendErrorAsync(context.Response, "Missing clientId");
return;
}
var client = await GetOrCreateClientAsync(boardId, width, height);
if (client == null)
{
await SendErrorAsync(context.Response, "Invalid clientId or camera not available");
return;
}
var clientToken = client.CTS.Token;
try
{
logger.Info("新HTTP客户端连接: {RemoteEndPoint}", context.Request.RemoteEndPoint);
if (path == "/video-stream")
{
// MJPEG 流请求FPGA
await HandleMjpegStreamAsync(context.Response, client, cancellationToken);
}
#if USB_CAMERA
else if (requestPath == "/usb-camera")
{
// USB Camera MJPEG流请求
await HandleUsbCameraStreamAsync(response, cancellationToken);
}
#endif
else if (path == "/snapshot")
{
// 单帧图像请求
await HandleSnapshotRequestAsync(context.Response, client, cancellationToken);
}
else if (path == "/html")
{
// HTML页面请求
await SendIndexHtmlPageAsync(context.Response);
}
else
{
// 默认返回简单的HTML页面提供链接到视频页面
await SendIndexHtmlPageAsync(context.Response);
}
}
catch (Exception ex)
{
logger.Error(ex, "接受HTTP客户端连接时发生错误");
}
}
private async Task SendErrorAsync(HttpListenerResponse response, string message)
{
response.StatusCode = 400;
await response.OutputStream.WriteAsync(System.Text.Encoding.UTF8.GetBytes(message));
response.Close();
}
// USB Camera MJPEG流处理
#if USB_CAMERA
private async Task HandleUsbCameraStreamAsync(HttpListenerResponse response, CancellationToken cancellationToken)
{
try
{
lock (_usbCameraLock)
{
if (_usbCamera == null)
{
_usbCamera = new VideoCapture(1);
_usbCamera.Fps = _frameRate;
_usbCamera.FrameWidth = _frameWidth;
_usbCamera.FrameHeight = _frameHeight;
_usbCameraEnable = _usbCamera.IsOpened();
}
}
if (!_usbCameraEnable || _usbCamera == null || !_usbCamera.IsOpened())
{
response.StatusCode = 500;
await response.OutputStream.FlushAsync(cancellationToken);
response.Close();
return;
}
response.ContentType = "multipart/x-mixed-replace; boundary=--boundary";
response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
response.Headers.Add("Pragma", "no-cache");
response.Headers.Add("Expires", "0");
using (var mat = new Mat())
{
while (!cancellationToken.IsCancellationRequested)
{
bool grabbed;
lock (_usbCameraLock)
{
grabbed = _usbCamera.Read(mat);
}
if (!grabbed || mat.Empty())
{
await Task.Delay(50, cancellationToken);
continue;
}
// 编码为JPEG
byte[]? jpegData = null;
try
{
jpegData = mat.ToBytes(".jpg", new int[] { (int)ImwriteFlags.JpegQuality, 80 });
}
catch (Exception ex)
{
logger.Error(ex, "USB Camera帧编码JPEG失败");
continue;
}
if (jpegData == null)
continue;
// MJPEG帧头
var header = Encoding.ASCII.GetBytes("--boundary\r\nContent-Type: image/jpeg\r\nContent-Length: " + jpegData.Length + "\r\n\r\n");
await response.OutputStream.WriteAsync(header, 0, header.Length, cancellationToken);
await response.OutputStream.WriteAsync(jpegData, 0, jpegData.Length, cancellationToken);
await response.OutputStream.WriteAsync(new byte[] { 0x0D, 0x0A }, 0, 2, cancellationToken); // \r\n
await response.OutputStream.FlushAsync(cancellationToken);
await Task.Delay(1000 / _frameRate, cancellationToken);
}
}
}
catch (Exception ex)
{
logger.Error(ex, "USB Camera MJPEG流处理异常");
}
finally
{
try { response.Close(); } catch { }
}
}
#endif
private async Task HandleSnapshotRequestAsync(HttpListenerResponse response, VideoStreamClient client, CancellationToken cancellationToken)
{
// 读取 Camera 快照,返回 JPEG
var frameResult = await client.Camera.ReadFrame();
if (!frameResult.IsSuccessful || frameResult.Value == null)
{
response.StatusCode = 500;
await response.OutputStream.WriteAsync(Encoding.UTF8.GetBytes("Failed to get snapshot"));
response.Close();
return;
}
var jpegResult = Common.Image.ConvertRGB24ToJpeg(frameResult.Value, client.FrameWidth, client.FrameHeight, 80);
if (!jpegResult.IsSuccessful)
{
response.StatusCode = 500;
await response.OutputStream.WriteAsync(Encoding.UTF8.GetBytes("JPEG conversion failed"));
response.Close();
return;
}
response.ContentType = "image/jpeg";
response.ContentLength64 = jpegResult.Value.Length;
await response.OutputStream.WriteAsync(jpegResult.Value, 0, jpegResult.Value.Length, cancellationToken);
response.Close();
}
private async Task HandleMjpegStreamAsync(HttpListenerResponse response, VideoStreamClient client, CancellationToken cancellationToken)
{
response.ContentType = "multipart/x-mixed-replace; boundary=--boundary";
response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
response.Headers.Add("Pragma", "no-cache");
response.Headers.Add("Expires", "0");
while (!cancellationToken.IsCancellationRequested)
{
var frameResult = await client.Camera.ReadFrame();
if (!frameResult.IsSuccessful || frameResult.Value == null) continue;
var jpegResult = Common.Image.ConvertRGB24ToJpeg(frameResult.Value, client.FrameWidth, client.FrameHeight, 80);
if (!jpegResult.IsSuccessful) continue;
var header = Encoding.ASCII.GetBytes("--boundary\r\nContent-Type: image/jpeg\r\nContent-Length: " + jpegResult.Value.Length + "\r\n\r\n");
await response.OutputStream.WriteAsync(header, 0, header.Length, cancellationToken);
await response.OutputStream.WriteAsync(jpegResult.Value, 0, jpegResult.Value.Length, cancellationToken);
await response.OutputStream.WriteAsync(new byte[] { 0x0D, 0x0A }, 0, 2, cancellationToken);
await response.OutputStream.FlushAsync(cancellationToken);
await Task.Delay(1000 / client.FrameWidth, cancellationToken);
}
response.Close();
}
private async Task SendVideoHtmlPageAsync(HttpListenerResponse response)
{
string html = $@"
<!DOCTYPE html>
<html>
<head>
<title>FPGA 视频流</title>
<meta charset=""utf-8"">
<style>
body {{ font-family: Arial, sans-serif; text-align: center; margin: 20px; }}
h1 {{ color: #333; }}
.video-container {{ margin: 20px auto; max-width: 800px; }}
.controls {{ margin: 10px 0; }}
img {{ max-width: 100%; border: 1px solid #ddd; }}
button {{ padding: 8px 16px; margin: 0 5px; cursor: pointer; }}
</style>
</head>
<body>
<h1>FPGA 实时视频流</h1>
<div class=""video-container"">
<img id=""videoStream"" src=""/video-stream"" alt=""FPGA视频流"" />
</div>
<div class=""controls"">
<button onclick=""document.getElementById('videoStream').src='/snapshot?t=' + new Date().getTime()"">刷新快照</button>
<button onclick=""document.getElementById('videoStream').src='/video-stream'"">开始流媒体</button>
<span id=""status"">状态: 连接中...</span>
</div>
<script>
document.getElementById('videoStream').onload = function() {{
document.getElementById('status').textContent = '状态: 已连接';
}};
document.getElementById('videoStream').onerror = function() {{
document.getElementById('status').textContent = '状态: 连接错误';
}};
</script>
</body>
</html>
";
response.ContentType = "text/html";
response.ContentEncoding = Encoding.UTF8;
byte[] buffer = Encoding.UTF8.GetBytes(html);
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
response.Close();
}
private async Task SendIndexHtmlPageAsync(HttpListenerResponse response)
{
string html = $@"
<!DOCTYPE html>
<html>
<head>
<title>FPGA WebLab 视频服务</title>
<meta charset=""utf-8"">
<style>
body {{ font-family: Arial, sans-serif; text-align: center; margin: 20px; }}
h1 {{ color: #333; }}
.links {{ margin: 20px; }}
a {{ padding: 10px 15px; background-color: #4CAF50; color: white; text-decoration: none; border-radius: 4px; margin: 5px; display: inline-block; }}
a:hover {{ background-color: #45a049; }}
</style>
</head>
<body>
<h1>FPGA WebLab 视频服务</h1>
<div class=""links"">
<a href=""/video-feed.html"">观看实时视频</a>
<a href=""/snapshot"" target=""_blank"">获取当前快照</a>
</div>
<p>HTTP流媒体服务端口: {_serverPort}</p>
</body>
</html>
";
response.ContentType = "text/html";
response.ContentEncoding = Encoding.UTF8;
byte[] buffer = Encoding.UTF8.GetBytes(html);
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
response.Close();
}
/// <summary>
/// 从 FPGA 获取图像数据
/// 实际从摄像头读取 RGB565 格式数据并转换为 RGB24
/// </summary>
private async Task<byte[]?> GetFPGAImageData(
VideoStreamClient client, CancellationToken cancellationToken = default)
{
try
{
using (await client.Lock.AcquireWriteLockAsync(cancellationToken))
{
// 从摄像头读取帧数据
var readStartTime = DateTime.UtcNow;
var result = await client.Camera.ReadFrame();
var readEndTime = DateTime.UtcNow;
var readTime = (readEndTime - readStartTime).TotalMilliseconds;
if (!result.IsSuccessful)
{
logger.Error("读取摄像头帧数据失败: {Error}", result.Error);
return new byte[0];
}
var rgb565Data = result.Value;
// 验证数据长度是否正确
if (!Common.Image.ValidateImageDataLength(rgb565Data, client.FrameWidth, client.FrameHeight, 2))
{
logger.Warn("摄像头数据长度不匹配,期望: {Expected}, 实际: {Actual}",
client.FrameWidth * client.FrameHeight * 2, rgb565Data.Length);
}
// 将 RGB565 转换为 RGB24
var convertStartTime = DateTime.UtcNow;
var rgb24Result = Common.Image.ConvertRGB565ToRGB24(rgb565Data, client.FrameWidth, client.FrameHeight, isLittleEndian: false);
var convertEndTime = DateTime.UtcNow;
var convertTime = (convertEndTime - convertStartTime).TotalMilliseconds;
if (!rgb24Result.IsSuccessful)
{
logger.Error("RGB565转RGB24失败: {Error}", rgb24Result.Error);
return new byte[0];
}
return rgb24Result.Value;
}
}
catch (Exception ex)
{
logger.Error(ex, "获取FPGA图像数据时发生错误");
return new byte[0];
}
}
/// <summary>
/// 设置视频流分辨率
/// </summary>
/// <param name="boardId">板卡ID</param>
/// <param name="width">宽度</param>
/// <param name="height">高度</param>
/// <param name="timeout">超时时间(毫秒)</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>设置结果</returns>
public async Task<Result<bool>> SetResolutionAsync(
string boardId, int width, int height,
int timeout = 100, CancellationToken cancellationToken = default)
{
try
{
var client = TryGetClient(boardId).OrThrow(() => new Exception($"无法获取摄像头客户端: {boardId}"));
using (await client.Lock.AcquireWriteLockAsync(TimeSpan.FromMilliseconds(timeout), cancellationToken))
{
var currentCamera = client.Camera;
if (currentCamera == null)
{
var message = $"获取摄像头失败";
logger.Error(message);
return new(new Exception(message));
}
// 设置摄像头分辨率
var ret = await currentCamera.ChangeResolution(width, height);
if (!ret.IsSuccessful)
{
var message = $"设置摄像头分辨率失败: {ret.Error}";
logger.Error(message);
return new(new Exception(message));
}
if (!ret.Value)
{
logger.Warn($"设置摄像头分辨率失败");
return false;
}
// 更新HTTP服务的分辨率配置
client.FrameWidth = width;
client.FrameHeight = height;
logger.Info($"视频流分辨率已成功设置为 {width}x{height}");
return true;
}
}
catch (Exception ex)
{
var message = $"设置分辨率时发生错误: {ex.Message}";
logger.Error(ex, message);
return new(new Exception(message));
}
}
/// <summary>
/// 初始化摄像头自动对焦功能
/// </summary>
/// <returns>初始化结果</returns>
public async Task<bool> InitAutoFocusAsync(
string boardId, int timeout = 1000, CancellationToken cancellationToken = default)
{
try
{
var client = TryGetClient(boardId).OrThrow(() => new Exception($"无法获取摄像头客户端: {boardId}"));
using (await client.Lock.AcquireWriteLockAsync(
TimeSpan.FromMilliseconds(timeout), cancellationToken))
{
var result = await client.Camera.InitAutoFocus();
if (result.IsSuccessful && result.Value)
{
logger.Info($"Board{boardId}摄像头自动对焦功能初始化成功");
return true;
}
else
{
logger.Error($"Board{boardId}摄像头自动对焦功能初始化失败: {result.Error?.Message ?? ""}");
return false;
}
}
}
catch (Exception ex)
{
logger.Error(ex, $"Board{boardId}初始化摄像头自动对焦功能时发生异常");
return false;
}
}
/// <summary>
/// 执行摄像头自动对焦
/// </summary>
/// <returns>对焦结果</returns>
public async Task<bool> PerformAutoFocusAsync(
string boardId, int timeout = 1000, CancellationToken cancellationToken = default)
{
try
{
var client = TryGetClient(boardId).OrThrow(() => new Exception($"无法获取摄像头客户端: {boardId}"));
logger.Info($"Board{boardId}开始执行摄像头自动对焦");
var result = await client.Camera.PerformAutoFocus();
if (result.IsSuccessful && result.Value)
{
logger.Info($"Board{boardId}摄像头自动对焦成功");
return true;
}
else
{
logger.Error($"Board{boardId}摄像头自动对焦执行失败: {result.Error?.Message ?? ""}");
return false;
}
}
catch (Exception ex)
{
logger.Error(ex, $"Board{boardId}执行摄像头自动对焦时发生异常");
return false;
}
}
/// <summary>
/// 配置摄像头连接参数
/// </summary>
/// <param name="boardId">板卡ID</param>
/// <returns>配置是否成功</returns>
public async Task<bool> ConfigureCameraAsync(string boardId)
{
try
{
var client = TryGetClient(boardId).OrThrow(() => new Exception($"无法获取摄像头客户端: {boardId}"));
using (await client.Lock.AcquireWriteLockAsync())
{
var ret = await client.Camera.Init();
if (!ret.IsSuccessful)
{
logger.Error(ret.Error);
throw ret.Error;
}
if (!ret.Value)
{
logger.Error($"Camera Init Failed!");
throw new Exception($"Camera Init Failed!");
}
}
using (await client.Lock.AcquireWriteLockAsync())
{
var ret = await client.Camera.ChangeResolution(client.FrameWidth, client.FrameHeight);
if (!ret.IsSuccessful)
{
logger.Error(ret.Error);
throw ret.Error;
}
if (!ret.Value)
{
logger.Error($"Camera Resolution Change Failed!");
throw new Exception($"Camera Resolution Change Failed!");
}
}
return true;
}
catch (Exception ex)
{
logger.Error(ex, "配置摄像头连接时发生错误");
return false;
}
}
public async Task SetVideoStreamEnableAsync(string boardId, bool enable)
{
try
{
var client = TryGetClient(boardId).OrThrow(() => new Exception($"无法获取摄像头客户端: {boardId}"));
if (client.IsEnabled == enable)
return;
using (await client.Lock.AcquireWriteLockAsync())
{
if (enable)
{
client.CTS = new CancellationTokenSource();
}
else
{
client.CTS.Cancel();
}
var camera = client.Camera;
var disableResult = await camera.EnableHardwareTrans(enable);
if (disableResult.IsSuccessful && disableResult.Value)
logger.Info($"Successfully disabled camera {boardId} hardware transmission");
else
logger.Error($"Failed to disable camera {boardId} hardware transmission: {disableResult.Error}");
}
}
catch (Exception ex)
{
logger.Error(ex, $"Exception occurred while disabling HDMI transmission for camera {boardId}");
}
}
public async ValueTask<bool> TestCameraConnection(string boardId)
{
try
{
var client = TryGetClient(boardId).OrThrow(() => new Exception($"无法获取摄像头客户端: {boardId}"));
var imageData = await GetFPGAImageData(client);
if (imageData == null || imageData.Length == 0)
return false;
return true;
}
catch (Exception ex)
{
logger.Error(ex, $"Board{boardId}执行摄像头自动对焦时发生异常");
return false;
}
}
public VideoStreamEndpoint GetVideoEndpoint(string boardId)
{
var client = TryGetClient(boardId).OrThrow(() => new Exception($"无法获取摄像头客户端: {boardId}"));
return new VideoStreamEndpoint
{
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}",
UsbCameraUrl = $"http://{Global.LocalHost}:{_serverPort}/usbCamera?boardId={boardId}",
HtmlUrl = $"http://{Global.LocalHost}:{_serverPort}/html?boardId={boardId}",
IsEnabled = client.IsEnabled,
FrameRate = client.FrameRate
};
}
public List<VideoStreamEndpoint> GetAllVideoEndpoints()
{
var endpoints = new List<VideoStreamEndpoint>();
foreach (var boardId in _clientDict.Keys)
endpoints.Add(GetVideoEndpoint(boardId));
return endpoints;
}
public VideoStreamServiceStatus GetServiceStatus()
{
return new VideoStreamServiceStatus
{
IsRunning = true,
ServerPort = _serverPort,
ClientEndpoints = GetAllVideoEndpoints()
};
}
}