"""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."}