fix(device-test): contain tool processes and add reviewed preview

This commit is contained in:
chenmingxuan committed 2026-10-30 12:43:36 +08:00
1 parent c98d7527a0
commit 7d31e09cb7
59 files changed
+23722

No files matched your search

@@ -0,0 +1,380 @@
"""设备 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
class UpgradeError(RuntimeError):
"""表示预检、协议或升级过程失败。"""
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]
@@ -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,26 @@
{
"schema_version": 1,
"review_date": "2026-10-16",
"scope": "Discovery and firmware protocol helpers only; no original skill policy or credentials imported.",
"files": [
{
"file": "device_upgrade.py",
"upstream_relative_path": "embedded-app-pc-debug/scripts/device_upgrade.py",
"upstream_sha256": "ce72435ff4a3dbe3cc0c28dd81c2a7873b60e55bb64f09055a64900b8f921578",
"vendored_sha256": "b50c12c982a6944a2112e22dfed87c0fdfdc311e6c6ff43a5ce9fa2a726b00ed",
"changes": [
"Extracted reusable protocol functions and constants verbatim.",
"Excluded original CLI, EventLogger, and output reconfiguration function/call."
]
},
{
"file": "v2_discover.py",
"upstream_relative_path": "embedded-app-pc-debug/scripts/v2_discover.py",
"upstream_sha256": "f6634bfc3b86abb6d379d28b679f2e209694f9d856e343157e24b9ff629ea894",
"vendored_sha256": "6d0783c2a4f34397b61f4d1a65f97c2300f492caf8c60c38b48c246a75774158",
"changes": [
"Moved module-level configure_output() into the __main__ guard; discovery logic unchanged."
]
}
]
}
@@ -0,0 +1,537 @@
#!/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()
if __name__ == "__main__":
configure_output()
raise SystemExit(main())