fix(device-test): contain tool processes and add reviewed preview
This commit is contained in:
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())
|
||||
Reference in new issue
Block a user