diff --git a/PROJECT_LOG.md b/PROJECT_LOG.md index 548c190..4256814 100644 --- a/PROJECT_LOG.md +++ b/PROJECT_LOG.md @@ -434,3 +434,57 @@ git version 2.51.1.windows.1 ## Следующий этап Криптографическая аутентификация отложена. Сначала выполняется этап стабилизации: быстрые тесты `protocol/` вместо единственной точки входа через лабораторную, разделение быстрых проверок и экспериментов, воспроизводимое описание окружения. + +--- + +# Запись 018 + +## Дата + +10 августа 2026 года + +## Тема + +Выбор радиочастотной архитектуры: рабочий диапазон, скачки по частоте и меры против подавления. + +## Исходные данные + +- Устройство опрошено напрямую: `Analog Devices PlutoSDR Rev.C (Z7010-AD9361)`. Приёмник перестраивается от 70 МГц до 6 ГГц, передатчик от 46,9 МГц. Минимальная частота дискретизации 2 083 333 Гц. +- Ограничений по разрешённым частотам у проекта нет. +- Усилителя мощности нет, покупка отложена до первых испытаний. + +## Отвергнутые варианты и причины + +**Выбор редкой фиксированной частоты.** Рассматривался как защита от подавления. Отклонён: одиночная несущая в пустом эфире обнаруживается сканирующим приёмником тривиально. Уязвимость создаёт не значение частоты, а её неизменность. + +**Скачки по всему диапазону 70 МГц – 6 ГГц.** Теоретически требуют от заградительной помехи на 42 дБ больше мощности, но выигрыш недостижим. Отношение крайних частот 86:1, одна антенна такой полосы не перекрывает; разброс потерь на 10 км составляет 39 дБ, проектировать пришлось бы по худшему краю; выше примерно 1 ГГц дифракция исчезает и для наземного ровера без прямой видимости эти каналы мертвы. Кроме того, подавление направлено на диапазон, в котором связь действительно работает, а не на тот, который способно настроить оборудование. + +**Диапазоны 330–433 МГц.** Отклонены как плотно занятые и общеизвестные. + +## Принято + +Рабочий диапазон **200–250 МГц**. Обоснование: четвертьволновая антенна 33 см пригодна для установки на ровер, дифракция ещё эффективна, участок заметно свободнее любительского 144 МГц и УКВ выше 300 МГц. Потери на 10 км составляют около 99,5 дБ. + +Совокупность мер против подавления: + +1. **Скачки по частоте в пределах 200–250 МГц.** Требуют от заградительной помехи на 22 дБ больше мощности при неизменной антенне и механике ровера. +2. **Направленная антенна наземной станции.** Даёт 10–20 дБ усиления и пространственно подавляет помеху, приходящую с иного направления. +3. **Расширение спектра для команд управления.** Поток команд составляет 10,88 кбит/с, поэтому расширение для него реализуемо в узкой полосе, в отличие от видеопотока. + +Видео остаётся некритичным классом: при подавлении управление сохраняется в узкой полосе, а ровер переходит к безопасному поведению по уже реализованной логике. + +## Запрещённые участки + +- 108–137 МГц — авиационная связь. +- 243 МГц — международная аварийная частота. + +## Ограничения решения + +- Нижняя граница 70 МГц определяется приёмником Pluto и без смены оборудования не преодолевается. +- Скачки требуют согласованной последовательности у обеих сторон, то есть зависят от криптографической аутентификации, которая пока не реализована. +- Расчёты выполнены для свободного пространства. Потери на препятствиях 20–40 дБ учтены оценочно и подлежат измерению. +- Дальность 10 км без усилителя мощности не достигается. + +## Влияние на ближайшие работы + +На Lab042 не влияет: там кабельная петля, несущая 435 МГц выбрана произвольно. Диапазон 200–250 МГц вступает в силу начиная с эфирных испытаний. diff --git a/tests/test_control_messages.py b/tests/test_control_messages.py new file mode 100644 index 0000000..c34bb3e --- /dev/null +++ b/tests/test_control_messages.py @@ -0,0 +1,155 @@ +"""Быстрые проверки формата управляющих сообщений: protocol/control_messages.py. + +Перенесено из функциональных проверок Lab041, где эти свойства +проверялись только при полном запуске лабораторной. +""" + +from __future__ import annotations + +import pytest + +from protocol.control_messages import ( + COMMON_SIZE, + ControlMessageError, + ControlStateMessage, + EmergencyAck, + EmergencyStop, + MessageContext, + MessageType, + MovementAuthorizeAck, + MovementAuthorizeRequest, + ResetAck, + ResetRequest, + RoverSafetyCode, + SequenceComparison, + SessionHello, + SessionReject, + SessionRejectReason, + SessionStatus, + compare_sequence, + decode_message, + encode_message, +) + + +CONTEXT = MessageContext(11, 22, 33, 44) + +ALL_MESSAGES = ( + SessionHello(CONTEXT, True, 101), + SessionStatus(CONTEXT, RoverSafetyCode.ROVER_SESSION_SYNCED_SAFE, False, 0), + MovementAuthorizeRequest(CONTEXT, 201), + MovementAuthorizeAck(CONTEXT, 201, True), + ControlStateMessage(CONTEXT, 1.25, -0.5, False, True), + EmergencyStop(CONTEXT, 301), + EmergencyAck(CONTEXT, 301), + ResetRequest(CONTEXT, 301, 401, 0.0, False), + ResetAck(CONTEXT, 301, 401, True, SessionRejectReason.ACCEPTED), + SessionReject(CONTEXT, MessageType.CONTROL_STATE, SessionRejectReason.CONTROL_EPOCH), +) + + +@pytest.mark.parametrize("message", ALL_MESSAGES, ids=lambda m: type(m).__name__) +def test_roundtrip_is_exact(message) -> None: + assert decode_message(encode_message(message)) == message + + +def test_every_message_type_is_covered() -> None: + """Если добавится тип сообщения, этот тест обязан упасть.""" + + assert len(ALL_MESSAGES) == len(MessageType) + + +@pytest.mark.parametrize("message", ALL_MESSAGES, ids=lambda m: type(m).__name__) +def test_encoding_starts_with_the_common_header(message) -> None: + encoded = encode_message(message) + assert len(encoded) >= COMMON_SIZE + assert encoded[0] == 1 + + +def test_wrong_version_is_rejected() -> None: + payload = bytearray(encode_message(SessionHello(CONTEXT, False, 0))) + payload[0] = 2 + with pytest.raises(ControlMessageError): + decode_message(bytes(payload)) + + +def test_unknown_type_is_rejected() -> None: + payload = bytearray(encode_message(SessionHello(CONTEXT, False, 0))) + payload[1] = 255 + with pytest.raises(ControlMessageError): + decode_message(bytes(payload)) + + +def test_truncated_message_is_rejected() -> None: + payload = encode_message(SessionHello(CONTEXT, False, 0)) + for cut in (0, 1, COMMON_SIZE - 1, len(payload) - 1): + with pytest.raises(ControlMessageError): + decode_message(payload[:cut]) + + +# ------------------------------------------------------- диапазоны команд движения + + +@pytest.mark.parametrize( + "speed,turn,braking,permitted", + [ + (15.1, 0.0, False, True), # выше предела + (-0.1, 0.0, False, True), # отрицательная скорость + (-5.0, 0.0, False, True), + (1.0, 1.1, False, True), # поворот вне диапазона + (1.0, -1.1, False, True), + (1.0, 0.0, True, False), # торможение при ненулевой скорости + (1.0, 0.0, False, False), # движение без разрешения + ], +) +def test_invalid_control_state_is_rejected(speed, turn, braking, permitted) -> None: + with pytest.raises(ControlMessageError): + encode_message(ControlStateMessage(CONTEXT, speed, turn, braking, permitted)) + + +@pytest.mark.parametrize( + "speed,turn,braking,permitted", + [ + (0.0, 0.0, True, False), + (15.0, 0.0, False, True), + (0.0, 0.0, False, False), + (7.5, 1.0, False, True), + (7.5, -1.0, False, True), + ], +) +def test_valid_control_state_is_accepted(speed, turn, braking, permitted) -> None: + message = ControlStateMessage(CONTEXT, speed, turn, braking, permitted) + assert decode_message(encode_message(message)) == message + + +@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")]) +def test_non_finite_speed_is_rejected(bad: float) -> None: + with pytest.raises(ControlMessageError): + encode_message(ControlStateMessage(CONTEXT, bad, 0.0, False, True)) + + +# ------------------------------------------------------- сравнение номеров сообщений + + +def test_sequence_comparison_basic_order() -> None: + assert compare_sequence(5, 4) is SequenceComparison.NEWER + assert compare_sequence(4, 5) is SequenceComparison.NOT_NEWER + assert compare_sequence(4, 4) is SequenceComparison.NOT_NEWER + + +def test_sequence_comparison_wraps_at_32_bits() -> None: + """Переход через 0xFFFFFFFF обязан считаться новым, а не откатом.""" + + assert compare_sequence(0, 0xFFFFFFFF) is SequenceComparison.NEWER + assert compare_sequence(1, 0xFFFFFFFE) is SequenceComparison.NEWER + + +def test_ambiguous_half_range_is_rejected() -> None: + """Разность ровно 2^31 неотличима от отката и должна отклоняться.""" + + assert compare_sequence(0x80000000, 0) is SequenceComparison.AMBIGUOUS + assert compare_sequence(0, 0x80000000) is SequenceComparison.AMBIGUOUS + + +def test_just_below_the_ambiguous_boundary_is_newer() -> None: + assert compare_sequence(0x7FFFFFFF, 0) is SequenceComparison.NEWER diff --git a/tests/test_session_state.py b/tests/test_session_state.py new file mode 100644 index 0000000..f0e3e6f --- /dev/null +++ b/tests/test_session_state.py @@ -0,0 +1,288 @@ +"""Проверки автоматов сеанса: protocol/session_state.py. + +Свойства безопасности, перенесённые из функциональных проверок Lab041. +До этого они проверялись только при полном запуске лабораторной на 180 +сочетаний, а пять из них в отчёте вообще не измерялись. +""" + +from __future__ import annotations + +import pytest + +from protocol.control_messages import ( + ControlStateMessage, + MessageContext, + ResetRequest, + SequenceComparison, + compare_sequence, +) +from protocol.session_state import ( + GroundSessionController, + GroundSessionState, + RejectionCause, + RoverSessionController, + RoverSessionState, + deterministic_identifier, +) + + +CONTROL_STREAM_ID = 20 + + +def active_pair(seed: int) -> tuple[GroundSessionController, RoverSessionController]: + """Довести обе стороны до состояния, в котором разрешено движение.""" + + ground = GroundSessionController(100 + seed) + rover = RoverSessionController(200 + seed) + status = rover.receive_hello(ground.start_synchronization()) + assert ground.receive_status(status) + acknowledgement = rover.authorize_movement(ground.request_movement_authorization(1)) + assert ground.receive_movement_authorize_ack(acknowledgement) + return ground, rover + + +def control_from(ground, rover, sequence_number: int, speed: float = 1.0) -> ControlStateMessage: + context = MessageContext(ground.ground_session_id, rover.rover_boot_id, rover.control_epoch, sequence_number) + return ControlStateMessage(context, speed, 0.0, False, True) + + +# ------------------------------------------------------------------ безопасный запуск + + +def test_both_sides_boot_without_movement() -> None: + ground = GroundSessionController(7) + rover = RoverSessionController(8) + assert ground.state is GroundSessionState.GROUND_BOOT + assert not ground.movement_permitted + assert rover.state is RoverSessionState.ROVER_BOOT_SAFE + assert rover.safe + + +def test_session_status_alone_does_not_permit_movement() -> None: + ground = GroundSessionController(9) + rover = RoverSessionController(10) + assert ground.receive_status(rover.receive_hello(ground.start_synchronization())) + assert ground.state is GroundSessionState.GROUND_SAFE_SYNCHRONIZED + assert not ground.movement_permitted + assert rover.safe + + +def test_authorization_ack_does_not_restore_motion() -> None: + ground, rover = active_pair(11) + assert ground.current_speed_mps == 0.0 + assert not ground.movement_permitted + assert rover.safe + + +def test_movement_requires_a_new_operator_command() -> None: + ground, rover = active_pair(12) + assert rover.safe + assert rover.receive_control(ground.new_operator_control(1.0), CONTROL_STREAM_ID).accepted + assert rover.speed_mps == 1.0 + + +def test_restarted_rover_is_immediately_safe() -> None: + ground, rover = active_pair(13) + rover.receive_control(ground.new_operator_control(1.0), CONTROL_STREAM_ID) + restarted = RoverSessionController(999) + assert restarted.state is RoverSessionState.ROVER_BOOT_SAFE + assert restarted.safe + + +def test_new_ground_session_stops_the_old_command_stream() -> None: + old_ground, rover = active_pair(14) + old_command = old_ground.new_operator_control(1.0) + new_ground = GroundSessionController(1000) + rover.receive_hello(new_ground.start_synchronization()) + decision = rover.receive_control(old_command, CONTROL_STREAM_ID) + assert decision.reason is RejectionCause.GROUND_SESSION_ID + + +def test_duplicate_hello_is_idempotent() -> None: + ground = GroundSessionController(30) + rover = RoverSessionController(31) + hello = ground.start_synchronization() + first = rover.receive_hello(hello) + second = rover.receive_hello(hello) + assert first == second + assert rover.duplicate_hellos == 1 + + +# ------------------------------------------------------------------ старые пакеты + + +@pytest.mark.parametrize( + "field,expected", + [ + ("ground_session_id", RejectionCause.GROUND_SESSION_ID), + ("rover_boot_id", RejectionCause.ROVER_BOOT_ID), + ("control_epoch", RejectionCause.CONTROL_EPOCH), + ], +) +def test_stale_identifier_is_rejected(field: str, expected: RejectionCause) -> None: + """Пакет предыдущего сеанса, запуска или периода управления не принимается.""" + + ground, rover = active_pair(hash(field) % 500) + values = { + "ground_session_id": ground.ground_session_id, + "rover_boot_id": rover.rover_boot_id, + "control_epoch": rover.control_epoch, + "sequence_number": 1, + } + values[field] -= 1 + message = ControlStateMessage(MessageContext(**values), 1.0, 0.0, False, True) + assert rover.receive_control(message, CONTROL_STREAM_ID).reason is expected + + +def test_sequence_zero_is_accepted_in_a_new_session() -> None: + ground, rover = active_pair(1) + assert rover.receive_control(control_from(ground, rover, 0), CONTROL_STREAM_ID).accepted + + +def test_sequence_wrap_is_treated_as_newer() -> None: + ground, rover = active_pair(5) + key = (ground.ground_session_id, rover.rover_boot_id, rover.control_epoch, CONTROL_STREAM_ID) + rover.last_sequences[key] = 0xFFFFFFFF + assert rover.receive_control(control_from(ground, rover, 0), CONTROL_STREAM_ID).accepted + assert compare_sequence(0, 0xFFFFFFFF) is SequenceComparison.NEWER + + +def test_ambiguous_half_range_is_rejected() -> None: + ground, rover = active_pair(6) + key = (ground.ground_session_id, rover.rover_boot_id, rover.control_epoch, CONTROL_STREAM_ID) + rover.last_sequences[key] = 0 + decision = rover.receive_control(control_from(ground, rover, 0x80000000), CONTROL_STREAM_ID) + assert not decision.accepted + assert decision.ambiguous_sequence + + +def test_replayed_command_is_rejected() -> None: + ground, rover = active_pair(21) + assert rover.receive_control(control_from(ground, rover, 10), CONTROL_STREAM_ID).accepted + replay = rover.receive_control(control_from(ground, rover, 10), CONTROL_STREAM_ID) + assert not replay.accepted + assert replay.reason is RejectionCause.SEQUENCE_NUMBER + + +# ------------------------------------------------------------------ аварийное намерение + + +def test_persisted_emergency_intent_survives_a_ground_restart() -> None: + ground = GroundSessionController(15, persisted_emergency_intent=True, persisted_emergency_event_id=77) + assert ground.emergency_intent + assert not ground.movement_permitted + hello = ground.start_synchronization() + assert hello.emergency_intent + assert hello.emergency_event_id == 77 + + +def test_emergency_acknowledgement_does_not_clear_the_intent() -> None: + ground, rover = active_pair(16) + acknowledgement = rover.receive_emergency(ground.request_emergency(88)) + assert acknowledgement is not None + assert ground.receive_emergency_ack(acknowledgement) + assert ground.emergency_intent + assert not ground.movement_permitted + + +def test_ordinary_command_does_not_release_the_latch() -> None: + ground, rover = active_pair(20) + rover.receive_emergency(ground.request_emergency(96)) + decision = rover.receive_control(control_from(ground, rover, 100), CONTROL_STREAM_ID) + assert not decision.accepted + assert rover.state is RoverSessionState.EMERGENCY_LATCHED + + +def test_movement_authorization_is_refused_while_intent_stands() -> None: + ground = GroundSessionController(40, persisted_emergency_intent=True, persisted_emergency_event_id=5) + rover = RoverSessionController(41) + ground.receive_status(rover.receive_hello(ground.start_synchronization())) + with pytest.raises(RuntimeError): + ground.request_movement_authorization(1) + + +# ------------------------------------------------------------------ сброс + + +def test_stale_reset_request_is_refused() -> None: + ground, rover = active_pair(17) + assert rover.receive_emergency(ground.request_emergency(90)) is not None + request = ground.request_reset(91) + stale_context = MessageContext( + request.context.ground_session_id - 1, + request.context.rover_boot_id, + request.context.control_epoch, + request.context.sequence_number, + ) + stale = ResetRequest(stale_context, request.emergency_event_id, request.reset_request_id, 0.0, False) + assert not rover.receive_reset(stale).acknowledgement.accepted + + +def test_repeated_reset_is_idempotent() -> None: + ground, rover = active_pair(18) + rover.receive_emergency(ground.request_emergency(92)) + request = ground.request_reset(93) + + first = rover.receive_reset(request) + assert first.performed + + repeated = rover.receive_reset(request) + assert repeated.duplicate + assert not repeated.performed + assert repeated.acknowledgement.accepted + + +def test_reset_requires_an_emergency_intent() -> None: + ground, _ = active_pair(50) + with pytest.raises(RuntimeError): + ground.request_reset(1) + + +def test_reset_does_not_restore_the_previous_command() -> None: + ground, rover = active_pair(51) + rover.receive_control(ground.new_operator_control(2.0), CONTROL_STREAM_ID) + rover.receive_emergency(ground.request_emergency(60)) + assert rover.speed_mps == 0.0 + + decision = rover.receive_reset(ground.request_reset(61)) + assert decision.performed + assert ground.receive_reset_ack(decision.acknowledgement) + assert not ground.movement_permitted + assert ground.current_speed_mps == 0.0 + assert rover.safe + + +# ------------------------------------------------------------------ идентификаторы + + +def test_identifiers_are_reproducible_and_nonzero() -> None: + assert deterministic_identifier(42, "ground") == deterministic_identifier(42, "ground") + assert deterministic_identifier(42, "ground") != deterministic_identifier(42, "rover") + assert deterministic_identifier(42, "ground") != deterministic_identifier(43, "ground") + for seed in range(50): + assert 0 < deterministic_identifier(seed, "ground") <= 0xFFFFFFFFFFFFFFFF + + +def test_identifier_rejects_a_non_integer_seed() -> None: + with pytest.raises(TypeError): + deterministic_identifier("не число", "ground") + + +# ------------------------------------------------------------------ границы скорости + + +def test_negative_operator_speed_is_refused() -> None: + """Свойство, которое в отчёте Lab041 стояло литеральным нулём.""" + + ground, _ = active_pair(70) + for speed in (-0.1, -5.0, 0.0): + with pytest.raises(ValueError): + ground.new_operator_control(speed) + + +def test_speed_above_the_limit_is_refused_by_encoding() -> None: + from protocol.control_messages import ControlMessageError, encode_message + + ground, rover = active_pair(71) + with pytest.raises(ControlMessageError): + encode_message(control_from(ground, rover, 1, speed=15.1))