Files
embedded-app-pc-debug/scripts/SerialWebConsoleHost.cs
T

721 lines
31 KiB
C#
Raw Permalink 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;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.IO.Ports;
using System.Net;
using System.Text;
using System.Threading;
namespace EmbeddedAppPcDebug
{
/// <summary>
/// 在单一进程内独占串口,同时提供只允许本机访问的日志与命令接口。
/// </summary>
public sealed class SerialWebConsoleHost : IDisposable
{
private const int WebClientTimeoutSeconds = 3;
private const int LastClientGraceMilliseconds = 2000;
private readonly SerialPort _serialPort;
private readonly Encoding _encoding;
private readonly Decoder _decoder;
private readonly string _logPath;
private readonly string _rawPath;
private readonly string _htmlTemplate;
private readonly string _listenPrefix;
private readonly string _sessionToken;
private readonly int _readBufferSize;
private readonly object _writeLock = new object();
private readonly object _logLock = new object();
private readonly object _stateLock = new object();
private readonly Dictionary<string, DateTime> _webClients = new Dictionary<string, DateTime>(StringComparer.Ordinal);
private readonly Dictionary<string, HttpListenerResponse> _liveWebClients = new Dictionary<string, HttpListenerResponse>(StringComparer.Ordinal);
private HttpListener _listener;
private FileStream _rawStream;
private StreamWriter _textWriter;
private Thread _receiveThread;
private Thread _httpThread;
private Thread _clientMonitorThread;
private volatile bool _stopping;
private volatile bool _started;
private bool _hadWebClient;
private DateTime _noClientSinceUtc = DateTime.MinValue;
private string _lastError = string.Empty;
private string _pendingLine = string.Empty;
private long _receivedBytes;
private DateTime _lastReceiveUtc = DateTime.MinValue;
public SerialWebConsoleHost(
string portName,
int baudRate,
int dataBits,
string parity,
string stopBits,
string handshake,
string encodingName,
int readTimeoutMilliseconds,
int readBufferSize,
bool dtrEnable,
bool rtsEnable,
string logPath,
string rawPath,
int webPort,
string htmlPath)
{
if (string.IsNullOrWhiteSpace(portName)) throw new ArgumentException("串口名称不能为空。", "portName");
if (string.IsNullOrWhiteSpace(logPath)) throw new ArgumentException("文本日志路径不能为空。", "logPath");
if (string.IsNullOrWhiteSpace(rawPath)) throw new ArgumentException("原始日志路径不能为空。", "rawPath");
if (string.IsNullOrWhiteSpace(htmlPath) || !File.Exists(htmlPath)) throw new FileNotFoundException("找不到串口控制台页面。", htmlPath);
_encoding = Encoding.GetEncoding(encodingName);
_decoder = _encoding.GetDecoder();
_logPath = Path.GetFullPath(logPath);
_rawPath = Path.GetFullPath(rawPath);
_htmlTemplate = File.ReadAllText(htmlPath, new UTF8Encoding(false));
_listenPrefix = "http://127.0.0.1:" + webPort.ToString(CultureInfo.InvariantCulture) + "/";
_sessionToken = Guid.NewGuid().ToString("N");
_readBufferSize = readBufferSize;
_serialPort = new SerialPort();
_serialPort.PortName = portName;
_serialPort.BaudRate = baudRate;
_serialPort.DataBits = dataBits;
_serialPort.Parity = (Parity)Enum.Parse(typeof(Parity), parity, true);
_serialPort.StopBits = (StopBits)Enum.Parse(typeof(StopBits), stopBits, true);
_serialPort.Handshake = (Handshake)Enum.Parse(typeof(Handshake), handshake, true);
_serialPort.DtrEnable = dtrEnable;
_serialPort.RtsEnable = rtsEnable;
_serialPort.ReadTimeout = readTimeoutMilliseconds;
_serialPort.ReadBufferSize = Math.Max(4096, readBufferSize);
}
public bool IsStopping { get { return _stopping; } }
public string Url { get { return _listenPrefix; } }
public void Start()
{
if (_started) throw new InvalidOperationException("串口网页服务已经启动。");
string logDirectory = Path.GetDirectoryName(_logPath);
string rawDirectory = Path.GetDirectoryName(_rawPath);
if (!string.IsNullOrEmpty(logDirectory)) Directory.CreateDirectory(logDirectory);
if (!string.IsNullOrEmpty(rawDirectory)) Directory.CreateDirectory(rawDirectory);
try
{
FileStream textStream = new FileStream(_logPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite);
_textWriter = new StreamWriter(textStream, new UTF8Encoding(false));
_textWriter.AutoFlush = true;
_rawStream = new FileStream(_rawPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite);
_serialPort.Open();
WriteSystemLog(string.Format(
CultureInfo.InvariantCulture,
"已打开 {0},{1} {2}{3}{4},流控 {5};网页控制台:{6};原始数据:{7}",
_serialPort.PortName,
_serialPort.BaudRate,
_serialPort.DataBits,
_serialPort.Parity.ToString().Substring(0, 1),
_serialPort.StopBits == StopBits.One ? "1" : (_serialPort.StopBits == StopBits.Two ? "2" : "1.5"),
_serialPort.Handshake,
_listenPrefix,
_rawPath));
_listener = new HttpListener();
// 必须收到页面连接断开的写异常,不能吞掉后把已关闭的页面永久计为在线。
_listener.IgnoreWriteExceptions = false;
_listener.Prefixes.Add(_listenPrefix);
_listener.Start();
_stopping = false;
_started = true;
_receiveThread = StartBackgroundThread(ReceiveLoop, "SerialWebConsole-RX");
_httpThread = StartBackgroundThread(HttpLoop, "SerialWebConsole-HTTP");
_clientMonitorThread = StartBackgroundThread(WebClientMonitorLoop, "SerialWebConsole-WebClient");
}
catch
{
Stop();
throw;
}
}
private static Thread StartBackgroundThread(ThreadStart action, string name)
{
Thread thread = new Thread(action);
thread.IsBackground = true;
thread.Name = name;
thread.Start();
return thread;
}
private void ReceiveLoop()
{
byte[] byteBuffer = new byte[_readBufferSize];
char[] charBuffer = new char[_encoding.GetMaxCharCount(_readBufferSize)];
while (!_stopping)
{
try
{
int count = _serialPort.Read(byteBuffer, 0, byteBuffer.Length);
if (count <= 0) continue;
lock (_logLock)
{
_rawStream.Write(byteBuffer, 0, count);
_rawStream.Flush();
}
int charCount = _decoder.GetChars(byteBuffer, 0, count, charBuffer, 0, false);
if (charCount > 0)
{
string text = new string(charBuffer, 0, charCount);
Console.Write(text);
WriteReceivedText(text);
}
Interlocked.Add(ref _receivedBytes, count);
lock (_stateLock)
{
_lastReceiveUtc = DateTime.UtcNow;
}
}
catch (TimeoutException)
{
// 读取超时用于让线程定期检查退出状态,不属于串口错误。
}
catch (Exception ex)
{
if (!_stopping)
{
SetFatalError("串口读取失败:" + ex.Message);
}
}
}
}
private void WriteReceivedText(string text)
{
lock (_logLock)
{
_pendingLine += text;
while (true)
{
int newlineIndex = _pendingLine.IndexOf('\n');
if (newlineIndex < 0) break;
string line = _pendingLine.Substring(0, newlineIndex).TrimEnd('\r');
_pendingLine = _pendingLine.Substring(newlineIndex + 1);
_textWriter.WriteLine("[" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture) + "] " + line);
}
}
}
private void WriteCommand(string text, string ending)
{
string terminator;
switch ((ending ?? string.Empty).ToUpperInvariant())
{
case "NONE": terminator = string.Empty; break;
case "CR": terminator = "\r"; break;
case "LF": terminator = "\n"; break;
case "CRLF": terminator = "\r\n"; break;
default: throw new ArgumentException("换行方式必须是 None、CR、LF 或 CRLF。", "ending");
}
string payload = (text ?? string.Empty) + terminator;
WriteBytes(_encoding.GetBytes(payload));
WriteTransmitLog(payload, "命令");
}
private void WriteTransmitLog(string text, string source)
{
// 将不可见的行尾转成明确标记,空命令回车会显示为 <CR>。
StringBuilder visible = new StringBuilder();
foreach (char c in text ?? string.Empty)
{
switch (c)
{
case '\r': visible.Append("<CR>"); break;
case '\n': visible.Append("<LF>"); break;
case '\t': visible.Append("<TAB>"); break;
default:
if (c < 32) visible.Append("<0x" + ((int)c).ToString("X2", CultureInfo.InvariantCulture) + ">");
else visible.Append(c);
break;
}
}
if (visible.Length == 0) visible.Append("<EMPTY>");
string line = "[" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture) + "] [TX:" + source + "] " + visible;
Console.WriteLine(line);
lock (_logLock)
{
if (_textWriter != null) _textWriter.WriteLine(line);
}
}
private void WriteBytes(byte[] bytes)
{
if (bytes == null || bytes.Length == 0) return;
lock (_writeLock)
{
if (_stopping || !_serialPort.IsOpen) throw new IOException("串口当前未打开。");
_serialPort.BaseStream.Write(bytes, 0, bytes.Length);
_serialPort.BaseStream.Flush();
}
}
private void TouchWebClient(string clientId)
{
if (string.IsNullOrWhiteSpace(clientId) || clientId.Length > 128) return;
lock (_stateLock)
{
if (_stopping) return;
_webClients[clientId] = DateTime.UtcNow;
_hadWebClient = true;
_noClientSinceUtc = DateTime.MinValue;
}
}
private void RemoveWebClient(string clientId)
{
RemoveWebClient(clientId, null, "网页主动离页");
}
private void RemoveWebClient(string clientId, HttpListenerResponse expectedResponse, string reason)
{
if (string.IsNullOrWhiteSpace(clientId)) return;
HttpListenerResponse liveResponse;
bool removed;
lock (_stateLock)
{
_liveWebClients.TryGetValue(clientId, out liveResponse);
// EventSource 重连可能先建立新连接,旧连接的 finally 不能删除新连接。
if (expectedResponse != null && !object.ReferenceEquals(liveResponse, expectedResponse)) return;
_liveWebClients.Remove(clientId);
removed = _webClients.Remove(clientId);
if (_hadWebClient && _webClients.Count == 0 && _noClientSinceUtc == DateTime.MinValue)
_noClientSinceUtc = DateTime.UtcNow;
}
if (liveResponse != null) { try { liveResponse.Abort(); } catch { } }
if (removed && !_stopping) WriteSystemLog(reason + ";客户端 " + clientId);
}
private void SendClientLiveness(HttpListenerContext context)
{
string clientId = context.Request.QueryString["client"];
if (string.IsNullOrWhiteSpace(clientId) || clientId.Length > 128)
throw new ArgumentException("页面客户端标识无效。");
HttpListenerResponse response = context.Response;
HttpListenerResponse previousResponse;
lock (_stateLock)
{
if (_stopping) throw new IOException("串口网页服务正在结束。");
_liveWebClients.TryGetValue(clientId, out previousResponse);
_liveWebClients[clientId] = response;
_webClients[clientId] = DateTime.UtcNow;
_hadWebClient = true;
_noClientSinceUtc = DateTime.MinValue;
}
if (previousResponse != null) { try { previousResponse.Abort(); } catch { } }
try
{
response.StatusCode = 200;
response.ContentType = "text/event-stream; charset=utf-8";
response.Headers["Cache-Control"] = "no-store";
response.SendChunked = true;
byte[] heartbeat = Encoding.UTF8.GetBytes("retry: 500\n: page connection alive\n\n");
// 原生 EventSource 持续连接不依赖页面 setInterval。即使页面定时器被节流,
// 仍由服务端每秒探测一次连接;页面关闭/崩溃时,写失败后释放该页面租约。
while (!_stopping)
{
lock (_stateLock)
{
HttpListenerResponse current;
if (!_liveWebClients.TryGetValue(clientId, out current) || !object.ReferenceEquals(current, response)) break;
}
response.OutputStream.Write(heartbeat, 0, heartbeat.Length);
response.OutputStream.Flush();
Thread.Sleep(1000);
}
}
catch (HttpListenerException) { /* 断开的网页连接是正常生命周期事件,不是串口故障。 */ }
catch (IOException) { }
catch (ObjectDisposedException) { }
finally
{
RemoveWebClient(clientId, response, "网页持续连接已断开(未收到主动离页通知)");
try { response.Abort(); } catch { }
}
}
private void WebClientMonitorLoop()
{
while (!_stopping)
{
bool shouldStop = false;
lock (_stateLock)
{
DateTime now = DateTime.UtcNow;
List<string> expiredClients = new List<string>();
foreach (KeyValuePair<string, DateTime> item in _webClients)
{
if (!_liveWebClients.ContainsKey(item.Key) && (now - item.Value).TotalSeconds >= WebClientTimeoutSeconds)
expiredClients.Add(item.Key);
}
foreach (string clientId in expiredClients)
{
_webClients.Remove(clientId);
WriteSystemLog("网页轮询心跳超时(不能据此断定页面已关闭);客户端 " + clientId);
}
if (_webClients.Count > 0)
{
_noClientSinceUtc = DateTime.MinValue;
}
else if (_hadWebClient)
{
if (_noClientSinceUtc == DateTime.MinValue) _noClientSinceUtc = now;
shouldStop = (now - _noClientSinceUtc).TotalMilliseconds >= LastClientGraceMilliseconds;
// 判定和置位必须在同一把锁内,防止刷新页面刚接管就被旧判定停止。
if (shouldStop) _stopping = true;
}
}
if (shouldStop)
{
WriteSystemLog("所有串口网页连接已结束,宽限期内无页面接管;正在停止日志采集并释放串口。");
try { if (_listener != null) _listener.Stop(); } catch { }
break;
}
Thread.Sleep(250);
}
}
private void HttpLoop()
{
while (!_stopping)
{
try
{
HttpListenerContext context = _listener.GetContext();
ThreadPool.QueueUserWorkItem(delegate { HandleHttpRequest(context); });
}
catch (HttpListenerException)
{
if (!_stopping) SetFatalError("本地网页服务异常停止。");
}
catch (ObjectDisposedException)
{
// 服务关闭时 GetContext 会退出。
}
catch (Exception ex)
{
if (!_stopping) SetFatalError("本地网页服务失败:" + ex.Message);
}
}
}
private void HandleHttpRequest(HttpListenerContext context)
{
try
{
if (context.Request.RemoteEndPoint == null || !IPAddress.IsLoopback(context.Request.RemoteEndPoint.Address))
{
SendTextResponse(context, 403, "text/plain; charset=utf-8", "只允许本机访问。");
return;
}
string path = context.Request.Url == null ? "/" : context.Request.Url.AbsolutePath;
string method = context.Request.HttpMethod.ToUpperInvariant();
if (method == "GET" && (path == "/" || path == "/index.html"))
{
string html = _htmlTemplate
.Replace("__SESSION_TOKEN__", _sessionToken)
.Replace("__SERIAL_PORT__", WebUtility.HtmlEncode(_serialPort.PortName));
SendTextResponse(context, 200, "text/html; charset=utf-8", html);
return;
}
if (method == "GET" && path == "/api/status")
{
TouchWebClient(context.Request.QueryString["client"]);
SendTextResponse(context, 200, "application/json; charset=utf-8", BuildStatusJson());
return;
}
if (method == "GET" && path == "/api/client/live")
{
ValidateDisconnectRequest(context);
SendClientLiveness(context);
return;
}
if (method == "GET" && path == "/api/log")
{
TouchWebClient(context.Request.QueryString["client"]);
SendLogTail(context, 2 * 1024 * 1024);
return;
}
if (method == "GET" && path == "/favicon.ico")
{
context.Response.StatusCode = 204;
context.Response.Close();
return;
}
if (method == "POST")
{
if (path == "/api/send")
{
ValidateCommandRequest(context);
string ending = context.Request.QueryString["ending"] ?? "CR";
string command = ReadRequestBody(context.Request, 16384);
WriteCommand(command, ending);
SendTextResponse(context, 200, "application/json; charset=utf-8", "{\"status\":\"written\"}");
return;
}
if (path == "/api/client/disconnect")
{
ValidateDisconnectRequest(context);
RemoveWebClient(context.Request.QueryString["client"]);
SendTextResponse(context, 200, "application/json; charset=utf-8", "{\"status\":\"disconnected\"}");
return;
}
}
SendTextResponse(context, 404, "text/plain; charset=utf-8", "未找到接口。");
}
catch (ArgumentException ex)
{
SendTextResponse(context, 400, "application/json; charset=utf-8", "{\"error\":\"" + JsonEscape(ex.Message) + "\"}");
}
catch (UnauthorizedAccessException ex)
{
SendTextResponse(context, 403, "application/json; charset=utf-8", "{\"error\":\"" + JsonEscape(ex.Message) + "\"}");
}
catch (Exception ex)
{
lock (_stateLock) _lastError = ex.Message;
SendTextResponse(context, 503, "application/json; charset=utf-8", "{\"error\":\"" + JsonEscape(ex.Message) + "\"}");
}
}
private void ValidateCommandRequest(HttpListenerContext context)
{
string token = context.Request.Headers["X-Serial-Token"];
if (!string.Equals(token, _sessionToken, StringComparison.Ordinal))
throw new UnauthorizedAccessException("本地会话令牌无效。");
string origin = context.Request.Headers["Origin"];
if (!string.IsNullOrWhiteSpace(origin) &&
!string.Equals(origin.TrimEnd('/') + "/", _listenPrefix, StringComparison.OrdinalIgnoreCase))
throw new UnauthorizedAccessException("请求来源不是当前串口控制台。");
}
private void ValidateDisconnectRequest(HttpListenerContext context)
{
// sendBeacon / EventSource 无法自定义请求头,仅页面生命周期接口允许查询令牌。
string token = context.Request.Headers["X-Serial-Token"];
if (string.IsNullOrWhiteSpace(token)) token = context.Request.QueryString["token"];
if (!string.Equals(token, _sessionToken, StringComparison.Ordinal))
throw new UnauthorizedAccessException("本地会话令牌无效。");
string origin = context.Request.Headers["Origin"];
if (!string.IsNullOrWhiteSpace(origin) &&
!string.Equals(origin.TrimEnd('/') + "/", _listenPrefix, StringComparison.OrdinalIgnoreCase))
throw new UnauthorizedAccessException("请求来源不是当前串口控制台。");
}
private static string ReadRequestBody(HttpListenerRequest request, int maxCharacters)
{
Encoding bodyEncoding = request.ContentEncoding ?? Encoding.UTF8;
using (StreamReader reader = new StreamReader(request.InputStream, bodyEncoding, true, 1024, false))
{
char[] buffer = new char[maxCharacters + 1];
int total = 0;
while (total < buffer.Length)
{
int count = reader.Read(buffer, total, buffer.Length - total);
if (count <= 0) break;
total += count;
}
if (total > maxCharacters) throw new ArgumentException("单次命令不能超过 16384 个字符。");
return new string(buffer, 0, total);
}
}
private void SendLogTail(HttpListenerContext context, int maxBytes)
{
byte[] data;
using (FileStream stream = new FileStream(_logPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
long start = Math.Max(0, stream.Length - maxBytes);
stream.Position = start;
data = new byte[stream.Length - start];
int offset = 0;
while (offset < data.Length)
{
int count = stream.Read(data, offset, data.Length - offset);
if (count <= 0) break;
offset += count;
}
if (offset != data.Length) Array.Resize(ref data, offset);
}
context.Response.StatusCode = 200;
context.Response.ContentType = "text/plain; charset=utf-8";
context.Response.Headers["Cache-Control"] = "no-store";
context.Response.ContentLength64 = data.Length;
context.Response.OutputStream.Write(data, 0, data.Length);
context.Response.Close();
}
private string BuildStatusJson()
{
string lastError;
DateTime lastReceive;
int activeWebClients;
int liveWebClients;
lock (_stateLock)
{
lastError = _lastError;
lastReceive = _lastReceiveUtc;
activeWebClients = _webClients.Count;
liveWebClients = _liveWebClients.Count;
}
return "{" +
"\"port\":\"" + JsonEscape(_serialPort.PortName) + "\"," +
"\"isOpen\":" + (_serialPort.IsOpen && !_stopping ? "true" : "false") + "," +
"\"receivedBytes\":" + Interlocked.Read(ref _receivedBytes).ToString(CultureInfo.InvariantCulture) + "," +
"\"lastReceiveUtc\":\"" + (lastReceive == DateTime.MinValue ? string.Empty : lastReceive.ToString("o", CultureInfo.InvariantCulture)) + "\"," +
"\"activeWebClients\":" + activeWebClients.ToString(CultureInfo.InvariantCulture) + "," +
"\"liveWebClients\":" + liveWebClients.ToString(CultureInfo.InvariantCulture) + "," +
"\"lastError\":\"" + JsonEscape(lastError) + "\"" +
"}";
}
private static string JsonEscape(string value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
StringBuilder builder = new StringBuilder(value.Length + 16);
foreach (char c in value)
{
switch (c)
{
case '\\': builder.Append("\\\\"); break;
case '"': builder.Append("\\\""); break;
case '\r': builder.Append("\\r"); break;
case '\n': builder.Append("\\n"); break;
case '\t': builder.Append("\\t"); break;
default:
if (c < 32) builder.Append("\\u" + ((int)c).ToString("x4", CultureInfo.InvariantCulture));
else builder.Append(c);
break;
}
}
return builder.ToString();
}
private static void SendTextResponse(HttpListenerContext context, int statusCode, string contentType, string text)
{
try
{
byte[] data = new UTF8Encoding(false).GetBytes(text ?? string.Empty);
context.Response.StatusCode = statusCode;
context.Response.ContentType = contentType;
context.Response.Headers["Cache-Control"] = "no-store";
context.Response.ContentLength64 = data.Length;
context.Response.OutputStream.Write(data, 0, data.Length);
context.Response.Close();
}
catch
{
try { context.Response.Abort(); } catch { }
}
}
private void SetFatalError(string message)
{
lock (_stateLock) _lastError = message;
WriteSystemLog(message);
_stopping = true;
try { if (_listener != null) _listener.Stop(); } catch { }
}
private void WriteSystemLog(string message)
{
string line = "[" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture) + "] " + message;
Console.WriteLine(line);
lock (_logLock)
{
if (_textWriter != null) _textWriter.WriteLine(line);
}
}
public void Stop()
{
_stopping = true;
List<HttpListenerResponse> liveResponses;
lock (_stateLock)
{
liveResponses = new List<HttpListenerResponse>(_liveWebClients.Values);
_liveWebClients.Clear();
_webClients.Clear();
}
foreach (HttpListenerResponse response in liveResponses) { try { response.Abort(); } catch { } }
try { if (_listener != null) _listener.Stop(); } catch { }
try { if (_serialPort.IsOpen) _serialPort.Close(); } catch { }
JoinThread(_httpThread);
JoinThread(_clientMonitorThread);
JoinThread(_receiveThread);
lock (_logLock)
{
if (_textWriter != null && !string.IsNullOrEmpty(_pendingLine))
{
_textWriter.WriteLine("[" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture) + "] " + _pendingLine.TrimEnd('\r'));
_pendingLine = string.Empty;
}
if (_textWriter != null)
{
_textWriter.WriteLine("[" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture) + "] 串口网页服务结束");
_textWriter.Dispose();
_textWriter = null;
}
if (_rawStream != null)
{
_rawStream.Dispose();
_rawStream = null;
}
}
if (_listener != null)
{
try { _listener.Close(); } catch { }
_listener = null;
}
_serialPort.Dispose();
_started = false;
}
private static void JoinThread(Thread thread)
{
if (thread == null || thread == Thread.CurrentThread) return;
try { thread.Join(1500); } catch { }
}
public void Dispose()
{
Stop();
}
}
}