"""Lab044: один произвольный protocol packet через реальный BPSK HackRF link. Приёмный тракт принципиально не принимает ожидаемый payload и не читает ``tx_payload.bin``. Сравнение с переданными данными выполняется отдельной диагностической стадией только после независимого frame/CRC-решения. """ from __future__ import annotations import argparse from functools import lru_cache import hashlib import json import math import secrets import struct import sys import time from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Callable if __package__ in (None, ""): sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import numpy as np from scipy.signal import fftconvolve from experiments import lab043_hackrf_bpsk as lab043 from experiments.lab043_hackrf_hardware import signed_iq_bytes_to_complex64 from experiments.lab043_pluto_to_rtlsdr import pilot_prbs7, shape_frame_like_lab042 from protocol import bpsk_radio as radio from protocol.packet import ( CRC_SIZE, HEADER_FORMAT, HEADER_SIZE, MESSAGE_TYPE_ACK, MESSAGE_TYPE_IMAGE_FRAGMENT, MESSAGE_TYPE_TELEMETRY, MESSAGE_TYPE_TEXT, PROTOCOL_VERSION, SYNC_WORD, CRCError, PacketError, build_packet, parse_packet, ) # ============================================================ Параметры Lab044 PAYLOAD_SIZE_BYTES = 256 MESSAGE_TYPE = MESSAGE_TYPE_TELEMETRY SEQUENCE_NUMBER = 44 MAX_PACKET_BYTES = 1024 PHY_SYNC = radio.RADIO_SYNC_WORD PHY_HEADER_FORMAT = ">HH" PHY_HEADER_SIZE_BYTES = struct.calcsize(PHY_HEADER_FORMAT) PHY_HEADER_BIT_COUNT = PHY_HEADER_SIZE_BYTES * 8 BIT_ORDER = "big" PREAMBLE_BIT_COUNT = lab043.PILOT_SYMBOL_COUNT PREAMBLE_INITIAL_STATE = lab043.PILOT_INITIAL_STATE DETECTION_GUARD_SYMBOLS = lab043.SECOND_PEAK_GUARD_SYMBOLS RF_HZ = lab043.RF_HZ SAMPLE_RATE_HZ = lab043.SAMPLE_RATE_HZ SYMBOL_RATE = lab043.SYMBOL_RATE SAMPLES_PER_SYMBOL = lab043.SAMPLES_PER_SYMBOL CARRIER_OFFSET_HZ = lab043.CARRIER_OFFSET_HZ TARGET_RMS_CODE = lab043.TARGET_RMS_CODE TX_VGA_DB = lab043.TX_VGA_DB RX_LNA_DB = 24 RX_VGA_DB = 24 CAPTURE_SAMPLE_COUNT = lab043.CAPTURE_SAMPLE_COUNT RX_LEAD_SECONDS = lab043.RX_LEAD_SECONDS RX_WAIT_TIMEOUT_SECONDS = lab043.RX_WAIT_TIMEOUT_SECONDS KNOWN_MESSAGE_TYPES = { MESSAGE_TYPE_TEXT, MESSAGE_TYPE_TELEMETRY, MESSAGE_TYPE_IMAGE_FRAGMENT, MESSAGE_TYPE_ACK, } CRC_PASS = "PASS" CRC_FAIL = "FAIL" CRC_NOT_REACHED = "NOT_REACHED" @dataclass(frozen=True) class TransmissionPlan: payload: bytes packet: bytes phy_header: bytes frame_bits: np.ndarray tx_cs8: bytes payload_sha256: str packet_sha256: str waveform_sha256: str @property def frame_symbol_count(self) -> int: return int(len(self.frame_bits)) @property def waveform_sample_count(self) -> int: return len(self.tx_cs8) // 2 @dataclass(frozen=True) class IndependentReception: analysis: dict packet_bytes: bytes | None payload_bytes: bytes | None PayloadFactory = Callable[[int], bytes] Analyzer = Callable[[np.ndarray], IndependentReception] DiagnosticComparer = Callable[[IndependentReception, Path, Path], dict] # ============================================================ Bytes и frame def bytes_to_bits(data: bytes) -> np.ndarray: """Преобразовать bytes в биты MSB first с явным bitorder.""" if not isinstance(data, bytes): raise TypeError("data должен иметь тип bytes") return np.unpackbits(np.frombuffer(data, dtype=np.uint8), bitorder=BIT_ORDER) def bits_to_bytes(bits: np.ndarray) -> bytes: """Упаковать одномерные 0/1 биты MSB first.""" bits = np.asarray(bits, dtype=np.uint8) if bits.ndim != 1: raise ValueError("bits должен быть одномерным") if len(bits) % 8: raise ValueError("Количество битов должно быть кратно восьми") if not np.all((bits == 0) | (bits == 1)): raise ValueError("bits должен содержать только 0 и 1") return np.packbits(bits, bitorder=BIT_ORDER).tobytes() def preamble_bits() -> np.ndarray: return pilot_prbs7(PREAMBLE_BIT_COUNT, PREAMBLE_INITIAL_STATE) def encode_phy_header(packet_length_bytes: int) -> bytes: if not 1 <= packet_length_bytes <= MAX_PACKET_BYTES: raise ValueError("Длина protocol packet вне границ Lab044") return struct.pack(PHY_HEADER_FORMAT, PHY_SYNC, packet_length_bytes) def decode_phy_header(header_bits: np.ndarray) -> dict: """Fail-closed разобрать PHY header, не выделяя память по принятой длине.""" bits = np.asarray(header_bits, dtype=np.uint8) if len(bits) < PHY_HEADER_BIT_COUNT: return {"passed": False, "reason": "TRUNCATED", "sync": None, "packet_bytes": None} try: sync, packet_bytes = struct.unpack( PHY_HEADER_FORMAT, bits_to_bytes(bits[:PHY_HEADER_BIT_COUNT]), ) except (ValueError, struct.error): return {"passed": False, "reason": "DECODE_ERROR", "sync": None, "packet_bytes": None} if sync != PHY_SYNC: return { "passed": False, "reason": "SYNC_MISMATCH", "sync": sync, "packet_bytes": packet_bytes, } if not 1 <= packet_bytes <= MAX_PACKET_BYTES: return { "passed": False, "reason": "LENGTH_OUT_OF_RANGE", "sync": sync, "packet_bytes": packet_bytes, } return {"passed": True, "reason": None, "sync": sync, "packet_bytes": packet_bytes} def _scale_and_carrier(symbols: np.ndarray) -> np.ndarray: shaped = shape_frame_like_lab042(symbols, SAMPLES_PER_SYMBOL) indexes = np.arange(len(shaped), dtype=np.float64) carriered = shaped * np.exp( 2j * np.pi * CARRIER_OFFSET_HZ * indexes / SAMPLE_RATE_HZ ) rms = float(np.sqrt(np.mean(np.abs(carriered) ** 2))) if not np.isfinite(rms) or rms <= 0.0: raise ValueError("Нулевая или нечисловая мощность формы") return carriered * (TARGET_RMS_CODE / rms) def build_transmission_plan(payload: bytes) -> TransmissionPlan: """Собрать и полностью проверить единственный произвольный Lab044 frame.""" if not isinstance(payload, bytes): raise TypeError("payload должен иметь тип bytes") if len(payload) != PAYLOAD_SIZE_BYTES: raise ValueError(f"Lab044 требует ровно {PAYLOAD_SIZE_BYTES} payload bytes") packet = build_packet(payload, MESSAGE_TYPE, SEQUENCE_NUMBER) phy_header = encode_phy_header(len(packet)) frame_bits = np.concatenate(( preamble_bits(), bytes_to_bits(phy_header), bytes_to_bits(packet), )) tx_cs8 = lab043.to_cs8_bytes(_scale_and_carrier(radio.bpsk_modulate(frame_bits))) plan = TransmissionPlan( payload=payload, packet=packet, phy_header=phy_header, frame_bits=frame_bits, tx_cs8=tx_cs8, payload_sha256=hashlib.sha256(payload).hexdigest(), packet_sha256=hashlib.sha256(packet).hexdigest(), waveform_sha256=hashlib.sha256(tx_cs8).hexdigest(), ) validate_transmission_plan(plan) return plan def validate_transmission_plan(plan: TransmissionPlan) -> None: """Закрыть TX-gate при любом внутреннем расхождении frame/packet.""" expected_packet_bytes = HEADER_SIZE + PAYLOAD_SIZE_BYTES + CRC_SIZE if len(plan.packet) != expected_packet_bytes: raise ValueError(f"Protocol packet имеет {len(plan.packet)}, ожидалось {expected_packet_bytes}") if parse_packet(plan.packet).payload != plan.payload: raise ValueError("Protocol packet round trip не восстановил payload") if bits_to_bytes(bytes_to_bits(plan.packet)) != plan.packet: raise ValueError("MSB-first packet bits round trip не совпал") header = decode_phy_header(bytes_to_bits(plan.phy_header)) if not header["passed"] or header["packet_bytes"] != len(plan.packet): raise ValueError("PHY header round trip не совпал") expected_frame_symbols = PREAMBLE_BIT_COUNT + PHY_HEADER_BIT_COUNT + len(plan.packet) * 8 if plan.frame_symbol_count != expected_frame_symbols: raise ValueError("Число frame symbols не совпало с форматом") if len(plan.tx_cs8) != 2 * plan.waveform_sample_count or not plan.tx_cs8: raise ValueError("Некорректная cs8-форма") tx_codes = np.frombuffer(plan.tx_cs8, dtype=np.int8) if int(np.max(np.abs(tx_codes.astype(np.int16)))) >= 127: raise ValueError("TX waveform касается предела signed int8") # ============================================================ Независимый RX @lru_cache(maxsize=1) def _preamble_reference_samples() -> np.ndarray: """Вернуть неизменяемую опорную преамбулу без повторного формирования.""" waveform = _scale_and_carrier(radio.bpsk_modulate(preamble_bits())) reference = signed_iq_bytes_to_complex64(lab043.to_cs8_bytes(waveform)) reference.flags.writeable = False return reference def _detect_preamble(samples: np.ndarray) -> dict: reference = _preamble_reference_samples() rx_decimated, reference_decimated = lab043._detection_streams(samples, reference) scores = lab043._normalized_sliding_correlation(rx_decimated, reference_decimated) guard = ( DETECTION_GUARD_SYMBOLS * SAMPLES_PER_SYMBOL // lab043.DETECTION_DECIMATION ) metrics = lab043._peak_metrics(scores, guard) detected = bool( metrics["over_median_db"] >= lab043.PREAMBLE_OVER_MEDIAN_DB_MIN and metrics["over_second_db"] >= lab043.PREAMBLE_OVER_SECOND_DB_MIN ) coarse_start = metrics["peak_index"] * lab043.DETECTION_DECIMATION low = max(0, coarse_start - 2_000) high = min(len(samples), coarse_start + len(reference) + 2_000) local = samples[low:high] - np.mean(samples) centered_reference = reference - np.mean(reference) if len(local) < len(centered_reference): refined_start = coarse_start else: correlation = fftconvolve(local, np.conj(centered_reference[::-1]), mode="valid") refined_start = low + int(np.argmax(np.abs(correlation))) return { "preamble_detected": detected, "method": "global PRBS7 preamble-only normalized correlation", "payload_used": False, "peak": metrics["peak"], "median": metrics["median"], "over_median_db": metrics["over_median_db"], "over_second_db": metrics["over_second_db"], "coarse_start_sample": coarse_start, "refined_start_sample": refined_start, } def inspect_protocol_header(packet_bytes: bytes) -> dict: """Проверить существующий protocol header отдельно от CRC.""" minimum = HEADER_SIZE + CRC_SIZE if len(packet_bytes) < minimum: return {"passed": False, "reason": "TRUNCATED", "payload_length": None} try: sync, version, message_type, sequence_number, payload_length = struct.unpack( HEADER_FORMAT, packet_bytes[:HEADER_SIZE], ) except struct.error: return {"passed": False, "reason": "DECODE_ERROR", "payload_length": None} expected_size = HEADER_SIZE + payload_length + CRC_SIZE reason = None if sync != SYNC_WORD: reason = "SYNC_MISMATCH" elif version != PROTOCOL_VERSION: reason = "VERSION_MISMATCH" elif message_type not in KNOWN_MESSAGE_TYPES: reason = "MESSAGE_TYPE_INVALID" elif expected_size != len(packet_bytes): reason = "PAYLOAD_LENGTH_MISMATCH" return { "passed": reason is None, "reason": reason, "sync": sync, "version": version, "message_type": message_type, "sequence_number": sequence_number, "payload_length": payload_length, "expected_packet_bytes": expected_size, } def _empty_analysis(raw: dict, detection: dict) -> dict: return { "phy_sync_pass": False, "frame_header_pass": False, "packet_received": False, "protocol_header_pass": False, "crc": CRC_NOT_REACHED, "classification": "D", "raw": raw, "detection": detection, "frame_header": None, "protocol_header": None, "carrier": None, } def _capture_diagnostics( samples: np.ndarray, detection: dict, packet_bytes_hint: int | None, ) -> dict: """BEFORE/DURING/AFTER статистика записи вокруг независимо найденного кадра. Переиспользует ``lab043._capture_intervals`` и ``lab043.raw_statistics`` без изменения самого Lab043. Граница DURING строится только по независимо найденному ``detection["refined_start_sample"]`` и по длине кадра: если PHY LENGTH уже декодирован (``packet_bytes_hint``), длина точная; иначе используется максимально возможная длина кадра Lab044. Payload при этом никогда не используется. Если независимый preamble не найден, DURING остаётся неопределённым и явно помечается как таковой. """ if not detection.get("preamble_detected"): return { "before": None, "during": None, "after": None, "frame_start_sample": None, "frame_end_sample": None, "note": "DURING interval не определён: независимый preamble не найден", "whole_capture": lab043.raw_statistics(samples), } frame_start = int(detection["refined_start_sample"]) if packet_bytes_hint is not None and 1 <= packet_bytes_hint <= MAX_PACKET_BYTES: frame_symbols = PREAMBLE_BIT_COUNT + PHY_HEADER_BIT_COUNT + packet_bytes_hint * 8 note = "DURING построен по независимому preamble start и декодированной PHY LENGTH" else: frame_symbols = PREAMBLE_BIT_COUNT + PHY_HEADER_BIT_COUNT + MAX_PACKET_BYTES * 8 note = ( "DURING построен по независимому preamble start и максимально возможной " "длине кадра Lab044 (PHY header ещё не восстановлен)" ) frame_length_samples = frame_symbols * SAMPLES_PER_SYMBOL intervals = lab043._capture_intervals(samples, frame_start, frame_length_samples) sample_rate = float(SAMPLE_RATE_HZ) region_bounds = { "before": (0, intervals["frame_start_sample"]), "during": (intervals["frame_start_sample"], intervals["frame_end_sample"]), "after": (intervals["frame_end_sample"], int(len(samples))), } for region_key, (start, end) in region_bounds.items(): stats = intervals[region_key] if stats is None: continue stats["sample_range"] = [int(start), int(end)] stats["duration_seconds"] = (end - start) / sample_rate intervals["note"] = note return intervals def _artifact_sha256(output_directory: Path, filenames: list[str]) -> dict[str, str | None]: """SHA-256 каждого реально существующего файла; None для несозданных. Никогда не создаёт файл ради вычисления хэша: если файла нет на диске (например, rx_payload.bin не был извлечён), значение — None. Переиспользует lab043._sha256_file. """ hashes: dict[str, str | None] = {} for name in filenames: path = output_directory / name hashes[name] = lab043._sha256_file(path) if path.is_file() else None return hashes def analyze_capture_independently(samples: np.ndarray) -> IndependentReception: """Принять Lab044 frame без ожидаемого packet или payload.""" samples = np.asarray(samples, dtype=np.complex64) raw = lab043.raw_statistics(samples) detection = _detect_preamble(samples) if not detection["preamble_detected"]: analysis = _empty_analysis(raw, detection) analysis["capture_intervals"] = _capture_diagnostics(samples, detection, None) return IndependentReception(analysis, None, None) maximum_frame_symbols = PREAMBLE_BIT_COUNT + PHY_HEADER_BIT_COUNT + MAX_PACKET_BYTES * 8 maximum_waveform_samples = ( 2 * 16 * SAMPLES_PER_SYMBOL + maximum_frame_symbols * SAMPLES_PER_SYMBOL + 10 * SAMPLES_PER_SYMBOL ) matched, nominal = lab043._matched_waveform( samples, detection["refined_start_sample"], maximum_waveform_samples, ) expected_preamble = radio.bpsk_modulate(preamble_bits()) timing, _ = lab043._timing_offsets(matched, nominal, expected_preamble) carrier = lab043._evaluate_symbols( matched, nominal, timing, preamble_bits(), PREAMBLE_BIT_COUNT, ) available_symbols = matched[ nominal + timing : nominal + timing + maximum_frame_symbols * SAMPLES_PER_SYMBOL : SAMPLES_PER_SYMBOL ].astype(np.complex128) if carrier["abs_h"] == 0.0: analysis = _empty_analysis(raw, detection) analysis["classification"] = "C" analysis["carrier"] = carrier analysis["capture_intervals"] = _capture_diagnostics(samples, detection, None) return IndependentReception(analysis, None, None) symbol_time = (np.arange(len(available_symbols)) + 0.5) / SYMBOL_RATE normalized = ( available_symbols * np.exp(-2j * np.pi * carrier["cfo_hz"] * symbol_time) / (carrier["abs_h"] * np.exp(1j * carrier["phase_rad"])) ) received_bits = (normalized.real >= 0.0).astype(np.uint8) preamble_error_count = int(np.count_nonzero( received_bits[:PREAMBLE_BIT_COUNT] != preamble_bits() )) phy_sync_pass = bool( raw["clipping_count"] == 0 and raw["near_int8_count"] == 0 and len(received_bits) >= PREAMBLE_BIT_COUNT + PHY_HEADER_BIT_COUNT ) header_start = PREAMBLE_BIT_COUNT header_end = header_start + PHY_HEADER_BIT_COUNT frame_header = decode_phy_header(received_bits[header_start:header_end]) if not frame_header["passed"]: analysis = { **_empty_analysis(raw, detection), "phy_sync_pass": phy_sync_pass, "classification": "C", "frame_header": frame_header, "carrier": carrier, "preamble_errors": preamble_error_count, "timing_offset_samples": timing, "capture_intervals": _capture_diagnostics(samples, detection, frame_header.get("packet_bytes")), } return IndependentReception(analysis, None, None) packet_length = int(frame_header["packet_bytes"]) packet_start = header_end packet_end = packet_start + packet_length * 8 if len(received_bits) < packet_end: frame_header = {**frame_header, "passed": False, "reason": "TRUNCATED_PACKET"} analysis = { **_empty_analysis(raw, detection), "phy_sync_pass": phy_sync_pass, "classification": "C", "frame_header": frame_header, "carrier": carrier, "preamble_errors": preamble_error_count, "timing_offset_samples": timing, "capture_intervals": _capture_diagnostics(samples, detection, packet_length), } return IndependentReception(analysis, None, None) packet_bytes = bits_to_bytes(received_bits[packet_start:packet_end]) protocol_header = inspect_protocol_header(packet_bytes) crc_status = CRC_NOT_REACHED payload_bytes: bytes | None = None if protocol_header["passed"]: payload_length = int(protocol_header["payload_length"]) payload_bytes = packet_bytes[HEADER_SIZE : HEADER_SIZE + payload_length] try: parsed = parse_packet(packet_bytes) except CRCError: crc_status = CRC_FAIL except PacketError as error: protocol_header = { **protocol_header, "passed": False, "reason": f"PACKET_ERROR: {error}", } else: crc_status = CRC_PASS payload_bytes = parsed.payload if crc_status == CRC_PASS: classification = "A" elif crc_status == CRC_FAIL and protocol_header["passed"]: classification = "B" else: classification = "C" analysis = { "phy_sync_pass": phy_sync_pass, "frame_header_pass": True, "packet_received": True, "protocol_header_pass": bool(protocol_header["passed"]), "crc": crc_status, "classification": classification, "raw": raw, "detection": detection, "frame_header": frame_header, "protocol_header": protocol_header, "carrier": carrier, "preamble_errors": preamble_error_count, "timing_offset_samples": timing, "received_symbol_count": int(len(received_bits)), "payload_known_before_decode": False, "capture_intervals": _capture_diagnostics(samples, detection, packet_length), } return IndependentReception(analysis, packet_bytes, payload_bytes) # ============================================================ Диагностика после CRC def _comparison(received: bytes | None, expected: bytes) -> dict: if received is None or len(received) != len(expected): return { "comparable": False, "expected_bytes": len(expected), "received_bytes": None if received is None else len(received), "bit_errors": None, "ber": None, "byte_errors": None, "byte_error_rate": None, "first_differing_byte_positions": [], "bit_error_run_lengths": [], } expected_array = np.frombuffer(expected, dtype=np.uint8) received_array = np.frombuffer(received, dtype=np.uint8) xor = np.bitwise_xor(expected_array, received_array) differing_bytes = np.flatnonzero(xor) error_bits = bytes_to_bits(xor.tobytes()) error_positions = np.flatnonzero(error_bits) runs: list[int] = [] if len(error_positions): run_length = 1 for previous, current in zip(error_positions[:-1], error_positions[1:]): if current == previous + 1: run_length += 1 else: runs.append(run_length) run_length = 1 runs.append(run_length) bit_errors = int(np.count_nonzero(error_bits)) byte_errors = int(len(differing_bytes)) return { "comparable": True, "expected_bytes": len(expected), "received_bytes": len(received), "bit_errors": bit_errors, "ber": bit_errors / (8 * len(expected)) if expected else 0.0, "byte_errors": byte_errors, "byte_error_rate": byte_errors / len(expected) if expected else 0.0, "first_differing_byte_positions": differing_bytes[:20].astype(int).tolist(), "bit_error_run_lengths": runs, } def diagnostic_compare_after_decode( reception: IndependentReception, tx_payload_path: Path, tx_packet_path: Path, ) -> dict: """Только теперь прочитать TX artifacts и сравнить без исправления RX.""" tx_packet = tx_packet_path.read_bytes() tx_payload = tx_payload_path.read_bytes() return { "stage": "AFTER_INDEPENDENT_CRC_DECISION", "receiver_knew_payload_before_decode": False, "packet": _comparison(reception.packet_bytes, tx_packet), "payload": _comparison(reception.payload_bytes, tx_payload), } def analyze_then_compare( samples: np.ndarray, tx_payload_path: Path, tx_packet_path: Path, analyzer: Analyzer = analyze_capture_independently, comparer: DiagnosticComparer = diagnostic_compare_after_decode, ) -> IndependentReception: """Зафиксировать порядок: независимый decode, затем чтение TX artifacts.""" reception = analyzer(samples) reception.analysis["diagnostic_comparison"] = comparer( reception, tx_payload_path, tx_packet_path, ) return reception # ============================================================ Hardware def default_output_directory() -> Path: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") return Path.home() / "Documents" / "SDR_Rover_Lab044_raw" / f"{timestamp}_bpsk_packet" def run_hardware_experiment( output_directory: Path, runner: lab043.CommandRunner | None = None, process_starter: lab043.ProcessStarter | None = None, sleep_function: lab043.SleepFunction = time.sleep, payload_factory: PayloadFactory = secrets.token_bytes, analyzer: Analyzer = analyze_capture_independently, comparer: DiagnosticComparer = diagnostic_compare_after_decode, ) -> dict: """Один fail-closed hardware packet: preflight, RX, один TX, decode.""" runner = runner or lab043._default_command_runner process_starter = process_starter or lab043._default_process_starter payload = payload_factory(PAYLOAD_SIZE_BYTES) plan = build_transmission_plan(payload) validate_transmission_plan(plan) output_directory.mkdir(parents=True, exist_ok=True) tx_payload_path = output_directory / "tx_payload.bin" tx_packet_path = output_directory / "tx_packet.bin" tx_waveform_path = output_directory / "tx_bpsk_cs8.iq" tx_payload_path.write_bytes(plan.payload) tx_packet_path.write_bytes(plan.packet) tx_waveform_path.write_bytes(plan.tx_cs8) guard = lab043.TransmitGuard() rx_process: lab043.RunningCommand | None = None try: preflight = lab043.common_clock_preflight(runner, guard) rx_path = output_directory / "rx_cs8.iq" rx_stdout_path = output_directory / "rx_stdout.txt" rx_stderr_path = output_directory / "rx_stderr.txt" tx_stdout_path = output_directory / "tx_stdout.txt" tx_stderr_path = output_directory / "tx_stderr.txt" transfer_tool = str(lab043.HACKRF_TOOLS_DIR / "hackrf_transfer.exe") rx_command = [ transfer_tool, "-d", lab043.SLAVE_SERIAL, "-r", str(rx_path), "-f", str(RF_HZ), "-s", str(SAMPLE_RATE_HZ), "-n", str(CAPTURE_SAMPLE_COUNT), "-l", str(RX_LNA_DB), "-g", str(RX_VGA_DB), "-a", "0", "-p", "0", "-B", ] rx_process = process_starter(rx_command, rx_stdout_path, rx_stderr_path) sleep_function(RX_LEAD_SECONDS) if rx_process.poll() is not None: rx_process.close_logs() detail = rx_stderr_path.read_text(encoding="utf-8", errors="replace") raise RuntimeError(f"RX завершился до разрешения TX: {detail.strip()}") tx_command = [ transfer_tool, "-d", lab043.MASTER_SERIAL, "-t", str(tx_waveform_path), "-f", str(RF_HZ), "-s", str(SAMPLE_RATE_HZ), "-n", str(plan.waveform_sample_count), "-x", str(TX_VGA_DB), "-a", "0", "-p", "0", "-B", ] guard.register_transmit() tx_result = runner(tx_command) tx_stdout_path.write_text(tx_result.stdout or "", encoding="utf-8") tx_stderr_path.write_text(tx_result.stderr or "", encoding="utf-8") if tx_result.returncode != 0: raise RuntimeError(f"Единственный TX завершился с кодом {tx_result.returncode}") rx_returncode = rx_process.wait(RX_WAIT_TIMEOUT_SECONDS) if rx_returncode != 0: raise RuntimeError(f"RX завершился с кодом {rx_returncode}") expected_size = CAPTURE_SAMPLE_COUNT * 2 if rx_path.stat().st_size != expected_size: raise RuntimeError("Размер raw не совпал с числом запрошенных samples") raw_sha_first = lab043._sha256_file(rx_path) raw_sha_second = lab043._sha256_file(rx_path) if raw_sha_first != raw_sha_second: raise RuntimeError("SHA-256 raw изменился после закрытия") rx_log = rx_stdout_path.read_text(encoding="utf-8", errors="replace") + rx_stderr_path.read_text( encoding="utf-8", errors="replace" ) tx_log = (tx_result.stdout or "") + (tx_result.stderr or "") if not lab043._transfer_log_is_clean(rx_log): raise RuntimeError("RX log содержит overrun/underrun") if not lab043._transfer_log_is_clean(tx_log): raise RuntimeError("TX log содержит overrun/underrun") samples = lab043.load_cs8(rx_path) reception = analyze_then_compare( samples, tx_payload_path, tx_packet_path, analyzer=analyzer, comparer=comparer, ) if reception.packet_bytes is not None: (output_directory / "rx_packet.bin").write_bytes(reception.packet_bytes) if reception.payload_bytes is not None: (output_directory / "rx_payload.bin").write_bytes(reception.payload_bytes) metadata = { "payload_bytes": len(plan.payload), "protocol_packet_bytes": len(plan.packet), "phy_header_bits": PHY_HEADER_BIT_COUNT, "total_frame_symbols": plan.frame_symbol_count, "total_waveform_samples": plan.waveform_sample_count, "tx_duration_seconds": plan.waveform_sample_count / SAMPLE_RATE_HZ, "bit_order": "MSB first", "byte_order": "big-endian/network order", "max_packet_bytes": MAX_PACKET_BYTES, "payload_sha256": plan.payload_sha256, "packet_sha256": plan.packet_sha256, "waveform_sha256": plan.waveform_sha256, "raw": { "bytes": expected_size, "complex_samples": CAPTURE_SAMPLE_COUNT, "duration_seconds": CAPTURE_SAMPLE_COUNT / SAMPLE_RATE_HZ, "sha256_first": raw_sha_first, "sha256_second": raw_sha_second, }, "rf": { "center_hz": RF_HZ, "sample_rate_hz": SAMPLE_RATE_HZ, "symbol_rate": SYMBOL_RATE, "digital_carrier_hz": CARRIER_OFFSET_HZ, "tx_vga_db": TX_VGA_DB, "rx_lna_db": RX_LNA_DB, "rx_vga_db": RX_VGA_DB, "rf_amp": False, "antenna_power": False, }, "preflight": preflight, "rf_tx_count": guard.transmit_calls, "artifact_sha256": _artifact_sha256( output_directory, [ "tx_payload.bin", "tx_packet.bin", "tx_bpsk_cs8.iq", "rx_cs8.iq", "rx_packet.bin", "rx_payload.bin", ], ), } (output_directory / "metadata.json").write_text( json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8" ) (output_directory / "analysis.json").write_text( json.dumps(reception.analysis, ensure_ascii=False, indent=2), encoding="utf-8" ) return { "classification": reception.analysis["classification"], "analysis": reception.analysis, "metadata": metadata, "output_directory": str(output_directory), } finally: if rx_process is not None: try: rx_process.terminate() except Exception: pass lab043._disable_clock_outputs(runner) def main() -> int: parser = argparse.ArgumentParser(description="Lab044 arbitrary BPSK packet + CRC") parser.add_argument("--hardware", action="store_true") parser.add_argument("--i-understand-hardware", action="store_true") parser.add_argument("--output-directory", type=Path) arguments = parser.parse_args() if not arguments.hardware: parser.print_help() return 0 if not arguments.i_understand_hardware: print("Аппаратный режим заблокирован без --i-understand-hardware") return 2 result = run_hardware_experiment(arguments.output_directory or default_output_directory()) print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())