Cover control messages and session state; record the RF decision

Two more suites, 69 checks, bringing the fast tests to 125 in 0.3 s.

test_control_messages: roundtrip for all ten message types, a guard that
fails if a type is added without a test, wrong version, unknown type,
truncation, the control-state range rules including negative and
non-finite speed, and sequence comparison across the 32-bit wrap and the
ambiguous 2^31 boundary.

test_session_state: the safety properties that previously only ran inside
Lab041. Safe boot on both sides, status and authorization not being a
movement command, a new operator command being required, stale session,
boot and epoch identifiers, replay, sequence wrap, ambiguity, persisted
emergency intent surviving a restart, acknowledgement not clearing it,
ordinary commands not releasing the latch, idempotent reset, and reset
not restoring the previous command.

Two of these were literal zeros in the Lab041 report and measured
nothing: negative speed and speed above the limit are now genuinely
exercised against the encoder.

PROJECT_LOG entry 018 records the RF architecture decision: 200-250 MHz
with frequency hopping, a directional ground antenna and spread spectrum
for the command channel, with the rejected alternatives and why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
LittleSam129
2026-08-10 15:29:47 +03:00
parent 2da80d0908
commit 92289bdf7c
3 changed files with 497 additions and 0 deletions

View File

@@ -434,3 +434,57 @@ git version 2.51.1.windows.1
## Следующий этап ## Следующий этап
Криптографическая аутентификация отложена. Сначала выполняется этап стабилизации: быстрые тесты `protocol/` вместо единственной точки входа через лабораторную, разделение быстрых проверок и экспериментов, воспроизводимое описание окружения. Криптографическая аутентификация отложена. Сначала выполняется этап стабилизации: быстрые тесты `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 ГГц дифракция исчезает и для наземного ровера без прямой видимости эти каналы мертвы. Кроме того, подавление направлено на диапазон, в котором связь действительно работает, а не на тот, который способно настроить оборудование.
**Диапазоны 330433 МГц.** Отклонены как плотно занятые и общеизвестные.
## Принято
Рабочий диапазон **200250 МГц**. Обоснование: четвертьволновая антенна 33 см пригодна для установки на ровер, дифракция ещё эффективна, участок заметно свободнее любительского 144 МГц и УКВ выше 300 МГц. Потери на 10 км составляют около 99,5 дБ.
Совокупность мер против подавления:
1. **Скачки по частоте в пределах 200250 МГц.** Требуют от заградительной помехи на 22 дБ больше мощности при неизменной антенне и механике ровера.
2. **Направленная антенна наземной станции.** Даёт 1020 дБ усиления и пространственно подавляет помеху, приходящую с иного направления.
3. **Расширение спектра для команд управления.** Поток команд составляет 10,88 кбит/с, поэтому расширение для него реализуемо в узкой полосе, в отличие от видеопотока.
Видео остаётся некритичным классом: при подавлении управление сохраняется в узкой полосе, а ровер переходит к безопасному поведению по уже реализованной логике.
## Запрещённые участки
- 108137 МГц — авиационная связь.
- 243 МГц — международная аварийная частота.
## Ограничения решения
- Нижняя граница 70 МГц определяется приёмником Pluto и без смены оборудования не преодолевается.
- Скачки требуют согласованной последовательности у обеих сторон, то есть зависят от криптографической аутентификации, которая пока не реализована.
- Расчёты выполнены для свободного пространства. Потери на препятствиях 2040 дБ учтены оценочно и подлежат измерению.
- Дальность 10 км без усилителя мощности не достигается.
## Влияние на ближайшие работы
На Lab042 не влияет: там кабельная петля, несущая 435 МГц выбрана произвольно. Диапазон 200250 МГц вступает в силу начиная с эфирных испытаний.

View File

@@ -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

288
tests/test_session_state.py Normal file
View File

@@ -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))