841 lines
32 KiB
Python
841 lines
32 KiB
Python
"""Lab044: один произвольный protocol packet через реальный BPSK HackRF link.
|
||
|
||
Приёмный тракт принципиально не принимает ожидаемый payload и не читает
|
||
``tx_payload.bin``. Сравнение с переданными данными выполняется отдельной
|
||
диагностической стадией только после независимого frame/CRC-решения.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
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
|
||
|
||
|
||
def _preamble_reference_samples() -> np.ndarray:
|
||
waveform = _scale_and_carrier(radio.bpsk_modulate(preamble_bits()))
|
||
return signed_iq_bytes_to_complex64(lab043.to_cs8_bytes(waveform))
|
||
|
||
|
||
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())
|