Files
SDR-Rover/experiments/lab044_hackrf_bpsk_packet.py
2026-09-03 14:42:02 +03:00

847 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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())