feat: add embedded app PC debugging skill
This commit is contained in:
commit
23e3a4910a
12 files changed
+2818
No files matched your search
@@ -0,0 +1,631 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
#!/usr/bin/env python3
|
||||
"""设备 V2 基础信息预检与 EPFP/4001 固件升级工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import socket
|
||||
import stat
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
|
||||
INFO_PORT = 8942
|
||||
UPGRADE_PORT = 8943
|
||||
INFO_COMMAND = 30101
|
||||
UPGRADE_COMMAND = 4001
|
||||
MAX_JSON_SIZE = 1024 * 1024
|
||||
MAX_STATUS_SIZE = 1024 * 1024
|
||||
MAX_ROM_SIZE = 0xFFFFFFFF
|
||||
STATUS_SIZE = 21
|
||||
|
||||
|
||||
def configure_redirected_output() -> None:
|
||||
"""输出被管道捕获时统一使用 UTF-8,避免中文日志乱码。"""
|
||||
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if not stream.isatty() and hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8", errors="backslashreplace")
|
||||
|
||||
|
||||
configure_redirected_output()
|
||||
|
||||
|
||||
class UpgradeError(RuntimeError):
|
||||
"""表示预检、协议或升级过程失败。"""
|
||||
|
||||
|
||||
class EventLogger:
|
||||
"""同时向终端和 JSONL 文件写入带时间戳的事件。"""
|
||||
|
||||
def __init__(self, log_dir: Path) -> None:
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
self.path = log_dir / ("device-upgrade-" + stamp + ".jsonl")
|
||||
self._stream = self.path.open("a", encoding="utf-8", buffering=1)
|
||||
|
||||
def close(self) -> None:
|
||||
self._stream.close()
|
||||
|
||||
def emit(self, event: str, **fields: Any) -> None:
|
||||
record: Dict[str, Any] = {
|
||||
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"event": event,
|
||||
}
|
||||
record.update(fields)
|
||||
line = json.dumps(record, ensure_ascii=False, separators=(",", ":"))
|
||||
print(line, flush=True)
|
||||
self._stream.write(line + "\n")
|
||||
|
||||
|
||||
def recv_exact(sock: socket.socket, size: int) -> bytes:
|
||||
"""读取指定字节数;对端提前关闭时立即失败。"""
|
||||
|
||||
data = bytearray()
|
||||
while len(data) < size:
|
||||
chunk = sock.recv(size - len(data))
|
||||
if not chunk:
|
||||
raise UpgradeError("设备在响应完整之前关闭了连接")
|
||||
data.extend(chunk)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def query_device_info(host: str, timeout: float, logger: EventLogger) -> Dict[str, Any]:
|
||||
"""通过 EPTP/30101 查询并严格校验设备基础信息。"""
|
||||
|
||||
session = int(time.time() * 1000) & 0xFFFFFFFF
|
||||
request_object = {"cmd": INFO_COMMAND, "session": session}
|
||||
request = json.dumps(request_object, separators=(",", ":")).encode("utf-8")
|
||||
logger.emit("info_request", host=host, port=INFO_PORT, request=request_object)
|
||||
|
||||
with socket.create_connection((host, INFO_PORT), timeout=timeout) as sock:
|
||||
sock.settimeout(timeout)
|
||||
sock.sendall(b"EPTP" + struct.pack("!I", len(request)) + request)
|
||||
header = recv_exact(sock, 8)
|
||||
if header[:4] != b"EPTP":
|
||||
raise UpgradeError("基础信息响应同步头不是 EPTP:%r" % (header[:4],))
|
||||
payload_size = struct.unpack("!I", header[4:])[0]
|
||||
if payload_size == 0 or payload_size > MAX_JSON_SIZE:
|
||||
raise UpgradeError("基础信息响应长度不合理:%d" % payload_size)
|
||||
payload = recv_exact(sock, payload_size)
|
||||
|
||||
try:
|
||||
response = json.loads(payload.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise UpgradeError("基础信息响应不是有效 UTF-8 JSON:%s" % exc) from exc
|
||||
if not isinstance(response, dict):
|
||||
raise UpgradeError("基础信息响应 JSON 顶层不是对象")
|
||||
if response.get("cmd") != INFO_COMMAND:
|
||||
raise UpgradeError("基础信息响应 cmd 不匹配:%r" % response.get("cmd"))
|
||||
if response.get("session") != session:
|
||||
raise UpgradeError(
|
||||
"基础信息响应 session 不匹配:期望 %d,实际 %r"
|
||||
% (session, response.get("session"))
|
||||
)
|
||||
if response.get("code") != 0:
|
||||
raise UpgradeError("基础信息响应 code 非 0:%r" % response.get("code"))
|
||||
logger.emit("device_info", response=response)
|
||||
return response
|
||||
|
||||
|
||||
def response_data(response: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""取得响应 data 对象,格式错误时拒绝继续。"""
|
||||
|
||||
data = response.get("data")
|
||||
if not isinstance(data, dict):
|
||||
raise UpgradeError("基础信息响应缺少 data 对象")
|
||||
return data
|
||||
|
||||
|
||||
def collect_models(data: Dict[str, Any]) -> List[str]:
|
||||
"""收集 inner、outer 和 hardware 中可用的型号。"""
|
||||
|
||||
models: List[str] = []
|
||||
for section_name in ("inner", "outer", "hardware"):
|
||||
section = data.get(section_name)
|
||||
if isinstance(section, dict):
|
||||
value = section.get("type")
|
||||
if isinstance(value, str) and value and value not in models:
|
||||
models.append(value)
|
||||
return models
|
||||
|
||||
|
||||
def collect_versions(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""收集各固件分区和硬件版本,便于升级前后比对。"""
|
||||
|
||||
versions: Dict[str, Any] = {}
|
||||
for section_name in ("inner", "outer", "hardware"):
|
||||
section = data.get(section_name)
|
||||
if isinstance(section, dict):
|
||||
versions[section_name] = section.get("version")
|
||||
return versions
|
||||
|
||||
|
||||
def validate_identity(
|
||||
response: Dict[str, Any], expected_sn: str, expected_model: Optional[str]
|
||||
) -> Tuple[Dict[str, Any], List[str]]:
|
||||
"""对完整 SN 做强校验,并按需校验型号。"""
|
||||
|
||||
data = response_data(response)
|
||||
actual_sn = data.get("sn")
|
||||
if actual_sn != expected_sn:
|
||||
raise UpgradeError("设备 SN 不匹配:期望 %s,实际 %r" % (expected_sn, actual_sn))
|
||||
models = collect_models(data)
|
||||
if expected_model and expected_model not in models:
|
||||
raise UpgradeError(
|
||||
"设备型号不匹配:期望 %s,设备报告 %s"
|
||||
% (expected_model, models if models else "无可用型号")
|
||||
)
|
||||
return data, models
|
||||
|
||||
|
||||
def infer_rom_version(path: Path) -> Optional[str]:
|
||||
"""仅从路径中识别明确的四段数字版本号,不猜测其他命名。"""
|
||||
|
||||
candidates: List[str] = []
|
||||
pattern = re.compile(r"(?<!\d)(\d+\.\d+\.\d+\.\d+)(?!\d)")
|
||||
for part in path.parts:
|
||||
candidates.extend(pattern.findall(part))
|
||||
return candidates[-1] if candidates else None
|
||||
|
||||
|
||||
def validate_rom(path: Path) -> Dict[str, Any]:
|
||||
"""确认 ROM 为普通非空文件,并计算 SHA-256。"""
|
||||
|
||||
absolute = path.expanduser().resolve(strict=True)
|
||||
metadata = os.stat(str(absolute))
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise UpgradeError("ROM 不是普通文件:%s" % absolute)
|
||||
if metadata.st_size <= 0:
|
||||
raise UpgradeError("ROM 文件为空:%s" % absolute)
|
||||
if metadata.st_size > MAX_ROM_SIZE:
|
||||
raise UpgradeError("ROM 大于协议 uint32 长度上限:%d" % metadata.st_size)
|
||||
|
||||
digest = hashlib.sha256()
|
||||
with absolute.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return {
|
||||
"path": str(absolute),
|
||||
"size": metadata.st_size,
|
||||
"modified": time.strftime(
|
||||
"%Y-%m-%d %H:%M:%S", time.localtime(metadata.st_mtime)
|
||||
),
|
||||
"sha256": digest.hexdigest(),
|
||||
"target_version_from_path": infer_rom_version(absolute),
|
||||
}
|
||||
|
||||
|
||||
def parse_status(payload: bytes) -> Dict[str, int]:
|
||||
"""解析 21 字节升级状态业务数据。"""
|
||||
|
||||
if len(payload) < STATUS_SIZE:
|
||||
raise UpgradeError("升级状态不足 21 字节:%d" % len(payload))
|
||||
status, session, download_total, downloaded, flash_total, flashed = struct.unpack(
|
||||
"!BIIIII", payload[:STATUS_SIZE]
|
||||
)
|
||||
if status not in (0, 1, 2, 3):
|
||||
raise UpgradeError("未知升级状态值:%d" % status)
|
||||
return {
|
||||
"status": status,
|
||||
"session": session,
|
||||
"download_total": download_total,
|
||||
"downloaded": downloaded,
|
||||
"flash_total": flash_total,
|
||||
"flashed": flashed,
|
||||
}
|
||||
|
||||
|
||||
def extract_status_payloads(buffer: bytearray) -> Iterable[Tuple[str, bytes]]:
|
||||
"""从 TCP 字节流中提取完整 EPFP 帧或兼容设备实测的裸状态。"""
|
||||
|
||||
while buffer:
|
||||
# 同步头被拆包时等待后续数据,避免把半个 EPFP 当裸状态。
|
||||
if len(buffer) < 4 and b"EPFP".startswith(bytes(buffer)):
|
||||
return
|
||||
|
||||
if buffer[:4] == b"EPFP":
|
||||
if len(buffer) < 12:
|
||||
return
|
||||
command, payload_size = struct.unpack("!II", buffer[4:12])
|
||||
if command != UPGRADE_COMMAND:
|
||||
raise UpgradeError("升级状态帧 cmd 不匹配:%d" % command)
|
||||
if payload_size < STATUS_SIZE or payload_size > MAX_STATUS_SIZE:
|
||||
raise UpgradeError("升级状态帧长度不合理:%d" % payload_size)
|
||||
if len(buffer) < 12 + payload_size:
|
||||
return
|
||||
payload = bytes(buffer[12 : 12 + payload_size])
|
||||
del buffer[: 12 + payload_size]
|
||||
yield "framed", payload
|
||||
continue
|
||||
|
||||
if buffer[0] in (0, 1, 2, 3):
|
||||
# 合法裸状态内部也可能包含字节串 EPFP,不能在其业务数据中误重同步。
|
||||
if len(buffer) < STATUS_SIZE:
|
||||
return
|
||||
payload = bytes(buffer[:STATUS_SIZE])
|
||||
del buffer[:STATUS_SIZE]
|
||||
yield "raw", payload
|
||||
continue
|
||||
|
||||
sync_position = buffer.find(b"EPFP")
|
||||
if sync_position > 0:
|
||||
# 首字节不是合法 status,且后方存在同步头时,丢弃无效前缀。
|
||||
del buffer[:sync_position]
|
||||
continue
|
||||
if len(buffer) < STATUS_SIZE:
|
||||
return
|
||||
# 保留这 21 字节交给状态解析器报告明确的非法 status。
|
||||
payload = bytes(buffer[:STATUS_SIZE])
|
||||
del buffer[:STATUS_SIZE]
|
||||
yield "raw", payload
|
||||
|
||||
|
||||
def transfer_rom(
|
||||
host: str,
|
||||
rom_path: Path,
|
||||
rom_size: int,
|
||||
connect_timeout: float,
|
||||
stall_timeout: float,
|
||||
total_timeout: float,
|
||||
chunk_size: int,
|
||||
logger: EventLogger,
|
||||
) -> Dict[str, Any]:
|
||||
"""发送 ROM,并在同一循环中处理回包、停滞和终态。"""
|
||||
|
||||
result: Dict[str, Any] = {"status": None, "bytes_sent": 0}
|
||||
receive_buffer = bytearray()
|
||||
pending = bytearray(b"EPFP" + struct.pack("!II", UPGRADE_COMMAND, rom_size))
|
||||
started = time.monotonic()
|
||||
last_progress = started
|
||||
previous_progress: Optional[Tuple[int, int]] = None
|
||||
upload_complete = False
|
||||
|
||||
logger.emit(
|
||||
"upgrade_connect", host=host, port=UPGRADE_PORT, rom_size=rom_size
|
||||
)
|
||||
with socket.create_connection((host, UPGRADE_PORT), timeout=connect_timeout) as sock:
|
||||
sock.setblocking(False)
|
||||
with rom_path.open("rb") as rom:
|
||||
while True:
|
||||
now = time.monotonic()
|
||||
if now - started >= total_timeout:
|
||||
raise UpgradeError("升级超过总超时 %.1f 秒" % total_timeout)
|
||||
if now - last_progress >= stall_timeout:
|
||||
raise UpgradeError("设备下载或烧录进度连续 %.1f 秒无变化" % stall_timeout)
|
||||
|
||||
readable, writable, exceptional = select.select(
|
||||
[sock], [] if upload_complete else [sock], [sock], 0.5
|
||||
)
|
||||
if exceptional:
|
||||
raise UpgradeError("升级 TCP 连接出现异常状态")
|
||||
|
||||
if readable:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
raise UpgradeError("设备在 status=2 之前关闭升级连接")
|
||||
logger.emit("upgrade_rx", size=len(chunk), hex=chunk.hex())
|
||||
receive_buffer.extend(chunk)
|
||||
for frame_type, payload in extract_status_payloads(receive_buffer):
|
||||
status = parse_status(payload)
|
||||
logger.emit("device_status", frame=frame_type, **status)
|
||||
progress = (status["downloaded"], status["flashed"])
|
||||
if previous_progress is None or progress != previous_progress:
|
||||
previous_progress = progress
|
||||
last_progress = time.monotonic()
|
||||
result.update(status)
|
||||
if status["status"] == 3:
|
||||
raise UpgradeError("设备返回升级失败状态 status=3")
|
||||
if status["status"] == 2:
|
||||
incomplete_counters = {}
|
||||
if (
|
||||
status["download_total"] > 0
|
||||
and status["downloaded"] < status["download_total"]
|
||||
):
|
||||
incomplete_counters["download"] = {
|
||||
"total": status["download_total"],
|
||||
"current": status["downloaded"],
|
||||
}
|
||||
if (
|
||||
status["flash_total"] > 0
|
||||
and status["flashed"] < status["flash_total"]
|
||||
):
|
||||
incomplete_counters["flash"] = {
|
||||
"total": status["flash_total"],
|
||||
"current": status["flashed"],
|
||||
}
|
||||
if incomplete_counters:
|
||||
logger.emit(
|
||||
"protocol_warning",
|
||||
message="status=2,但设备进度计数尚未达到其自身总量",
|
||||
counters=incomplete_counters,
|
||||
)
|
||||
logger.emit("protocol_success", status=2)
|
||||
return result
|
||||
|
||||
if writable:
|
||||
if not pending:
|
||||
next_chunk = rom.read(chunk_size)
|
||||
if next_chunk:
|
||||
pending.extend(next_chunk)
|
||||
else:
|
||||
upload_complete = True
|
||||
logger.emit(
|
||||
"client_upload_complete",
|
||||
bytes_sent=result["bytes_sent"],
|
||||
rom_size=rom_size,
|
||||
)
|
||||
if pending:
|
||||
sent = sock.send(pending)
|
||||
if sent <= 0:
|
||||
raise UpgradeError("升级 TCP 连接无法继续发送")
|
||||
# 前 12 字节是 EPFP 头,不计入 ROM 上传进度。
|
||||
previous_wire_bytes = result.get("wire_bytes_sent", 0)
|
||||
wire_bytes = previous_wire_bytes + sent
|
||||
result["wire_bytes_sent"] = wire_bytes
|
||||
result["bytes_sent"] = max(0, min(rom_size, wire_bytes - 12))
|
||||
del pending[:sent]
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""创建命令行参数解析器。"""
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="V2 设备 30101 身份预检与 EPFP/4001 ROM 升级;默认仅预检。"
|
||||
)
|
||||
parser.add_argument("--host", required=True, help="目标设备 IPv4/主机名")
|
||||
parser.add_argument("--expected-sn", required=True, help="必须精确匹配的完整设备 SN")
|
||||
parser.add_argument("--expected-model", help="可选,必须出现在设备型号字段中的型号")
|
||||
parser.add_argument("--rom", required=True, type=Path, help="ROM 文件绝对或相对路径")
|
||||
parser.add_argument("--log-dir", required=True, type=Path, help="本次任务日志目录")
|
||||
parser.add_argument(
|
||||
"--execute",
|
||||
action="store_true",
|
||||
help="实际传输 ROM;未指定时只查询身份、校验 ROM 和输出预检信息",
|
||||
)
|
||||
parser.add_argument("--connect-timeout", type=float, default=10.0)
|
||||
parser.add_argument("--stall-timeout", type=float, default=60.0)
|
||||
parser.add_argument("--total-timeout", type=float, default=600.0)
|
||||
parser.add_argument("--chunk-size", type=int, default=256 * 1024)
|
||||
return parser
|
||||
|
||||
|
||||
def validate_numeric_options(args: argparse.Namespace) -> None:
|
||||
"""拒绝会让超时或分块保护失效的参数。"""
|
||||
|
||||
if args.connect_timeout <= 0:
|
||||
raise UpgradeError("--connect-timeout 必须大于 0")
|
||||
if args.stall_timeout <= 0:
|
||||
raise UpgradeError("--stall-timeout 必须大于 0")
|
||||
if args.total_timeout <= 0:
|
||||
raise UpgradeError("--total-timeout 必须大于 0")
|
||||
if args.chunk_size <= 0 or args.chunk_size > 16 * 1024 * 1024:
|
||||
raise UpgradeError("--chunk-size 必须在 1 到 16777216 之间")
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
"""执行预检;仅在明确给出 --execute 时进入升级传输。"""
|
||||
|
||||
args = build_parser().parse_args(argv)
|
||||
logger: Optional[EventLogger] = None
|
||||
try:
|
||||
validate_numeric_options(args)
|
||||
logger = EventLogger(args.log_dir.expanduser().resolve())
|
||||
logger.emit("mode", execute=bool(args.execute), log_file=str(logger.path))
|
||||
rom = validate_rom(args.rom)
|
||||
logger.emit("rom", **rom)
|
||||
response = query_device_info(args.host, args.connect_timeout, logger)
|
||||
data, models = validate_identity(response, args.expected_sn, args.expected_model)
|
||||
summary = {
|
||||
"host": args.host,
|
||||
"sn": data.get("sn"),
|
||||
"mac": data.get("mac"),
|
||||
"models": models,
|
||||
"versions": collect_versions(data),
|
||||
"expected_model": args.expected_model,
|
||||
"rom": rom,
|
||||
"rom_model_verification": (
|
||||
"由调用方提供并已核对设备型号"
|
||||
if args.expected_model
|
||||
else "无法从协议或 ROM 自动验证目标型号"
|
||||
),
|
||||
}
|
||||
logger.emit("preflight", **summary)
|
||||
|
||||
if not args.execute:
|
||||
logger.emit("preflight_only", message="未指定 --execute,未连接 8943,未发送 ROM")
|
||||
return 0
|
||||
|
||||
result = transfer_rom(
|
||||
host=args.host,
|
||||
rom_path=Path(rom["path"]),
|
||||
rom_size=int(rom["size"]),
|
||||
connect_timeout=args.connect_timeout,
|
||||
stall_timeout=args.stall_timeout,
|
||||
total_timeout=args.total_timeout,
|
||||
chunk_size=args.chunk_size,
|
||||
logger=logger,
|
||||
)
|
||||
if result.get("status") != 2:
|
||||
raise UpgradeError("升级没有取得 status=2 成功终态")
|
||||
logger.emit(
|
||||
"upgrade_result",
|
||||
protocol_success=True,
|
||||
postcheck_required=True,
|
||||
result=result,
|
||||
)
|
||||
return 0
|
||||
except (OSError, ValueError, UpgradeError) as exc:
|
||||
if logger is not None:
|
||||
logger.emit("error", type=type(exc).__name__, message=str(exc))
|
||||
else:
|
||||
print("错误:%s" % exc, file=sys.stderr, flush=True)
|
||||
return 1
|
||||
finally:
|
||||
if logger is not None:
|
||||
logger.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,222 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
实时显示并保存设备串口数据。
|
||||
|
||||
.DESCRIPTION
|
||||
使用 .NET SerialPort 直接独占打开串口。收到的数据会原样写入二进制文件,
|
||||
同时解码后实时显示到当前 PowerShell,并按行写入带时间戳的文本日志。
|
||||
默认只读取数据,不向设备发送指令。DtrEnable 和 RtsEnable 会改变硬件控制线,
|
||||
只有用户明确要求时才能启用。
|
||||
|
||||
.EXAMPLE
|
||||
.\serial_monitor.ps1 -Port COMx -LogPath F:\tmp\embedded-app-pc-debug\task\logs\serial.log
|
||||
|
||||
.EXAMPLE
|
||||
.\serial_monitor.ps1 -Port COMx -LogPath .\serial.log -DurationSeconds 60
|
||||
|
||||
.PARAMETER DtrEnable
|
||||
改变 DTR 硬件控制线;仅在用户明确要求时使用。
|
||||
|
||||
.PARAMETER RtsEnable
|
||||
改变 RTS 硬件控制线;仅在用户明确要求时使用。
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string]$Port,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string]$LogPath,
|
||||
|
||||
[ValidateRange(1, 4000000)]
|
||||
[int]$BaudRate = 115200,
|
||||
|
||||
[ValidateRange(5, 8)]
|
||||
[int]$DataBits = 8,
|
||||
|
||||
[ValidateSet('None', 'Odd', 'Even', 'Mark', 'Space')]
|
||||
[string]$Parity = 'None',
|
||||
|
||||
[ValidateSet('One', 'OnePointFive', 'Two')]
|
||||
[string]$StopBits = 'One',
|
||||
|
||||
[ValidateSet('None', 'XOnXOff', 'RequestToSend', 'RequestToSendXOnXOff')]
|
||||
[string]$Handshake = 'None',
|
||||
|
||||
[string]$EncodingName = 'utf-8',
|
||||
|
||||
[ValidateRange(0, 2147483647)]
|
||||
[int]$DurationSeconds = 0,
|
||||
|
||||
[ValidateRange(1, 5000)]
|
||||
[int]$PollMilliseconds = 100,
|
||||
|
||||
[ValidateRange(64, 1048576)]
|
||||
[int]$BufferSize = 4096,
|
||||
|
||||
[string]$RawPath,
|
||||
|
||||
[switch]$DtrEnable,
|
||||
|
||||
[switch]$RtsEnable
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$serialPort = $null
|
||||
$textWriter = $null
|
||||
$rawStream = $null
|
||||
$pendingLine = ''
|
||||
|
||||
function Write-TimestampedLines {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Text,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.IO.StreamWriter]$Writer
|
||||
)
|
||||
|
||||
$script:pendingLine += $Text
|
||||
while ($true) {
|
||||
$newlineIndex = $script:pendingLine.IndexOf("`n", [System.StringComparison]::Ordinal)
|
||||
if ($newlineIndex -lt 0) {
|
||||
break
|
||||
}
|
||||
$line = $script:pendingLine.Substring(0, $newlineIndex).TrimEnd("`r")
|
||||
$script:pendingLine = $script:pendingLine.Substring($newlineIndex + 1)
|
||||
$Writer.WriteLine(('[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'), $line))
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$resolvedLogPath = [System.IO.Path]::GetFullPath($LogPath)
|
||||
if ([string]::IsNullOrWhiteSpace($RawPath)) {
|
||||
$resolvedRawPath = $resolvedLogPath + '.raw.bin'
|
||||
}
|
||||
else {
|
||||
$resolvedRawPath = [System.IO.Path]::GetFullPath($RawPath)
|
||||
}
|
||||
|
||||
$logDirectory = [System.IO.Path]::GetDirectoryName($resolvedLogPath)
|
||||
$rawDirectory = [System.IO.Path]::GetDirectoryName($resolvedRawPath)
|
||||
if (-not [string]::IsNullOrWhiteSpace($logDirectory)) {
|
||||
[System.IO.Directory]::CreateDirectory($logDirectory) | Out-Null
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($rawDirectory)) {
|
||||
[System.IO.Directory]::CreateDirectory($rawDirectory) | Out-Null
|
||||
}
|
||||
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
$textFileStream = New-Object System.IO.FileStream(
|
||||
$resolvedLogPath,
|
||||
[System.IO.FileMode]::Append,
|
||||
[System.IO.FileAccess]::Write,
|
||||
[System.IO.FileShare]::Read
|
||||
)
|
||||
$textWriter = New-Object System.IO.StreamWriter($textFileStream, $utf8NoBom)
|
||||
$textWriter.AutoFlush = $true
|
||||
$rawStream = New-Object System.IO.FileStream(
|
||||
$resolvedRawPath,
|
||||
[System.IO.FileMode]::Append,
|
||||
[System.IO.FileAccess]::Write,
|
||||
[System.IO.FileShare]::Read
|
||||
)
|
||||
|
||||
try {
|
||||
$encoding = [System.Text.Encoding]::GetEncoding($EncodingName)
|
||||
}
|
||||
catch {
|
||||
throw "无法识别编码 '$EncodingName':$($_.Exception.Message)"
|
||||
}
|
||||
$decoder = $encoding.GetDecoder()
|
||||
|
||||
$serialPort = New-Object System.IO.Ports.SerialPort
|
||||
$serialPort.PortName = $Port
|
||||
$serialPort.BaudRate = $BaudRate
|
||||
$serialPort.DataBits = $DataBits
|
||||
$serialPort.Parity = [System.Enum]::Parse([System.IO.Ports.Parity], $Parity)
|
||||
$serialPort.StopBits = [System.Enum]::Parse([System.IO.Ports.StopBits], $StopBits)
|
||||
$serialPort.Handshake = [System.Enum]::Parse([System.IO.Ports.Handshake], $Handshake)
|
||||
$serialPort.DtrEnable = $DtrEnable.IsPresent
|
||||
$serialPort.RtsEnable = $RtsEnable.IsPresent
|
||||
$serialPort.ReadTimeout = $PollMilliseconds
|
||||
$serialPort.ReadBufferSize = [Math]::Max(4096, $BufferSize)
|
||||
|
||||
try {
|
||||
$serialPort.Open()
|
||||
}
|
||||
catch {
|
||||
throw "无法打开串口 $Port。串口可能不存在或正被其他程序占用:$($_.Exception.Message)"
|
||||
}
|
||||
|
||||
$startTime = Get-Date
|
||||
$startMessage = '[{0}] 已打开 {1},{2} {3}{4}{5},流控 {6};原始数据:{7}' -f `
|
||||
(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'),
|
||||
$Port,
|
||||
$BaudRate,
|
||||
$DataBits,
|
||||
$Parity.Substring(0, 1),
|
||||
$(if ($StopBits -eq 'One') { '1' } elseif ($StopBits -eq 'Two') { '2' } else { '1.5' }),
|
||||
$Handshake,
|
||||
$resolvedRawPath
|
||||
[Console]::WriteLine($startMessage)
|
||||
$textWriter.WriteLine($startMessage)
|
||||
|
||||
$byteBuffer = New-Object byte[] $BufferSize
|
||||
$charBuffer = New-Object char[] ($encoding.GetMaxCharCount($BufferSize))
|
||||
|
||||
while ($true) {
|
||||
if ($DurationSeconds -gt 0 -and ((Get-Date) - $startTime).TotalSeconds -ge $DurationSeconds) {
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
$count = $serialPort.Read($byteBuffer, 0, $byteBuffer.Length)
|
||||
}
|
||||
catch [System.TimeoutException] {
|
||||
continue
|
||||
}
|
||||
|
||||
if ($count -le 0) {
|
||||
continue
|
||||
}
|
||||
$rawStream.Write($byteBuffer, 0, $count)
|
||||
$rawStream.Flush()
|
||||
|
||||
$charCount = $decoder.GetChars($byteBuffer, 0, $count, $charBuffer, 0, $false)
|
||||
if ($charCount -gt 0) {
|
||||
$text = New-Object string($charBuffer, 0, $charCount)
|
||||
[Console]::Write($text)
|
||||
Write-TimestampedLines -Text $text -Writer $textWriter
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# 使用字符串连接保留底层异常文本,避免异常内容再次参与格式化。
|
||||
$errorMessage = '[' + (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + '] 串口监测失败:' + [string]$_.Exception.Message
|
||||
[Console]::Error.WriteLine($errorMessage)
|
||||
if ($null -ne $textWriter) {
|
||||
$textWriter.WriteLine($errorMessage)
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $textWriter -and -not [string]::IsNullOrEmpty($script:pendingLine)) {
|
||||
$textWriter.WriteLine(('[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'), $script:pendingLine.TrimEnd("`r")))
|
||||
}
|
||||
if ($null -ne $serialPort) {
|
||||
if ($serialPort.IsOpen) {
|
||||
$serialPort.Close()
|
||||
}
|
||||
$serialPort.Dispose()
|
||||
}
|
||||
if ($null -ne $rawStream) {
|
||||
$rawStream.Dispose()
|
||||
}
|
||||
if ($null -ne $textWriter) {
|
||||
$textWriter.WriteLine('[{0}] 串口监测结束' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'))
|
||||
$textWriter.Dispose()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
启动支持网页命令输入的串口实时控制台。
|
||||
|
||||
.DESCRIPTION
|
||||
使用单一进程独占串口,同时提供串口读取、原始日志、时间戳文本日志和本机网页命令接口。
|
||||
网页仅监听 127.0.0.1;普通命令支持 None、CR、LF、CRLF 四种结尾。
|
||||
|
||||
.EXAMPLE
|
||||
.\serial_web_console.ps1 -Port COM56 -LogPath F:\tmp\serial.log
|
||||
|
||||
.EXAMPLE
|
||||
.\serial_web_console.ps1 -Port COM56 -LogPath .\serial.log -WebPort 8766
|
||||
|
||||
.PARAMETER ValidateOnly
|
||||
只编译并验证程序和页面资源,不打开串口,也不监听网页端口。
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string]$Port,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string]$LogPath,
|
||||
|
||||
[ValidateRange(1, 4000000)]
|
||||
[int]$BaudRate = 115200,
|
||||
|
||||
[ValidateRange(5, 8)]
|
||||
[int]$DataBits = 8,
|
||||
|
||||
[ValidateSet('None', 'Odd', 'Even', 'Mark', 'Space')]
|
||||
[string]$Parity = 'None',
|
||||
|
||||
[ValidateSet('One', 'OnePointFive', 'Two')]
|
||||
[string]$StopBits = 'One',
|
||||
|
||||
[ValidateSet('None', 'XOnXOff', 'RequestToSend', 'RequestToSendXOnXOff')]
|
||||
[string]$Handshake = 'None',
|
||||
|
||||
[string]$EncodingName = 'utf-8',
|
||||
|
||||
[ValidateRange(1, 5000)]
|
||||
[int]$PollMilliseconds = 100,
|
||||
|
||||
[ValidateRange(64, 1048576)]
|
||||
[int]$BufferSize = 4096,
|
||||
|
||||
[string]$RawPath,
|
||||
|
||||
[ValidateRange(1024, 65535)]
|
||||
[int]$WebPort = 8766,
|
||||
|
||||
[string]$HtmlPath,
|
||||
|
||||
[ValidateRange(0, 2147483647)]
|
||||
[int]$DurationSeconds = 0,
|
||||
|
||||
[switch]$DtrEnable,
|
||||
|
||||
[switch]$RtsEnable,
|
||||
|
||||
[switch]$ValidateOnly
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$broker = $null
|
||||
|
||||
try {
|
||||
if ($PSVersionTable.PSEdition -ne 'Desktop') {
|
||||
throw '该脚本需要由 Windows PowerShell 5.1 运行。请使用 powershell.exe,而不是 pwsh.exe。'
|
||||
}
|
||||
|
||||
$sourcePath = Join-Path $PSScriptRoot 'SerialWebConsoleHost.cs'
|
||||
if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) {
|
||||
throw "找不到串口服务源文件:$sourcePath"
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($HtmlPath)) {
|
||||
$HtmlPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'assets\serial-console.html'
|
||||
}
|
||||
$resolvedHtmlPath = [System.IO.Path]::GetFullPath($HtmlPath)
|
||||
if (-not (Test-Path -LiteralPath $resolvedHtmlPath -PathType Leaf)) {
|
||||
throw "找不到串口控制台页面:$resolvedHtmlPath"
|
||||
}
|
||||
|
||||
# Windows PowerShell 5.1 自带完整 .NET Framework,可直接编译 SerialPort 和 HttpListener。
|
||||
Add-Type -Path $sourcePath -ErrorAction Stop
|
||||
|
||||
if ($ValidateOnly.IsPresent) {
|
||||
Write-Output "验证通过:串口服务代码已编译,网页资源存在。"
|
||||
Write-Output "源文件:$sourcePath"
|
||||
Write-Output "页面:$resolvedHtmlPath"
|
||||
return
|
||||
}
|
||||
|
||||
$resolvedLogPath = [System.IO.Path]::GetFullPath($LogPath)
|
||||
if ([string]::IsNullOrWhiteSpace($RawPath)) {
|
||||
$resolvedRawPath = $resolvedLogPath + '.raw.bin'
|
||||
}
|
||||
else {
|
||||
$resolvedRawPath = [System.IO.Path]::GetFullPath($RawPath)
|
||||
}
|
||||
|
||||
$broker = [EmbeddedAppPcDebug.SerialWebConsoleHost]::new(
|
||||
$Port,
|
||||
$BaudRate,
|
||||
$DataBits,
|
||||
$Parity,
|
||||
$StopBits,
|
||||
$Handshake,
|
||||
$EncodingName,
|
||||
$PollMilliseconds,
|
||||
$BufferSize,
|
||||
$DtrEnable.IsPresent,
|
||||
$RtsEnable.IsPresent,
|
||||
$resolvedLogPath,
|
||||
$resolvedRawPath,
|
||||
$WebPort,
|
||||
$resolvedHtmlPath
|
||||
)
|
||||
|
||||
$broker.Start()
|
||||
$startTime = Get-Date
|
||||
Write-Output "串口网页控制台已启动:$($broker.Url)"
|
||||
Write-Output "按 Ctrl+C 可停止串口采集和网页服务。"
|
||||
|
||||
while (-not $broker.IsStopping) {
|
||||
if ($DurationSeconds -gt 0 -and ((Get-Date) - $startTime).TotalSeconds -ge $DurationSeconds) {
|
||||
break
|
||||
}
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$message = '[' + (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + '] 串口网页控制台失败:' + [string]$_.Exception.Message
|
||||
[Console]::Error.WriteLine($message)
|
||||
exit 1
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $broker) {
|
||||
$broker.Dispose()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
#!/usr/bin/env python3
|
||||
"""通过多个 Windows IPv4 接口执行 V2 EPBP 设备发现。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import errno
|
||||
import ipaddress
|
||||
import json
|
||||
import secrets
|
||||
import select
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
|
||||
DEVICE_PORT = 8941
|
||||
CLIENT_PORT = 8951
|
||||
DISCOVERY_COMMAND = 60000
|
||||
MAX_FRAME_SIZE = 1024 * 1024
|
||||
|
||||
|
||||
class DiscoveryError(RuntimeError):
|
||||
"""表示参数、网卡枚举或协议校验失败。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Interface:
|
||||
"""保存一个 IPv4 接口及其筛选信息。"""
|
||||
|
||||
alias: str
|
||||
description: str
|
||||
address: ipaddress.IPv4Address
|
||||
prefix_length: int
|
||||
hardware: bool
|
||||
|
||||
@property
|
||||
def network(self) -> ipaddress.IPv4Network:
|
||||
"""返回接口对应的 IPv4 网络。"""
|
||||
|
||||
return ipaddress.ip_network(
|
||||
"%s/%d" % (self.address, self.prefix_length), strict=False
|
||||
)
|
||||
|
||||
|
||||
def configure_output() -> None:
|
||||
"""输出被重定向时统一使用 UTF-8。"""
|
||||
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if not stream.isatty() and hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8", errors="backslashreplace")
|
||||
|
||||
|
||||
def emit(event: str, **fields: Any) -> None:
|
||||
"""输出单行 JSON,便于终端查看和日志保存。"""
|
||||
|
||||
record: Dict[str, Any] = {
|
||||
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"event": event,
|
||||
}
|
||||
record.update(fields)
|
||||
print(json.dumps(record, ensure_ascii=False, separators=(",", ":")), flush=True)
|
||||
|
||||
|
||||
def parse_bool(value: Any) -> bool:
|
||||
"""兼容 PowerShell JSON 中的布尔值和字符串。"""
|
||||
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() == "true"
|
||||
|
||||
|
||||
def powershell_interfaces() -> List[Interface]:
|
||||
"""通过 Windows PowerShell 枚举已启用且具有 IPv4 的接口。"""
|
||||
|
||||
command = r"""
|
||||
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
||||
$OutputEncoding = [Console]::OutputEncoding
|
||||
$items = Get-NetIPConfiguration | Where-Object {
|
||||
$_.NetAdapter.Status -eq 'Up' -and $null -ne $_.IPv4Address
|
||||
} | ForEach-Object {
|
||||
$cfg = $_
|
||||
foreach ($ipv4 in @($cfg.IPv4Address)) {
|
||||
[PSCustomObject]@{
|
||||
Alias = [string]$cfg.InterfaceAlias
|
||||
Description = [string]$cfg.InterfaceDescription
|
||||
Address = [string]$ipv4.IPAddress
|
||||
PrefixLength = [int]$ipv4.PrefixLength
|
||||
Hardware = [bool]$cfg.NetAdapter.HardwareInterface
|
||||
}
|
||||
}
|
||||
}
|
||||
@($items) | ConvertTo-Json -Compress
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", command],
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=20,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise DiscoveryError("无法调用 PowerShell 枚举网卡:%s" % exc) from exc
|
||||
if result.returncode != 0:
|
||||
raise DiscoveryError("PowerShell 网卡枚举失败:%s" % result.stderr.strip())
|
||||
try:
|
||||
decoded = json.loads(result.stdout or "[]")
|
||||
except json.JSONDecodeError as exc:
|
||||
raise DiscoveryError("PowerShell 网卡结果不是有效 JSON:%s" % exc) from exc
|
||||
if isinstance(decoded, dict):
|
||||
decoded = [decoded]
|
||||
|
||||
interfaces: List[Interface] = []
|
||||
for item in decoded:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
address = ipaddress.IPv4Address(str(item.get("Address", "")))
|
||||
prefix_length = int(item.get("PrefixLength"))
|
||||
ipaddress.ip_network("%s/%d" % (address, prefix_length), strict=False)
|
||||
except (ipaddress.AddressValueError, ValueError, TypeError):
|
||||
continue
|
||||
interfaces.append(
|
||||
Interface(
|
||||
alias=str(item.get("Alias") or "未知接口"),
|
||||
description=str(item.get("Description") or ""),
|
||||
address=address,
|
||||
prefix_length=prefix_length,
|
||||
hardware=parse_bool(item.get("Hardware")),
|
||||
)
|
||||
)
|
||||
return interfaces
|
||||
|
||||
|
||||
def explicit_interfaces(values: Sequence[str]) -> List[Interface]:
|
||||
"""解析重复传入的 IP/前缀接口参数。"""
|
||||
|
||||
interfaces: List[Interface] = []
|
||||
for index, value in enumerate(values, 1):
|
||||
try:
|
||||
parsed = ipaddress.ip_interface(value)
|
||||
except ValueError as exc:
|
||||
raise DiscoveryError("无效的 --interface %r:%s" % (value, exc)) from exc
|
||||
if not isinstance(parsed, ipaddress.IPv4Interface):
|
||||
raise DiscoveryError("--interface 只支持 IPv4:%s" % value)
|
||||
interfaces.append(
|
||||
Interface(
|
||||
alias="手工接口%d" % index,
|
||||
description="由 --interface 指定",
|
||||
address=parsed.ip,
|
||||
prefix_length=parsed.network.prefixlen,
|
||||
hardware=True,
|
||||
)
|
||||
)
|
||||
return interfaces
|
||||
|
||||
|
||||
def exclusion_reason(interface: Interface, include_virtual: bool) -> Optional[str]:
|
||||
"""返回接口的排除原因;返回空值表示可用于搜索。"""
|
||||
|
||||
if interface.address.is_loopback:
|
||||
return "回环地址"
|
||||
if interface.address.is_link_local:
|
||||
return "APIPA/链路本地地址"
|
||||
if interface.address.is_unspecified or interface.address.is_multicast:
|
||||
return "不是可用单播地址"
|
||||
if not interface.hardware and not include_virtual:
|
||||
return "虚拟或 VPN 接口(使用 --include-virtual 可加入)"
|
||||
return None
|
||||
|
||||
|
||||
def make_frame(session: int) -> bytes:
|
||||
"""生成 V2 发现请求帧。"""
|
||||
|
||||
payload = json.dumps(
|
||||
{"cmd": DISCOVERY_COMMAND, "session": session}, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
return b"EPBP" + struct.pack("!I", len(payload)) + payload
|
||||
|
||||
|
||||
def parse_frame(packet: bytes, expected_session: int) -> Dict[str, Any]:
|
||||
"""严格解析并校验一个完整 EPBP 数据报。"""
|
||||
|
||||
if len(packet) < 8 or packet[:4] != b"EPBP":
|
||||
raise DiscoveryError("响应同步头不是 EPBP")
|
||||
payload_size = struct.unpack("!I", packet[4:8])[0]
|
||||
if payload_size <= 0 or payload_size > MAX_FRAME_SIZE:
|
||||
raise DiscoveryError("响应长度不合理:%d" % payload_size)
|
||||
if len(packet) != 8 + payload_size:
|
||||
raise DiscoveryError(
|
||||
"响应数据报长度不匹配:声明 %d,实际 %d" % (payload_size, len(packet) - 8)
|
||||
)
|
||||
try:
|
||||
response = json.loads(packet[8:].decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise DiscoveryError("响应不是有效 UTF-8 JSON:%s" % exc) from exc
|
||||
if not isinstance(response, dict):
|
||||
raise DiscoveryError("响应 JSON 顶层不是对象")
|
||||
if response.get("cmd") != DISCOVERY_COMMAND:
|
||||
raise DiscoveryError("响应 cmd 不匹配:%r" % response.get("cmd"))
|
||||
if response.get("session") != expected_session:
|
||||
raise DiscoveryError("响应 session 不匹配:%r" % response.get("session"))
|
||||
return response
|
||||
|
||||
|
||||
def response_data(response: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""兼容身份位于顶层或 data 对象的设备响应。"""
|
||||
|
||||
data = response.get("data")
|
||||
return data if isinstance(data, dict) else response
|
||||
|
||||
|
||||
def identity(response: Dict[str, Any]) -> Tuple[Optional[str], List[str], Optional[str]]:
|
||||
"""提取完整 SN、型号集合和 MAC。"""
|
||||
|
||||
data = response_data(response)
|
||||
sn = data.get("sn") if isinstance(data.get("sn"), str) else None
|
||||
mac = data.get("mac") if isinstance(data.get("mac"), str) else None
|
||||
models: List[str] = []
|
||||
for section_name in ("inner", "outer", "hardware"):
|
||||
section = data.get(section_name)
|
||||
if isinstance(section, dict):
|
||||
model = section.get("type")
|
||||
if isinstance(model, str) and model and model not in models:
|
||||
models.append(model)
|
||||
return sn, models, mac
|
||||
|
||||
|
||||
def port_owner(port: int) -> Optional[Dict[str, Any]]:
|
||||
"""在 Windows 上查询 UDP 端口占用者,不结束任何进程。"""
|
||||
|
||||
if sys.platform != "win32":
|
||||
return None
|
||||
command = (
|
||||
"$e=Get-NetUDPEndpoint -LocalPort %d -ErrorAction SilentlyContinue | "
|
||||
"Select-Object -First 1; if($null -ne $e){$p=Get-Process -Id $e.OwningProcess "
|
||||
"-ErrorAction SilentlyContinue;[PSCustomObject]@{PID=$e.OwningProcess;"
|
||||
"Process=$p.ProcessName;Address=$e.LocalAddress}|ConvertTo-Json -Compress}" % port
|
||||
)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", command],
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=10,
|
||||
)
|
||||
return json.loads(result.stdout) if result.stdout.strip() else None
|
||||
except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def open_sockets(
|
||||
interfaces: Iterable[Interface], client_port: int
|
||||
) -> List[Tuple[Interface, socket.socket]]:
|
||||
"""为每个 IPv4 接口创建独立的广播收发套接字。"""
|
||||
|
||||
opened: List[Tuple[Interface, socket.socket]] = []
|
||||
for interface in interfaces:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
try:
|
||||
sock.bind((str(interface.address), client_port))
|
||||
except OSError as exc:
|
||||
sock.close()
|
||||
owner = port_owner(client_port) if exc.errno in (errno.EACCES, errno.EADDRINUSE, 10013, 10048) else None
|
||||
emit(
|
||||
"bind_error",
|
||||
interface=interface.alias,
|
||||
address=str(interface.address),
|
||||
port=client_port,
|
||||
error=str(exc),
|
||||
owner=owner,
|
||||
action="请释放端口;本脚本不会结束占用进程",
|
||||
)
|
||||
continue
|
||||
sock.setblocking(False)
|
||||
opened.append((interface, sock))
|
||||
emit(
|
||||
"socket_ready",
|
||||
interface=interface.alias,
|
||||
address=str(interface.address),
|
||||
port=client_port,
|
||||
)
|
||||
return opened
|
||||
|
||||
|
||||
def discover(
|
||||
opened: Sequence[Tuple[Interface, socket.socket]],
|
||||
device_port: int,
|
||||
timeout: float,
|
||||
session: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""向每个接口发送两类广播并收集合法响应。"""
|
||||
|
||||
frame = make_frame(session)
|
||||
socket_map = {sock: interface for interface, sock in opened}
|
||||
for interface, sock in opened:
|
||||
destinations = ["255.255.255.255", str(interface.network.broadcast_address)]
|
||||
for destination in dict.fromkeys(destinations):
|
||||
try:
|
||||
sock.sendto(frame, (destination, device_port))
|
||||
emit(
|
||||
"broadcast_sent",
|
||||
interface=interface.alias,
|
||||
source=str(interface.address),
|
||||
destination=destination,
|
||||
device_port=device_port,
|
||||
session=session,
|
||||
)
|
||||
except OSError as exc:
|
||||
emit(
|
||||
"send_error",
|
||||
interface=interface.alias,
|
||||
destination=destination,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
found: List[Dict[str, Any]] = []
|
||||
while socket_map:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
readable, _, _ = select.select(list(socket_map), [], [], remaining)
|
||||
if not readable:
|
||||
break
|
||||
for sock in readable:
|
||||
interface = socket_map[sock]
|
||||
try:
|
||||
packet, peer = sock.recvfrom(MAX_FRAME_SIZE + 9)
|
||||
response = parse_frame(packet, session)
|
||||
except (OSError, DiscoveryError) as exc:
|
||||
emit(
|
||||
"invalid_response",
|
||||
interface=interface.alias,
|
||||
error=str(exc),
|
||||
)
|
||||
continue
|
||||
sn, models, mac = identity(response)
|
||||
candidate = {
|
||||
"source_ip": peer[0],
|
||||
"source_port": peer[1],
|
||||
"received_on": str(interface.address),
|
||||
"interface": interface.alias,
|
||||
"sn": sn,
|
||||
"models": models,
|
||||
"mac": mac,
|
||||
"response": response,
|
||||
}
|
||||
found.append(candidate)
|
||||
emit("candidate", **candidate)
|
||||
return found
|
||||
|
||||
|
||||
def deduplicate(candidates: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""只合并 SN、源地址、MAC 和型号完全一致的重复响应。"""
|
||||
|
||||
unique: Dict[Tuple[str, ...], Dict[str, Any]] = {}
|
||||
for candidate in candidates:
|
||||
sn = candidate.get("sn")
|
||||
key = (
|
||||
"identity",
|
||||
str(sn or ""),
|
||||
str(candidate.get("source_ip") or ""),
|
||||
str(candidate.get("mac") or ""),
|
||||
json.dumps(candidate.get("models", []), ensure_ascii=False, separators=(",", ":")),
|
||||
)
|
||||
unique.setdefault(key, candidate)
|
||||
return list(unique.values())
|
||||
|
||||
|
||||
def find_identity_conflicts(candidates: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""找出同一完整 SN 对应多个网络或型号身份的冲突。"""
|
||||
|
||||
grouped: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for candidate in candidates:
|
||||
sn = candidate.get("sn")
|
||||
if isinstance(sn, str) and sn:
|
||||
grouped.setdefault(sn, []).append(candidate)
|
||||
return [
|
||||
{"sn": sn, "candidates": items}
|
||||
for sn, items in grouped.items()
|
||||
if len(items) > 1
|
||||
]
|
||||
|
||||
|
||||
def select_expected(
|
||||
candidates: Sequence[Dict[str, Any]], expected_sn: Optional[str], expected_model: Optional[str]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""按完整 SN 和型号筛选候选。"""
|
||||
|
||||
selected = list(candidates)
|
||||
if expected_sn:
|
||||
selected = [item for item in selected if item.get("sn") == expected_sn]
|
||||
if expected_model:
|
||||
selected = [item for item in selected if expected_model in item.get("models", [])]
|
||||
return selected
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""创建命令行参数解析器。"""
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="从每个活动 IPv4 接口的本地 8951 端口执行 V2 EPBP 广播搜索。"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--interface",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="IP/PREFIX",
|
||||
help="手工指定接口,可重复;给出后不自动枚举,格式为本机IPv4/前缀长度",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-virtual",
|
||||
action="store_true",
|
||||
help="自动枚举时同时使用活动的虚拟、VPN 等非物理接口",
|
||||
)
|
||||
parser.add_argument("--expected-sn", help="期望的完整设备 SN,按完全相等匹配")
|
||||
parser.add_argument("--expected-model", help="期望型号,匹配 inner/outer/hardware.type")
|
||||
parser.add_argument("--timeout", type=float, default=3.0, help="接收等待秒数")
|
||||
parser.add_argument("--client-port", type=int, default=CLIENT_PORT)
|
||||
parser.add_argument("--device-port", type=int, default=DEVICE_PORT)
|
||||
return parser
|
||||
|
||||
|
||||
def validate_options(args: argparse.Namespace) -> None:
|
||||
"""拒绝无效端口和等待时间。"""
|
||||
|
||||
if args.timeout <= 0 or args.timeout > 300:
|
||||
raise DiscoveryError("--timeout 必须在 0 到 300 秒之间")
|
||||
for name in ("client_port", "device_port"):
|
||||
value = getattr(args, name)
|
||||
if value <= 0 or value > 65535:
|
||||
raise DiscoveryError("--%s 必须在 1 到 65535 之间" % name.replace("_", "-"))
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
"""执行网卡筛选、广播搜索和唯一身份匹配。"""
|
||||
|
||||
opened: List[Tuple[Interface, socket.socket]] = []
|
||||
try:
|
||||
args = build_parser().parse_args(argv)
|
||||
validate_options(args)
|
||||
if args.client_port != CLIENT_PORT:
|
||||
emit(
|
||||
"diagnostic_port",
|
||||
client_port=args.client_port,
|
||||
warning="设备可能只回复到 8951;非标准源端口无响应不能证明设备离线",
|
||||
)
|
||||
interfaces = (
|
||||
explicit_interfaces(args.interface) if args.interface else powershell_interfaces()
|
||||
)
|
||||
selected_interfaces: List[Interface] = []
|
||||
for interface in interfaces:
|
||||
reason = exclusion_reason(interface, args.include_virtual)
|
||||
emit(
|
||||
"interface",
|
||||
alias=interface.alias,
|
||||
description=interface.description,
|
||||
address=str(interface.address),
|
||||
prefix_length=interface.prefix_length,
|
||||
directed_broadcast=str(interface.network.broadcast_address),
|
||||
hardware=interface.hardware,
|
||||
selected=reason is None,
|
||||
exclusion_reason=reason,
|
||||
)
|
||||
if reason is None:
|
||||
selected_interfaces.append(interface)
|
||||
if not selected_interfaces:
|
||||
raise DiscoveryError("没有可用于广播的 IPv4 接口")
|
||||
|
||||
opened = open_sockets(selected_interfaces, args.client_port)
|
||||
if not opened:
|
||||
raise DiscoveryError(
|
||||
"所有接口都无法绑定本地端口 %d;请检查端口占用" % args.client_port
|
||||
)
|
||||
session = secrets.randbelow(0xFFFFFFFF) + 1
|
||||
candidates = deduplicate(
|
||||
discover(opened, args.device_port, args.timeout, session)
|
||||
)
|
||||
conflicts = find_identity_conflicts(candidates)
|
||||
if conflicts:
|
||||
emit(
|
||||
"identity_conflict",
|
||||
message="同一完整 SN 对应不同 IP、MAC 或型号,禁止自动选择设备",
|
||||
conflicts=conflicts,
|
||||
)
|
||||
return 2
|
||||
matches = select_expected(candidates, args.expected_sn, args.expected_model)
|
||||
identity_verified = bool(args.expected_sn and len(matches) == 1)
|
||||
emit(
|
||||
"summary",
|
||||
mode="identity_check" if args.expected_sn else "list_only",
|
||||
identity_verified=identity_verified,
|
||||
candidate_count=len(candidates),
|
||||
candidates=candidates,
|
||||
expected_sn=args.expected_sn,
|
||||
expected_model=args.expected_model,
|
||||
match_count=len(matches),
|
||||
matches=matches,
|
||||
)
|
||||
if not candidates:
|
||||
emit(
|
||||
"not_found",
|
||||
message=(
|
||||
"未收到合法响应;若使用的不是客户端端口 8951,此结果不能证明设备离线"
|
||||
),
|
||||
)
|
||||
return 2
|
||||
if args.expected_sn:
|
||||
if len(matches) != 1:
|
||||
emit("identity_error", message="期望身份没有唯一匹配,禁止自动选择设备")
|
||||
return 2
|
||||
return 0
|
||||
except (OSError, ValueError, DiscoveryError) as exc:
|
||||
emit("error", type=type(exc).__name__, message=str(exc))
|
||||
return 1
|
||||
finally:
|
||||
for _, sock in opened:
|
||||
sock.close()
|
||||
|
||||
|
||||
configure_output()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user