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

632 lines
26 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;
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 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.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)
{
_webClients[clientId] = DateTime.UtcNow;
_hadWebClient = true;
_noClientSinceUtc = DateTime.MinValue;
}
}
private void RemoveWebClient(string clientId)
{
if (string.IsNullOrWhiteSpace(clientId)) return;
lock (_stateLock)
{
_webClients.Remove(clientId);
if (_hadWebClient && _webClients.Count == 0 && _noClientSinceUtc == DateTime.MinValue)
_noClientSinceUtc = DateTime.UtcNow;
}
}
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 ((now - item.Value).TotalSeconds >= WebClientTimeoutSeconds)
expiredClients.Add(item.Key);
}
foreach (string clientId in expiredClients) _webClients.Remove(clientId);
if (_webClients.Count > 0)
{
_noClientSinceUtc = DateTime.MinValue;
}
else if (_hadWebClient)
{
if (_noClientSinceUtc == DateTime.MinValue) _noClientSinceUtc = now;
shouldStop = (now - _noClientSinceUtc).TotalMilliseconds >= LastClientGraceMilliseconds;
}
}
if (shouldStop)
{
WriteSystemLog("最后一个串口网页已关闭,正在停止日志采集并释放串口。");
_stopping = true;
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/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 无法自定义请求头,因此仅离页通知接口允许在查询参数中携带会话令牌。
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;
lock (_stateLock)
{
lastError = _lastError;
lastReceive = _lastReceiveUtc;
activeWebClients = _webClients.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) + "," +
"\"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;
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();
}
}
}