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,424 @@
#!/usr/bin/env python3
"""Local ROM checks and explicit V1/V2 discovery, identity and firmware operations."""
from __future__ import annotations
import argparse
from contextlib import contextmanager
import os
import shutil
import tempfile
import hashlib
import importlib.util
import ipaddress
import json
import math
from pathlib import Path
import re
import sys
from typing import Any
VENDOR = Path(__file__).resolve().parent / "vendor"
FIRMWARE_FIELDS = ("inner.version", "outer.version")
V1_NATIVE_FIELDS = ("firmware", "dev_ver")
def load_vendor(filename: str, module_name: str):
"""Load only the bundled, hash-pinned helper without modifying sys.path."""
path = VENDOR / filename
manifest = json.loads((VENDOR / "source-manifest.json").read_text(encoding="utf-8"))
entry = next(item for item in manifest["files"] if item["file"] == filename)
if hashlib.sha256(path.read_bytes()).hexdigest() != entry["vendored_sha256"]:
raise RuntimeError("Bundled protocol helper checksum mismatch: " + filename)
if module_name in sys.modules:
return sys.modules[module_name]
spec = importlib.util.spec_from_file_location(module_name, path)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
except BaseException:
sys.modules.pop(module_name, None)
raise
return module
protocol = load_vendor("device_upgrade.py", "artemis_device_vendor_upgrade")
_v1_spec = importlib.util.spec_from_file_location("artemis_device_v1", Path(__file__).with_name("v1_protocol.py"))
v1 = importlib.util.module_from_spec(_v1_spec)
_v1_spec.loader.exec_module(v1)
class UnsupportedProtocolError(protocol.UpgradeError):
"""A selected protocol has no implementation; no device I/O is allowed."""
def require_supported_protocol(name: str):
if name not in ("v1", "v2"):
raise UnsupportedProtocolError("Unknown device protocol")
def capabilities() -> dict:
return {
"status": "CAPABILITIES", "network_used": False, "write_executed": False,
"firmware_verified": False, "default_protocol": "v2",
"protocols": {
"v1": {"implemented": True, "operations": ["discover", "identify", "info", "upgrade", "verify-version"],
"upgrade_preflight": True, "upgrade_transfer": True,
"upgrade_default": "online_preflight_only",
"upgrade_transport": "tcp_4941_2170_md5_ascii32_plus_rom",
"version_fields": list(V1_NATIVE_FIELDS),
"documentation": "references/protocol-support.md"},
"v2": {"implemented": True,
"operations": ["discover", "identify", "info", "upgrade", "verify-version"],
"upgrade_default": "online_preflight_only",
"documentation": "references/device-adapter.md"},
},
}
class EventCollector:
"""Preserve protocol evidence inside one JSON result without changing streams."""
def __init__(self):
self.events: list[dict[str, Any]] = []
def emit(self, event: str, **fields: Any):
if event == "protocol_success":
# Upstream reports only a remote status; the adapter still checks our upload.
event = "device_reported_status_2"
self.events.append({"event": event, **fields})
def nonempty(value: str) -> str:
if not value.strip():
raise argparse.ArgumentTypeError("value must not be empty or whitespace")
return value
def positive_seconds(value: str) -> float:
number = float(value)
if not math.isfinite(number) or not 0 < number <= 3600:
raise argparse.ArgumentTypeError("timeout must be finite and in (0, 3600]")
return number
def add_identity(parser):
parser.add_argument("--expected-sn", required=True, type=nonempty,
help="Full serial number; exact equality, no suffix matching")
parser.add_argument("--expected-model", required=True, type=nonempty,
help="Exact dev_type for V1, or inner/outer/hardware.type for V2")
def add_target(parser):
parser.add_argument("--target-version", required=True, type=nonempty)
parser.add_argument("--version-field", required=True, choices=FIRMWARE_FIELDS + V1_NATIVE_FIELDS,
help="Explicit firmware field; hardware.version is not firmware")
def add_protocol(parser):
parser.add_argument("--protocol", choices=("v1", "v2"), default="v2",
help="Device wire protocol; default v2. No automatic fallback.")
def build_parser():
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("capabilities", help="List implemented protocols without network access")
discover = commands.add_parser("discover", help="Read-only broadcast discovery; forwards native arguments", add_help=False)
add_protocol(discover)
discover.add_argument("arguments", nargs=argparse.REMAINDER)
offline = commands.add_parser("offline", help="Only inspect/hash a local ROM; no network")
offline.add_argument("--rom", required=True, type=Path)
offline.add_argument("--rom-sha256", help="Optional expected SHA-256")
identify = commands.add_parser("identify", help="Read-only query with full SN to observe the reported model")
add_protocol(identify)
identify.add_argument("--host", required=True, type=nonempty)
identify.add_argument("--timeout", type=positive_seconds, default=10.0)
identify.add_argument("--expected-sn", required=True, type=nonempty)
info = commands.add_parser("info", help="Read-only identity query; no ROM needed")
add_protocol(info)
info.add_argument("--host", required=True, type=nonempty)
info.add_argument("--timeout", type=positive_seconds, default=10.0)
add_identity(info)
upgrade = commands.add_parser("upgrade", help="Online preflight by default; write only with --execute")
add_protocol(upgrade)
upgrade.add_argument("--host", required=True, type=nonempty,
help="Explicit numeric unicast IPv4/IPv6; hostnames are rejected even for preflight")
add_identity(upgrade)
add_target(upgrade)
upgrade.add_argument("--rom", required=True, type=Path)
upgrade.add_argument("--rom-sha256", help="Expected digest; mandatory with --execute")
upgrade.add_argument("--execute", action="store_true")
upgrade.add_argument("--timeout", type=positive_seconds, default=10.0)
upgrade.add_argument("--stall-timeout", type=positive_seconds, default=60.0)
upgrade.add_argument("--total-timeout", type=positive_seconds, default=600.0)
verify = commands.add_parser("verify-version", help="Compare an explicit firmware version against recorded or live info")
add_protocol(verify)
source = verify.add_mutually_exclusive_group(required=True)
source.add_argument("--response", type=Path, help="Protocol response JSON or info result JSON; V1 requires its explicit envelope")
source.add_argument("--host", type=nonempty, help="Query selected protocol once; read-only")
verify.add_argument("--timeout", type=positive_seconds, default=10.0)
add_identity(verify)
add_target(verify)
return parser
def inspect_rom(path: Path, expected_digest: str | None) -> dict:
if expected_digest is not None and re.fullmatch(r"[0-9a-fA-F]{64}", expected_digest) is None:
raise protocol.UpgradeError("--rom-sha256 must contain exactly 64 hexadecimal characters")
rom = protocol.validate_rom(path)
if expected_digest is not None and rom["sha256"] != expected_digest.lower():
raise protocol.UpgradeError("ROM SHA-256 mismatch")
rom["expected_sha256"] = expected_digest.lower() if expected_digest else None
rom["expected_digest_verified"] = expected_digest is not None
return rom
def numeric_upgrade_target(host: str) -> str:
"""Bind both protocol connections to one numeric address without DNS selection."""
try:
address = ipaddress.ip_address(host)
except ValueError as exc:
raise protocol.UpgradeError("upgrade --host requires an explicit numeric IPv4/IPv6 address") from exc
effective = address.ipv4_mapped if isinstance(address, ipaddress.IPv6Address) else None
effective = effective if effective is not None else address
if effective.is_multicast or effective.is_unspecified or str(effective) == "255.255.255.255":
raise protocol.UpgradeError("upgrade --host must be unicast; multicast, unspecified and limited-broadcast addresses are rejected")
return str(address)
def validate_response(response: Any, expected_sn: str, expected_model: str | None, protocol_name="v2"):
if protocol_name == "v1":
return v1.validate_identity(response, expected_sn, expected_model)
if not isinstance(response, dict):
raise protocol.UpgradeError("30101 response must be a JSON object")
if response.get("cmd") != 30101 or response.get("code") != 0:
raise protocol.UpgradeError("Expected a successful cmd=30101, code=0 response")
return protocol.validate_identity(response, expected_sn, expected_model)
def read_response(path: Path) -> dict:
if path.stat().st_size > 2 * protocol.MAX_JSON_SIZE:
raise protocol.UpgradeError("Recorded response exceeds 2 MiB")
response = json.loads(path.read_text(encoding="utf-8-sig"))
if isinstance(response, dict) and "response" in response:
response = response["response"]
return response
def verify_version(response: dict, expected_sn: str, expected_model: str,
target_version: str, version_field: str, protocol_name="v2") -> dict:
allowed = FIRMWARE_FIELDS + V1_NATIVE_FIELDS if protocol_name == "v1" else FIRMWARE_FIELDS
if version_field not in allowed:
raise protocol.UpgradeError("Selected firmware field is not supported by this protocol")
data, models = validate_response(response, expected_sn, expected_model, protocol_name)
if "." in version_field:
section, field = version_field.split(".")
values = data.get(section)
actual = values.get(field) if isinstance(values, dict) else None
else:
actual = data.get(version_field)
available = isinstance(actual, str) and bool(actual.strip())
matched = available and actual == target_version
return {
"status": "PASS" if matched else ("FAIL" if available else "INCOMPLETE"),
"firmware_verified": bool(matched),
"acceptance_scope": "identity_and_selected_firmware_version_only",
"sn": data["sn"], "models": models,
"version_field": version_field, "target_version": target_version,
"actual_version": actual,
"reason": "exact_match" if matched else ("version_mismatch" if available else "firmware_version_missing"),
"response": response,
}
@contextmanager
def protected_rom_snapshot(source: Path, expected_sha256: str, expected_size: int):
"""Hold a Windows read-only, no-write/no-delete share lock through transfer."""
if os.name != "nt":
raise protocol.UpgradeError("Protected firmware snapshots require Windows")
import ctypes
from ctypes import wintypes
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
kernel.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
kernel.CreateFileW.restype = wintypes.HANDLE
kernel.CloseHandle.argtypes = [wintypes.HANDLE]
kernel.CloseHandle.restype = wintypes.BOOL
with tempfile.TemporaryDirectory(prefix="artemis-rom-") as folder:
snapshot = Path(folder) / "firmware.rom"
with source.open("rb") as src, snapshot.open("xb") as dst:
shutil.copyfileobj(src, dst, length=1024 * 1024)
# FILE_SHARE_READ only: conflicting existing writers prevent this open;
# future writers, renames and deletion remain blocked until CloseHandle.
handle = kernel.CreateFileW(str(snapshot), 0x80000000, 1, None, 3, 0x80, None)
if handle == ctypes.c_void_p(-1).value:
raise protocol.UpgradeError("Unable to protect firmware snapshot")
try:
info = inspect_rom(snapshot, expected_sha256)
if info["size"] != expected_size:
raise protocol.UpgradeError("Firmware snapshot size differs from preflight")
yield snapshot
finally:
kernel.CloseHandle(handle)
def run(args, logger: EventCollector) -> dict:
if args.command == "capabilities":
return capabilities()
if args.command == "offline":
return {"status": "OFFLINE_READY", "mode": "offline", "firmware_verified": False,
"rom": inspect_rom(args.rom, args.rom_sha256), "network_used": False}
require_supported_protocol(args.protocol)
if args.command in ("upgrade", "verify-version") and args.protocol == "v2" and args.version_field not in FIRMWARE_FIELDS:
raise protocol.UpgradeError("V2 requires inner.version or outer.version")
driver = v1 if args.protocol == "v1" else protocol
if args.command == "verify-version":
live = args.host is not None
response = (driver.query_device_info(args.host, args.timeout, logger)
if live else read_response(args.response))
result = verify_version(response, args.expected_sn, args.expected_model,
args.target_version, args.version_field, args.protocol)
result.update(mode="read_only" if live else "offline", live_readback=live,
evidence_source=("live_v1_2104" if args.protocol == "v1" else "live_30101") if live else "recorded_response",
network_used=live)
return result
rom = None
host = args.host
if args.command == "upgrade":
host = numeric_upgrade_target(args.host)
logger.emit("upgrade_target_bound", bound_ip=host)
if args.execute and not args.rom_sha256:
raise protocol.UpgradeError("--execute requires an explicitly reviewed --rom-sha256")
rom = inspect_rom(args.rom, args.rom_sha256)
response = driver.query_device_info(host, args.timeout, logger)
expected_model = getattr(args, "expected_model", None)
data, models = validate_response(response, args.expected_sn, expected_model, args.protocol)
result = {"status": "IDENTITY_CONFIRMED", "mode": "read_only", "firmware_verified": False,
"sn": data["sn"], "models": models, "response": response,
"network_used": True, "write_executed": False}
if args.protocol == "v1":
result["native_versions"] = {key: data[key] for key in ("firmware", "dev_ver", "hardware") if key in data}
if args.command == "identify":
result.update(status="IDENTITY_OBSERVED", sn_verified=True, model_verified=False)
return result
if args.command == "info":
return result
result.update(status="PREFLIGHT_ONLY", rom=rom, target_version=args.target_version,
version_field=args.version_field, write_executed=False,
postcheck_required=True, bound_ip=host,
rom_model_verification="Caller must verify ROM compatibility; protocol cannot prove it")
if args.protocol == "v1":
observation = verify_version(response, args.expected_sn, args.expected_model,
args.target_version, args.version_field, "v1")
result.update(preflight_complete=True, write_supported=True,
selected_version_field_available=observation["reason"] != "firmware_version_missing",
current_selected_version=observation["actual_version"])
if not args.execute:
return result
# Hash and transfer the same snapshot while Windows denies writes and replacement.
with protected_rom_snapshot(Path(rom["path"]), args.rom_sha256, rom["size"]) as snapshot:
logger.emit("write_attempt", rom_sha256=rom["sha256"], bound_ip=host,
target_version=args.target_version, version_field=args.version_field)
transfer_options = {"expected_sha256": args.rom_sha256} if args.protocol == "v1" else {}
transfer = driver.transfer_rom(
host=host, rom_path=snapshot, rom_size=rom["size"],
connect_timeout=args.timeout, stall_timeout=args.stall_timeout,
total_timeout=args.total_timeout, chunk_size=256 * 1024, logger=logger,
**transfer_options,
)
logger.emit("transfer_result", **transfer)
if args.protocol == "v1":
if (transfer.get("acknowledged") is not True
or transfer.get("response_command") != v1.UPGRADE_COMMAND
or type(transfer.get("response_code")) is not int or transfer["response_code"] != 0):
raise protocol.UpgradeError("Device did not acknowledge the V1 upgrade command with integer code=0")
elif transfer.get("status") != 2:
raise protocol.UpgradeError("Device did not report protocol status=2")
bytes_sent = transfer.get("bytes_sent")
if type(bytes_sent) is not int or bytes_sent != rom["size"]:
raise protocol.UpgradeError(
"Device reported success before a complete ROM upload: expected %d bytes, sent %r"
% (rom["size"], bytes_sent)
)
result.update(status="PROTOCOL_SUCCESS_POSTCHECK_REQUIRED", mode="write",
write_executed=True, protocol_success=True, transfer=transfer)
return result
def discover_v1_cli(argv, logger):
parser = argparse.ArgumentParser(description="V1 UDP ff00/ff01 read-only discovery; never upgrades a device")
parser.add_argument("--interface", action="append", default=[], metavar="IPv4/PREFIX")
parser.add_argument("--include-virtual", action="store_true")
parser.add_argument("--expected-sn", type=nonempty)
parser.add_argument("--expected-model", type=nonempty, help="Exact dev_type")
parser.add_argument("--timeout", type=positive_seconds, default=3.0)
parser.add_argument("--client-port", type=int, default=v1.DISCOVERY_PORT,
help="Local receive port; default same broadcast channel 4942; V1 source-port rule is unspecified")
args = parser.parse_args(argv)
if args.timeout > 300 or not 1 <= args.client_port <= 65535:
raise protocol.UpgradeError("V1 discovery requires timeout <= 300s and client-port in 1..65535")
# Reuse only the reviewed Windows interface enumeration, never V2 packets or ports.
helper = load_vendor("v2_discover.py", "artemis_device_vendor_discover")
try:
interfaces = helper.explicit_interfaces(args.interface) if args.interface else helper.powershell_interfaces()
except helper.DiscoveryError as exc:
raise protocol.UpgradeError(str(exc)) from exc
selected = []
for interface in interfaces:
reason = helper.exclusion_reason(interface, args.include_virtual)
logger.emit("v1_interface", alias=interface.alias, address=str(interface.address),
prefix_length=interface.prefix_length, selected=reason is None, exclusion_reason=reason)
if reason is None:
selected.append(interface)
if not selected:
raise protocol.UpgradeError("No eligible V1 broadcast interface")
return v1.discover(selected, args.timeout, args.client_port, logger,
args.expected_sn, args.expected_model)
def main(argv: list[str] | None = None) -> int:
# Only CLI execution configures streams; importing the adapter is side-effect free.
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="backslashreplace")
argv = list(sys.argv[1:] if argv is None else argv)
logger = EventCollector()
selected_protocol = None
try:
if argv and argv[0] == "discover":
router = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
add_protocol(router)
routing, native_arguments = router.parse_known_args(argv[1:])
selected_protocol = routing.protocol
require_supported_protocol(selected_protocol)
if selected_protocol == "v2":
discovery = load_vendor("v2_discover.py", "artemis_device_vendor_discover")
return discovery.main(native_arguments)
result = discover_v1_cli(native_arguments, logger)
else:
args = build_parser().parse_args(argv)
selected_protocol = getattr(args, "protocol", None)
result = run(args, logger)
code = 0 if result["status"] not in ("FAIL", "INCOMPLETE", "BLOCKED") else 1
except (OSError, ValueError, protocol.UpgradeError) as exc:
write_attempted = any(event["event"] == "write_attempt" for event in logger.events)
bound_ip = next((event["bound_ip"] for event in logger.events
if event["event"] == "upgrade_target_bound"), None)
result = {"status": "ERROR", "firmware_verified": False,
"error_type": type(exc).__name__, "message": str(exc),
"automatic_retry": False, "write_may_have_started": write_attempted,
"postcheck_required": write_attempted, "bound_ip": bound_ip}
if isinstance(exc, UnsupportedProtocolError):
result.update(status="BLOCKED", error_code=getattr(exc, "error_code", "protocol_not_implemented"),
network_used=False, write_executed=False)
code = 1
if selected_protocol is not None:
result["protocol"] = selected_protocol
result["events"] = logger.events
print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
return code
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""Build an independent evidence bundle. Never infer business PASS from tool exit codes."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import shutil
import sys
import zipfile
from datetime import datetime, timezone
from pathlib import Path, PureWindowsPath
VERDICTS = {'passed', 'failed', 'blocked', 'inconclusive'}
def digest(path: Path) -> str:
h = hashlib.sha256()
with path.open('rb') as stream:
for block in iter(lambda: stream.read(1024 * 1024), b''):
h.update(block)
return h.hexdigest()
def inside(root: Path, name: str) -> Path:
if not isinstance(name, str) or not name.strip():
raise ValueError('Evidence path must be a non-empty relative path')
if Path(name).is_absolute() or PureWindowsPath(name).is_absolute() or ':' in name:
raise ValueError('Evidence path must be relative to evidence_root')
candidate = (root / name).resolve()
if not candidate.is_relative_to(root) or '..' in Path(name.replace('\\', '/')).parts:
raise ValueError('Evidence path escapes evidence_root')
return candidate
def overall(assertions: list[dict]) -> str:
required = [a['verdict'] for a in assertions if a.get('required', True)]
if not required:
return 'inconclusive'
for status in ('failed', 'blocked', 'inconclusive'):
if status in required:
return status
return 'passed'
def cell(value) -> str:
return str(value).replace('|', '\\|').replace('\n', '<br>')
def bundle(manifest_path: Path, output: Path) -> dict:
manifest_path = manifest_path.resolve(strict=True)
output = output.resolve()
if output.exists():
raise FileExistsError('Output already exists; use a new run directory')
source = json.loads(manifest_path.read_text(encoding='utf-8-sig'))
if not isinstance(source, dict) or source.get('schema_version') != 1:
raise ValueError('schema_version must be 1')
run_id = source.get('run_id')
if not isinstance(run_id, str) or not re.fullmatch(r'[A-Za-z0-9_.-]{1,100}', run_id):
raise ValueError('run_id must be 1..100 letters, digits, _, -, or .')
root = (manifest_path.parent / source.get('evidence_root', '.')).resolve(strict=True)
assertions = source.get('assertions')
if not root.is_dir() or not isinstance(assertions, list):
raise ValueError('evidence_root must be a directory and assertions must be a list')
context = source.get('context', {})
if not isinstance(context, dict):
raise ValueError('context must be an object')
execution_mode = context.get('execution_mode', 'unspecified')
if execution_mode not in {'live', 'offline', 'simulation', 'unspecified'}:
raise ValueError('execution_mode must be live, offline, simulation or unspecified')
real_device = context.get('real_device_test_executed')
if real_device is not None and not isinstance(real_device, bool):
raise ValueError('real_device_test_executed must be Boolean')
if execution_mode in {'offline', 'simulation'} and real_device:
raise ValueError('Offline/simulation cannot claim real device execution')
ids = set()
checked = []
artifacts = {}
for original in assertions:
if not isinstance(original, dict):
raise ValueError('Each assertion must be an object')
a = dict(original)
identity = a.get('id')
if not isinstance(identity, str) or not identity or identity in ids:
raise ValueError('Assertion IDs must be non-empty and unique')
ids.add(identity)
if a.get('verdict') not in VERDICTS or not isinstance(a.get('required', True), bool):
raise ValueError('Use explicit verdict and Boolean required')
if 'expected' not in a or 'actual' not in a:
raise ValueError('Each assertion needs expected and actual observations')
refs = a.get('evidence', [])
if not isinstance(refs, list):
raise ValueError('evidence must be a list')
a['requested_evidence'] = refs
problems = []
normalized = []
for ref in refs:
ref = {'path': ref} if isinstance(ref, str) else ref
if not isinstance(ref, dict):
raise ValueError('Evidence references must be paths or objects')
file = inside(root, ref.get('path'))
relative = file.relative_to(root).as_posix()
if not file.is_file():
problems.append(f'Missing evidence: {relative}')
continue
sha = digest(file)
expected_hash = ref.get('sha256')
artifact = artifacts.setdefault(relative, {'path': 'evidence/' + relative,
'sha256': sha, 'size_bytes': file.stat().st_size, 'integrity': 'recorded'})
if expected_hash is not None and expected_hash != sha:
problems.append(f'Hash mismatch: {relative}')
artifact['integrity'] = 'mismatch'
elif expected_hash is not None and artifact['integrity'] != 'mismatch':
artifact['integrity'] = 'verified'
normalized.append('evidence/' + relative)
a['requested_verdict'] = a['verdict']
a['evidence'] = normalized
if a['verdict'] in {'passed', 'failed'} and (problems or not normalized):
a['verdict'] = 'inconclusive'
if not normalized:
problems.append('No valid evidence for the asserted observation')
a['evidence_issues'] = problems
checked.append(a)
result = {
'schema_version': 1, 'run_id': run_id,
'created_at': datetime.now(timezone.utc).isoformat(),
'report_type': 'independent_combined_evidence',
'verdict': overall(checked), 'assertions': checked,
'artifacts': list(artifacts.values()),
'context': context,
'notice': 'Evidence integrity is checked; the author remains responsible for semantic assertions. '
'Original artifacts may contain internal data. This is not an Artemis native verdict.',
}
# All input validation precedes output creation. A changed source aborts rather than publishing stale hashes.
output.mkdir(parents=True, exist_ok=False)
for relative, artifact in artifacts.items():
target = output / artifact['path']
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(root / relative, target)
if digest(target) != artifact['sha256']:
raise ValueError('Evidence changed while copying; output is incomplete')
(output / 'manifest.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
live_label = '是' if real_device is True else ('否' if real_device is False else '未标注')
lines = [f'# 联合测试记录:{run_id}', '', f"结论:**{result['verdict']}**", '',
f'运行性质:**{execution_mode}**;含真机操作:**{live_label}**。', '',
'此报告关联 PC 与手机证据,不改写 Artemis 原生判定。工具执行结束不等于测试通过。', '',
'| 断言 | 必需 | 预期 | 实际 | 判定 | 证据 |', '| --- | --- | --- | --- | --- | --- |']
for a in checked:
refs_text = ', '.join(f'[{Path(p).name}]({p.replace(" ", "%20")})' for p in a['evidence'])
lines.append('| ' + ' | '.join(cell(v) for v in (
a['id'], a.get('required', True), a['expected'], a['actual'], a['verdict'], refs_text)) + ' |')
for problem in a['evidence_issues']:
lines.append(f"\n证据问题({cell(a['id'])}):{cell(problem)}\n")
lines += ['', '机器可读的运行关联信息、文件哈希及原始请求判定见 manifest.json。',
'归档包含原始证据,按内部测试资料保管;本脚本不自动向外部发送或发布。', '']
(output / 'report.md').write_text('\n'.join(lines), encoding='utf-8')
with zipfile.ZipFile(output / 'bundle.zip', 'x', zipfile.ZIP_DEFLATED) as archive:
for file in sorted(output.rglob('*')):
if file.is_file() and file != output / 'bundle.zip':
archive.write(file, file.relative_to(output).as_posix())
return result
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest='command', required=True)
b = sub.add_parser('bundle')
b.add_argument('--manifest', type=Path, required=True)
b.add_argument('--out', type=Path, required=True)
args = parser.parse_args(argv)
try:
result = bundle(args.manifest, args.out)
print(json.dumps({'execution_status': 'completed', 'verdict': result['verdict'],
'output': str(args.out.resolve()), 'assertions': len(result['assertions'])},
ensure_ascii=False))
return 0
except (OSError, ValueError, TypeError) as exc:
print(json.dumps({'execution_status': 'error', 'message': str(exc)}, ensure_ascii=False))
return 2
if __name__ == '__main__':
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8')
raise SystemExit(main())
@@ -0,0 +1,262 @@
"""Shared Windows process ownership for the plugin's fixed external tools."""
from __future__ import annotations
import os
import subprocess
import threading
import time
class ManagedProcessError(OSError):
"""Stable error with execution facts; never includes tool arguments/output."""
def __init__(self, code, *, may_have_started=False):
super().__init__(code)
self.error_code = code
self.may_have_started = may_have_started
class WindowsJob:
def __init__(self):
import ctypes
from ctypes import wintypes
class IO_COUNTERS(ctypes.Structure):
_fields_ = [
(n, ctypes.c_ulonglong)
for n in (
"ReadOperationCount",
"WriteOperationCount",
"OtherOperationCount",
"ReadTransferCount",
"WriteTransferCount",
"OtherTransferCount",
)
]
class BASIC_LIMIT(ctypes.Structure):
_fields_ = [
("PerProcessUserTimeLimit", ctypes.c_longlong),
("PerJobUserTimeLimit", ctypes.c_longlong),
("LimitFlags", wintypes.DWORD),
("MinimumWorkingSetSize", ctypes.c_size_t),
("MaximumWorkingSetSize", ctypes.c_size_t),
("ActiveProcessLimit", wintypes.DWORD),
("Affinity", ctypes.c_size_t),
("PriorityClass", wintypes.DWORD),
("SchedulingClass", wintypes.DWORD),
]
class EXTENDED_LIMIT(ctypes.Structure):
_fields_ = [
("BasicLimitInformation", BASIC_LIMIT),
("IoInfo", IO_COUNTERS),
("ProcessMemoryLimit", ctypes.c_size_t),
("JobMemoryLimit", ctypes.c_size_t),
("PeakProcessMemoryUsed", ctypes.c_size_t),
("PeakJobMemoryUsed", ctypes.c_size_t),
]
class ACCOUNTING(ctypes.Structure):
_fields_ = [
("TotalUserTime", ctypes.c_longlong),
("TotalKernelTime", ctypes.c_longlong),
("ThisPeriodTotalUserTime", ctypes.c_longlong),
("ThisPeriodTotalKernelTime", ctypes.c_longlong),
("TotalPageFaultCount", wintypes.DWORD),
("TotalProcesses", wintypes.DWORD),
("ActiveProcesses", wintypes.DWORD),
("TotalTerminatedProcesses", wintypes.DWORD),
]
self.ctypes = ctypes
self.accounting = ACCOUNTING
self.kernel = k = ctypes.WinDLL("kernel32", use_last_error=True)
self.ntdll = ctypes.WinDLL("ntdll")
k.CreateJobObjectW.argtypes = [wintypes.LPVOID, wintypes.LPCWSTR]
k.CreateJobObjectW.restype = wintypes.HANDLE
k.SetInformationJobObject.argtypes = [
wintypes.HANDLE,
ctypes.c_int,
wintypes.LPVOID,
wintypes.DWORD,
]
k.SetInformationJobObject.restype = wintypes.BOOL
k.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
k.AssignProcessToJobObject.restype = wintypes.BOOL
k.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT]
k.TerminateJobObject.restype = wintypes.BOOL
k.QueryInformationJobObject.argtypes = [
wintypes.HANDLE,
ctypes.c_int,
wintypes.LPVOID,
wintypes.DWORD,
wintypes.LPVOID,
]
k.QueryInformationJobObject.restype = wintypes.BOOL
k.CloseHandle.argtypes = [wintypes.HANDLE]
k.CloseHandle.restype = wintypes.BOOL
self.ntdll.NtResumeProcess.argtypes = [wintypes.HANDLE]
self.ntdll.NtResumeProcess.restype = ctypes.c_long
self.handle = k.CreateJobObjectW(None, None)
if not self.handle:
raise ManagedProcessError("process_ownership_failed")
limits = EXTENDED_LIMIT()
limits.BasicLimitInformation.LimitFlags = (
0x2000 # KILL_ON_JOB_CLOSE; handle is not inherited.
)
if not k.SetInformationJobObject(
self.handle, 9, ctypes.byref(limits), ctypes.sizeof(limits)
):
self.close()
raise ManagedProcessError("process_ownership_failed")
def assign(self, process):
if not self.kernel.AssignProcessToJobObject(self.handle, int(process._handle)):
raise ManagedProcessError("process_ownership_failed")
def resume(self, process):
if self.ntdll.NtResumeProcess(int(process._handle)) != 0:
raise ManagedProcessError(
"process_resume_unconfirmed", may_have_started=True
)
def stop(self):
c = self.ctypes
if not self.kernel.TerminateJobObject(self.handle, 1):
raise OSError("job_termination_unconfirmed")
deadline = time.monotonic() + 10
while True:
account = self.accounting()
if not self.kernel.QueryInformationJobObject(
self.handle, 1, c.byref(account), c.sizeof(account), None
):
raise OSError("job_cleanup_unconfirmed")
if account.ActiveProcesses == 0:
return
if time.monotonic() >= deadline:
raise OSError("job_cleanup_timeout")
time.sleep(0.02)
def close(self):
if self.handle:
handle, self.handle = self.handle, None
if not self.kernel.CloseHandle(handle):
raise OSError("job_handle_close_unconfirmed")
def run(argv, *, timeout=None, check=False, max_output_bytes=4 * 1024 * 1024, **kwargs):
"""Contain before resuming; bound pipes; reap the tree on every exit path."""
if os.name != "nt":
raise ManagedProcessError("process_ownership_requires_windows")
text = bool(
kwargs.get("text")
or kwargs.get("universal_newlines")
or kwargs.get("encoding")
or kwargs.get("errors")
)
process = None
assigned = started = False
job = WindowsJob()
buffers = {}
readers = []
output_limit = threading.Event()
read_error = threading.Event()
lock = threading.Lock()
size = 0
def read_pipe(name, pipe):
nonlocal size
buffers[name] = bytearray()
try:
while data := os.read(pipe.fileno(), 65536):
with lock:
size += len(data)
if size > max_output_bytes:
output_limit.set()
elif not output_limit.is_set():
buffers[name].extend(data)
except OSError:
read_error.set()
try:
kwargs["creationflags"] = (
kwargs.get("creationflags", 0) | subprocess.CREATE_NO_WINDOW | 0x4
)
try:
process = subprocess.Popen(argv, **kwargs)
except OSError:
raise ManagedProcessError("process_launch_failed") from None
job.assign(process)
assigned = True
started = True # Set before resume: failure cannot prove the tool did not run.
job.resume(process)
for name in ("stdout", "stderr"):
if pipe := getattr(process, name):
thread = threading.Thread(
target=read_pipe, args=(name, pipe), daemon=True
)
thread.start()
readers.append(thread)
deadline = time.monotonic() + timeout if timeout is not None else None
while process.poll() is None:
if output_limit.is_set() or read_error.is_set():
break
remaining = deadline - time.monotonic() if deadline is not None else 0.05
if remaining <= 0:
raise subprocess.TimeoutExpired(argv, timeout)
try:
process.wait(timeout=min(0.05, remaining))
except subprocess.TimeoutExpired:
continue
finally:
cleanup_failed = False
try:
if process is not None:
if assigned:
job.stop()
elif process.poll() is None:
process.kill() # Still suspended; no unowned descendants can exist.
process.wait(timeout=10)
except (OSError, subprocess.TimeoutExpired):
cleanup_failed = True
finally:
try:
job.close()
except OSError:
cleanup_failed = True
for thread in readers:
thread.join(timeout=2)
cleanup_failed |= thread.is_alive()
for name in ("stdin", "stdout", "stderr"):
if (pipe := getattr(process, name, None)) and not cleanup_failed:
try:
pipe.close()
except OSError:
cleanup_failed = True
if cleanup_failed:
raise ManagedProcessError(
"process_cleanup_unconfirmed", may_have_started=started
) from None
if output_limit.is_set():
raise ManagedProcessError("process_output_limit", may_have_started=True)
if read_error.is_set():
raise ManagedProcessError("process_output_interrupted", may_have_started=True)
output = {}
for name in ("stdout", "stderr"):
data = bytes(buffers[name]) if name in buffers else None
if text and data is not None:
pipe = getattr(process, name)
data = (
data.decode(pipe.encoding, pipe.errors)
.replace("\r\n", "\n")
.replace("\r", "\n")
)
output[name] = data
result = subprocess.CompletedProcess(
argv, process.returncode, output["stdout"], output["stderr"]
)
if check:
result.check_returncode()
return result
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,354 @@
"""Legacy V1 discovery, information and MD5+ROM upgrade on TCP 4941.
Wire contract: references/v1-protocol.md, verified against supplied Qt source.
An upgrade acknowledgment is not evidence of a completed flash or boot.
"""
from __future__ import annotations
import hashlib
import ipaddress
import json
import math
from pathlib import Path
import re
import secrets
import select
import socket
import struct
import time
INFO_PORT = 4941
DISCOVERY_PORT = 4942
INFO_COMMAND = 0x5A0E2104
UPGRADE_COMMAND = 0x5A0E2170
DISCOVERY_REQUEST = 0x5A0EFF00
DISCOVERY_RESPONSE = 0x5A0EFF01
MAX_JSON_SIZE = 1024 * 1024
INFO_FIELDS = ("code", "desc", "sn", "dev_type", "firmware", "hardware", "dev_id", "dev_ver")
DISCOVERY_FIELDS = ("session", "ip", "sn", "dev_type", "mac", "firmware", "hardware")
class ProtocolError(ValueError):
"""A malformed or mismatched V1 frame; never trigger a retry."""
def encode_frame(command: int, payload: dict | None = None) -> bytes:
data = b"" if payload is None else json.dumps(payload, separators=(",", ":")).encode("utf-8")
return struct.pack("!II", command, len(data)) + data
def decode_frame(packet: bytes, expected_command: int) -> dict:
if len(packet) < 8:
raise ProtocolError("V1 header is shorter than 8 bytes")
command, size = struct.unpack("!II", packet[:8])
if command != expected_command:
raise ProtocolError("V1 response command mismatch")
if not 0 < size <= MAX_JSON_SIZE or len(packet) != 8 + size:
raise ProtocolError("V1 response length mismatch or out of bounds")
try:
result = json.loads(packet[8:].decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ProtocolError("V1 response is not a UTF-8 JSON object") from exc
if not isinstance(result, dict):
raise ProtocolError("V1 response JSON must be an object")
return result
def _string_fields(body: dict, fields):
for field in fields:
if field in body and not isinstance(body[field], str):
raise ProtocolError("V1 field %s must be a string" % field)
def _remaining(deadline: float) -> float:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("V1 query exceeded its total timeout")
return remaining
def _recv_exact(sock, count: int, deadline: float) -> bytes:
result = bytearray()
while len(result) < count:
sock.settimeout(_remaining(deadline))
chunk = sock.recv(count - len(result))
if not chunk:
raise ProtocolError("V1 connection closed before the complete response")
result.extend(chunk)
return bytes(result)
def query_device_info(host: str, timeout: float, logger) -> dict:
deadline = time.monotonic() + timeout
logger.emit("v1_info_request", host=host, port=INFO_PORT, command=hex(INFO_COMMAND))
with socket.create_connection((host, INFO_PORT), timeout=_remaining(deadline)) as sock:
sock.settimeout(_remaining(deadline))
sock.sendall(encode_frame(INFO_COMMAND))
header = _recv_exact(sock, 8, deadline)
command, size = struct.unpack("!II", header)
if command != INFO_COMMAND or not 0 < size <= MAX_JSON_SIZE:
raise ProtocolError("V1 info response command/length is invalid")
body = decode_frame(header + _recv_exact(sock, size, deadline), INFO_COMMAND)
if type(body.get("code")) is not int or body["code"] != 0:
raise ProtocolError("V1 info response did not report integer code=0")
_string_fields(body, INFO_FIELDS[1:])
# Keep only the documented read-only fields; do not retain unexpected credentials.
data = {key: body[key] for key in INFO_FIELDS if key in body}
# A task may explicitly require a literal nested version if a firmware reports
# it. Observe that exact path only; never manufacture it from native fields.
for section in ("inner", "outer"):
nested = body.get(section)
if isinstance(nested, dict) and isinstance(nested.get("version"), str):
data[section] = {"version": nested["version"]}
response = {"protocol": "v1", "cmd": INFO_COMMAND, "code": 0, "data": data}
logger.emit("v1_device_info", response=response)
return response
def validate_identity(response: dict, expected_sn: str, expected_model: str | None):
if (not isinstance(response, dict) or response.get("protocol") != "v1"
or type(response.get("cmd")) is not int or response["cmd"] != INFO_COMMAND
or type(response.get("code")) is not int or response["code"] != 0):
raise ProtocolError("Expected an explicit V1 0x5a0e2104 response envelope")
data = response.get("data")
if not isinstance(data, dict) or type(data.get("code")) is not int or data["code"] != 0:
raise ProtocolError("V1 identity requires successful query data")
_string_fields(data, INFO_FIELDS[1:])
if not isinstance(data.get("sn"), str) or data["sn"] != expected_sn:
raise ProtocolError("V1 full SN mismatch")
model = data.get("dev_type")
models = [model] if isinstance(model, str) and model.strip() else []
if expected_model is not None and expected_model not in models:
raise ProtocolError("V1 dev_type does not exactly match the expected model")
return data, models
def _upgrade_wait(deadline: float, stall_timeout: float) -> float:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("V1 upgrade exceeded its total timeout")
return min(remaining, stall_timeout)
def transfer_rom(host: str, rom_path: Path, rom_size: int, connect_timeout: float,
stall_timeout: float, total_timeout: float, chunk_size: int,
logger, expected_sha256: str) -> dict:
"""Send one legacy 2170 stream, then require its complete JSON acknowledgment.
Data = lowercase ASCII MD5[32] + ROM. Length excludes the 8-byte frame header.
Hash and stream the same open file, bound each socket operation and the whole
transaction, and never reconnect or retry a write. Only ROM bytes count toward
bytes_sent; wire_bytes_sent also includes the 40-byte header/digest prefix.
"""
if type(rom_size) is not int or not 0 < rom_size <= 0xFFFFFFFF - 32:
raise ProtocolError("V1 ROM size is outside its U32 data length")
if type(chunk_size) is not int or not 0 < chunk_size <= 1024 * 1024:
raise ProtocolError("V1 chunk size must be in 1..1048576")
for value in (connect_timeout, stall_timeout, total_timeout):
if not math.isfinite(value) or value <= 0:
raise ProtocolError("V1 timeouts must be positive and finite")
if not isinstance(expected_sha256, str) or re.fullmatch(r"[0-9a-fA-F]{64}", expected_sha256) is None:
raise ProtocolError("V1 transfer requires the reviewed SHA-256")
expected_sha256 = expected_sha256.lower()
deadline = time.monotonic() + total_timeout
bytes_sent = wire_bytes_sent = 0
try:
with Path(rom_path).open("rb") as rom:
md5, sha256 = hashlib.md5(), hashlib.sha256()
hashed_size = 0
while True:
_upgrade_wait(deadline, stall_timeout)
chunk = rom.read(chunk_size)
if not chunk:
break
hashed_size += len(chunk)
if hashed_size > rom_size:
raise ProtocolError("V1 ROM size changed before transfer")
md5.update(chunk)
sha256.update(chunk)
if hashed_size != rom_size or sha256.hexdigest() != expected_sha256:
raise ProtocolError("V1 ROM size or SHA-256 changed before transfer")
md5_hex = md5.hexdigest()
prefix = struct.pack("!II", UPGRADE_COMMAND, 32 + rom_size) + md5_hex.encode("ascii")
rom.seek(0)
logger.emit("v1_upgrade_prepared", host=host, port=INFO_PORT,
command=hex(UPGRADE_COMMAND), data_length=32 + rom_size,
rom_size=rom_size, md5=md5_hex, sha256=expected_sha256)
with socket.create_connection((host, INFO_PORT),
timeout=_upgrade_wait(deadline, connect_timeout)) as sock:
logger.emit("v1_upgrade_connected", host=host, port=INFO_PORT)
def send_part(part, is_rom):
nonlocal bytes_sent, wire_bytes_sent
view = memoryview(part)
while view:
sock.settimeout(_upgrade_wait(deadline, stall_timeout))
count = sock.send(view)
if not 0 < count <= len(view):
raise ProtocolError("V1 connection stopped accepting upload bytes")
wire_bytes_sent += count
if is_rom:
bytes_sent += count
view = view[count:]
send_part(prefix, False)
sent_sha256 = hashlib.sha256()
next_progress = 4 * 1024 * 1024
while bytes_sent < rom_size:
chunk = rom.read(min(chunk_size, rom_size - bytes_sent))
if not chunk:
raise ProtocolError("V1 ROM ended during upload")
sent_sha256.update(chunk)
send_part(chunk, True)
if bytes_sent >= next_progress or bytes_sent == rom_size:
logger.emit("v1_upload_progress", bytes_sent=bytes_sent,
rom_size=rom_size, wire_bytes_sent=wire_bytes_sent)
next_progress = bytes_sent + 4 * 1024 * 1024
if rom.read(1) or sent_sha256.hexdigest() != expected_sha256:
raise ProtocolError("V1 ROM changed during upload; result is uncertain")
logger.emit("v1_upload_complete", bytes_sent=bytes_sent,
wire_bytes_sent=wire_bytes_sent, awaiting_acknowledgment=True)
def receive(count):
data = bytearray()
while len(data) < count:
sock.settimeout(_upgrade_wait(deadline, stall_timeout))
chunk = sock.recv(count - len(data))
if not chunk:
raise ProtocolError("V1 connection closed before the upgrade acknowledgment")
data.extend(chunk)
return bytes(data)
header = receive(8)
command, size = struct.unpack("!II", header)
if command != UPGRADE_COMMAND or not 0 < size <= MAX_JSON_SIZE:
raise ProtocolError("V1 upgrade response command/length is invalid")
body = decode_frame(header + receive(size), UPGRADE_COMMAND)
if type(body.get("code")) is not int or body["code"] != 0:
raise ProtocolError("V1 upgrade did not report integer code=0")
_string_fields(body, ("desc",))
# Retain only documented acknowledgment fields, not arbitrary data.
acknowledgment = {k: body[k] for k in ("code", "desc") if k in body}
logger.emit("v1_upgrade_acknowledged", command=hex(command),
response=acknowledgment, bytes_sent=bytes_sent)
return {"acknowledged": True, "response_command": command,
"response_code": body["code"], "response": acknowledgment,
"bytes_sent": bytes_sent, "wire_bytes_sent": wire_bytes_sent,
"md5": md5_hex, "sha256": expected_sha256}
except (OSError, ValueError) as exc:
logger.emit("v1_transfer_error", error_type=type(exc).__name__,
bytes_sent=bytes_sent, wire_bytes_sent=wire_bytes_sent,
automatic_retry=False)
raise
def parse_discovery(packet: bytes, session: int) -> dict:
body = decode_frame(packet, DISCOVERY_RESPONSE)
if type(body.get("session")) is not int or body["session"] != session:
raise ProtocolError("V1 discovery session mismatch")
if not isinstance(body.get("sn"), str) or not body["sn"].strip():
raise ProtocolError("V1 discovery requires a complete SN")
if "code" in body and (type(body["code"]) is not int or body["code"] != 0):
raise ProtocolError("V1 discovery returned a failure code")
_string_fields(body, DISCOVERY_FIELDS[1:])
return {key: body[key] for key in DISCOVERY_FIELDS if key in body}
def discover(interfaces, timeout: float, client_port: int, logger,
expected_sn=None, expected_model=None, session=None) -> dict:
"""Send ff00 to selected directed broadcasts and receive ff01 on a wildcard socket.
The specification says replies are broadcast, so binding the receiver only to a
unicast interface address can lose them. One exclusive receive socket also keeps
the same source port for all requests. Routing selects the outgoing interface for
each subnet broadcast; this does not override Windows routing policy.
"""
session = secrets.randbelow(0xFFFFFFFF) + 1 if session is None else session
frame = encode_frame(DISCOVERY_REQUEST, {"session": session})
targets = {}
for interface in interfaces:
if interface.prefix_length > 30:
logger.emit("v1_interface_skipped", address=str(interface.address),
reason="no_subnet_broadcast_for_prefix")
continue
targets.setdefault(str(interface.network.broadcast_address), []).append(str(interface.address))
if not targets:
raise ProtocolError("No V1 interface has a usable directed broadcast")
found = []
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
# Do not enable SO_REUSEADDR or stop another process to acquire this port.
if hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
sock.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
try:
sock.bind(("0.0.0.0", client_port))
except OSError as exc:
logger.emit("v1_bind_error", address="0.0.0.0", port=client_port,
error_type=type(exc).__name__, automatic_process_stop=False)
raise ProtocolError("Cannot bind V1 UDP receive port; check port ownership") from exc
sock.setblocking(False)
logger.emit("v1_socket_ready", address="0.0.0.0", client_port=client_port)
sent = 0
for target, addresses in targets.items():
try:
sock.sendto(frame, (target, DISCOVERY_PORT))
sent += 1
logger.emit("v1_broadcast_sent", selected_interface_addresses=addresses,
destination=target, port=DISCOVERY_PORT, session=session)
except OSError as exc:
logger.emit("v1_send_error", destination=target, error_type=type(exc).__name__)
if not sent:
raise ProtocolError("No V1 broadcast could be sent")
deadline = time.monotonic() + timeout
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
readable, _, _ = select.select([sock], [], [], remaining)
if not readable:
break
for ready in readable:
try:
packet, peer = ready.recvfrom(65535)
if packet == frame:
logger.emit("v1_own_request_ignored")
continue
body = parse_discovery(packet, session)
address = ipaddress.IPv4Address(peer[0])
if (address.is_unspecified or address.is_multicast or address.is_loopback
or str(address) == "255.255.255.255"):
raise ProtocolError("V1 discovery has an invalid source address")
except (OSError, ValueError) as exc:
logger.emit("v1_invalid_response", error_type=type(exc).__name__)
continue
model = body.get("dev_type")
found.append({"sn": body["sn"], "source_ip": str(address),
"source_port": peer[1], "received_on": "0.0.0.0:%d" % client_port,
"mac": body.get("mac"),
"models": [model] if isinstance(model, str) and model.strip() else [],
"response": body})
finally:
sock.close()
unique = {}
for candidate in found:
key = (candidate["sn"], candidate["source_ip"], candidate["mac"], tuple(candidate["models"]))
unique[key] = candidate
candidates = list(unique.values())
groups = {}
for candidate in candidates:
groups.setdefault(candidate["sn"], []).append(candidate)
conflicts = [sn for sn, identities in groups.items() if len(identities) > 1]
matches = [c for c in candidates if (expected_sn is None or c["sn"] == expected_sn)
and (expected_model is None or expected_model in c["models"])]
blocked = bool(conflicts or (expected_sn is not None and len(matches) != 1))
return {"status": "BLOCKED" if blocked else "DISCOVERY_COMPLETE",
"protocol": "v1", "mode": "read_only", "network_used": True,
"write_executed": False, "firmware_verified": False,
"identity_verified": bool(expected_sn is not None and len(matches) == 1 and not conflicts),
"candidate_count": len(candidates), "candidates": candidates,
"matches": [] if conflicts else matches, "match_count": 0 if conflicts else len(matches),
"identity_conflicts": conflicts, "expected_sn": expected_sn,
"expected_model": expected_model,
"reason": "identity_conflict" if conflicts else ("target_not_uniquely_found" if blocked else "read_only_discovery"),
"note": "UDP receive port is configurable; no response does not prove the device is offline."}
@@ -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())