Files
SDR-Rover/protocol/session_state.py
2026-08-05 14:14:24 +03:00

423 lines
19 KiB
Python
Raw Permalink 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.
"""Автоматы безопасного сеанса наземной станции и ровера для Lab041."""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
import hashlib
from protocol.control_messages import (
ControlStateMessage,
EmergencyAck,
EmergencyStop,
MessageContext,
MessageType,
MovementAuthorizeAck,
MovementAuthorizeRequest,
ResetAck,
ResetRequest,
RoverSafetyCode,
SequenceComparison,
SessionHello,
SessionRejectReason,
SessionStatus,
compare_sequence,
)
class GroundSessionState(str, Enum):
GROUND_BOOT = "ground_boot"
GROUND_SYNCING = "ground_syncing"
GROUND_SAFE_SYNCHRONIZED = "ground_safe_synchronized"
GROUND_MOVEMENT_AUTH_REQUESTED = "ground_movement_auth_requested"
GROUND_ACTIVE = "ground_active"
GROUND_EMERGENCY_REQUESTED = "ground_emergency_requested"
GROUND_EMERGENCY_CONFIRMED = "ground_emergency_confirmed"
GROUND_RESET_REQUESTED = "ground_reset_requested"
class RoverSessionState(str, Enum):
ROVER_BOOT_SAFE = "rover_boot_safe"
ROVER_SESSION_SYNCED_SAFE = "rover_session_synced_safe"
ROVER_MOVEMENT_AUTHORIZED = "rover_movement_authorized"
STAGE1_DECELERATION = "stage1_deceleration"
STAGE2_BRAKING = "stage2_braking"
EMERGENCY_LATCHED = "emergency_latched"
class RejectionCause(str, Enum):
GROUND_SESSION_ID = "ground_session_id"
ROVER_BOOT_ID = "rover_boot_id"
CONTROL_EPOCH = "control_epoch"
SEQUENCE_NUMBER = "sequence_number"
EMERGENCY_EVENT_ID = "emergency_event_id"
RESET_REQUEST_ID = "reset_request_id"
NOT_SYNCHRONIZED = "not_synchronized"
MOVEMENT_NOT_AUTHORIZED = "movement_not_authorized"
@dataclass(frozen=True)
class ControlDecision:
accepted: bool
reason: RejectionCause | None
ambiguous_sequence: bool = False
@dataclass(frozen=True)
class ResetDecision:
acknowledgement: ResetAck
performed: bool
duplicate: bool
def deterministic_identifier(seed: int, purpose: str) -> int:
"""Получить воспроизводимый ненулевой 64-битный идентификатор."""
if not isinstance(seed, int):
raise TypeError("начальное значение генератора должно быть целым")
digest = hashlib.blake2b(f"Lab041:{purpose}:{seed}".encode("utf-8"), digest_size=8).digest()
return int.from_bytes(digest, "big") or 1
class GroundSessionController:
"""Наземный автомат без автоматического восстановления движения."""
def __init__(
self,
seed: int,
*,
persisted_emergency_intent: bool = False,
persisted_emergency_event_id: int = 0,
) -> None:
self.ground_session_id = deterministic_identifier(seed, "ground")
self.rover_boot_id = 0
self.control_epoch = 0
self.sequence_number = 0
self.state = GroundSessionState.GROUND_BOOT
self.emergency_intent = bool(persisted_emergency_intent)
self.emergency_event_id = persisted_emergency_event_id if self.emergency_intent else 0
self.authorization_id = 0
self.reset_request_id = 0
self.last_operator_sequence: int | None = None
self.movement_permitted = False
self.current_speed_mps = 0.0
self.zero_commands_after_restart = 0
def _next_sequence(self) -> int:
value = self.sequence_number
self.sequence_number = (self.sequence_number + 1) & 0xFFFFFFFF
return value
def _context(self, *, epoch: int | None = None) -> MessageContext:
return MessageContext(
self.ground_session_id,
self.rover_boot_id,
self.control_epoch if epoch is None else epoch,
self._next_sequence(),
)
def start_synchronization(self) -> SessionHello:
if self.state is not GroundSessionState.GROUND_BOOT:
raise RuntimeError("синхронизацию можно начать только после запуска")
self.state = GroundSessionState.GROUND_SYNCING
self.movement_permitted = False
self.current_speed_mps = 0.0
return SessionHello(self._context(epoch=0), self.emergency_intent, self.emergency_event_id)
def repeat_hello(self) -> SessionHello:
if self.state is not GroundSessionState.GROUND_SYNCING:
raise RuntimeError("повтор приветствия допустим только при синхронизации")
return SessionHello(self._context(epoch=0), self.emergency_intent, self.emergency_event_id)
def receive_status(self, status: SessionStatus) -> bool:
if self.state is not GroundSessionState.GROUND_SYNCING:
return False
if status.context.ground_session_id != self.ground_session_id:
return False
if status.context.rover_boot_id == 0:
return False
self.rover_boot_id = status.context.rover_boot_id
self.control_epoch = 0
self.movement_permitted = False
self.current_speed_mps = 0.0
if self.emergency_intent:
self.state = GroundSessionState.GROUND_EMERGENCY_REQUESTED
else:
self.state = GroundSessionState.GROUND_SAFE_SYNCHRONIZED
return True
def request_movement_authorization(self, authorization_id: int) -> MovementAuthorizeRequest:
if self.state is not GroundSessionState.GROUND_SAFE_SYNCHRONIZED:
raise RuntimeError("разрешение движения требует безопасного согласованного сеанса")
if self.emergency_intent:
raise RuntimeError("при аварийном намерении движение запрещено")
if not 0 <= authorization_id <= 0xFFFFFFFFFFFFFFFF:
raise ValueError("authorization_id вне диапазона uint64")
self.authorization_id = authorization_id
self.state = GroundSessionState.GROUND_MOVEMENT_AUTH_REQUESTED
return MovementAuthorizeRequest(self._context(epoch=0), authorization_id)
def receive_movement_authorize_ack(self, acknowledgement: MovementAuthorizeAck) -> bool:
if self.state is not GroundSessionState.GROUND_MOVEMENT_AUTH_REQUESTED:
return False
context = acknowledgement.context
if (
context.ground_session_id != self.ground_session_id
or context.rover_boot_id != self.rover_boot_id
or acknowledgement.authorization_id != self.authorization_id
or not acknowledgement.accepted
or context.control_epoch == 0
):
return False
self.control_epoch = context.control_epoch
self.state = GroundSessionState.GROUND_ACTIVE
self.movement_permitted = False
self.current_speed_mps = 0.0
self.last_operator_sequence = None
return True
def new_operator_control(self, speed_mps: float, turn: float = 0.0) -> ControlStateMessage:
if self.state is not GroundSessionState.GROUND_ACTIVE:
raise RuntimeError("новая команда движения возможна только в активном сеансе")
if speed_mps <= 0.0:
raise ValueError("скорость новой команды должна быть положительной")
context = self._context()
self.last_operator_sequence = context.sequence_number
self.movement_permitted = True
self.current_speed_mps = speed_mps
return ControlStateMessage(context, speed_mps, turn, False, True)
def safe_zero_control(self) -> ControlStateMessage:
self.zero_commands_after_restart += 1
return ControlStateMessage(self._context(), 0.0, 0.0, True, False)
def request_emergency(self, emergency_event_id: int) -> EmergencyStop:
if not 0 < emergency_event_id <= 0xFFFFFFFFFFFFFFFF:
raise ValueError("emergency_event_id должен быть ненулевым uint64")
self.emergency_intent = True
self.emergency_event_id = emergency_event_id
self.movement_permitted = False
self.current_speed_mps = 0.0
self.state = GroundSessionState.GROUND_EMERGENCY_REQUESTED
return EmergencyStop(self._context(), emergency_event_id)
def receive_emergency_ack(self, acknowledgement: EmergencyAck) -> bool:
if acknowledgement.context.ground_session_id != self.ground_session_id:
return False
if acknowledgement.emergency_event_id != self.emergency_event_id:
return False
if self.state is GroundSessionState.GROUND_EMERGENCY_REQUESTED:
self.state = GroundSessionState.GROUND_EMERGENCY_CONFIRMED
return True
def request_reset(self, reset_request_id: int) -> ResetRequest:
if not self.emergency_intent:
raise RuntimeError("сброс требует аварийного намерения")
if not 0 < reset_request_id <= 0xFFFFFFFFFFFFFFFF:
raise ValueError("reset_request_id должен быть ненулевым uint64")
self.reset_request_id = reset_request_id
self.state = GroundSessionState.GROUND_RESET_REQUESTED
return ResetRequest(self._context(), self.emergency_event_id, reset_request_id, 0.0, False)
def receive_reset_ack(self, acknowledgement: ResetAck) -> bool:
if self.state is not GroundSessionState.GROUND_RESET_REQUESTED:
return False
if (
acknowledgement.context.ground_session_id != self.ground_session_id
or acknowledgement.context.rover_boot_id != self.rover_boot_id
or acknowledgement.emergency_event_id != self.emergency_event_id
or acknowledgement.reset_request_id != self.reset_request_id
or not acknowledgement.accepted
):
return False
self.emergency_intent = False
self.emergency_event_id = 0
self.movement_permitted = False
self.current_speed_mps = 0.0
self.control_epoch = 0
self.state = GroundSessionState.GROUND_SAFE_SYNCHRONIZED
return True
class RoverSessionController:
"""Ровер, который после каждого запуска начинает с безопасного запрета."""
def __init__(self, seed: int) -> None:
self.rover_boot_id = deterministic_identifier(seed, "rover")
self.ground_session_id = 0
self.control_epoch = 0
self.state = RoverSessionState.ROVER_BOOT_SAFE
self.speed_mps = 0.0
self.movement_permitted = False
self.emergency_event_id = 0
self.last_sequences: dict[tuple[int, int, int, int], int] = {}
self.accepted_reset_keys: set[tuple[int, int, int, int]] = set()
self.duplicate_hellos = 0
self.duplicate_resets = 0
@property
def safe(self) -> bool:
return self.speed_mps == 0.0 and not self.movement_permitted
def _status_context(self, sequence_number: int = 0) -> MessageContext:
return MessageContext(self.ground_session_id, self.rover_boot_id, self.control_epoch, sequence_number)
def receive_hello(self, hello: SessionHello) -> SessionStatus:
if hello.context.ground_session_id == 0:
raise ValueError("ground_session_id должен быть ненулевым")
if hello.context.ground_session_id == self.ground_session_id:
self.duplicate_hellos += 1
else:
self.ground_session_id = hello.context.ground_session_id
self.control_epoch = 0
self.last_sequences.clear()
self.movement_permitted = False
self.speed_mps = 0.0
self.state = RoverSessionState.ROVER_SESSION_SYNCED_SAFE
if hello.emergency_intent:
self.emergency_event_id = hello.emergency_event_id
self.state = RoverSessionState.EMERGENCY_LATCHED
code = {
RoverSessionState.ROVER_BOOT_SAFE: RoverSafetyCode.ROVER_BOOT_SAFE,
RoverSessionState.ROVER_SESSION_SYNCED_SAFE: RoverSafetyCode.ROVER_SESSION_SYNCED_SAFE,
RoverSessionState.ROVER_MOVEMENT_AUTHORIZED: RoverSafetyCode.ROVER_MOVEMENT_AUTHORIZED,
RoverSessionState.STAGE1_DECELERATION: RoverSafetyCode.STAGE1_DECELERATION,
RoverSessionState.STAGE2_BRAKING: RoverSafetyCode.STAGE2_BRAKING,
RoverSessionState.EMERGENCY_LATCHED: RoverSafetyCode.EMERGENCY_LATCHED,
}[self.state]
return SessionStatus(
self._status_context(hello.context.sequence_number),
code,
self.state is RoverSessionState.EMERGENCY_LATCHED,
self.emergency_event_id,
)
def authorize_movement(self, request: MovementAuthorizeRequest) -> MovementAuthorizeAck:
accepted = (
self.state is RoverSessionState.ROVER_SESSION_SYNCED_SAFE
and request.context.ground_session_id == self.ground_session_id
and request.context.rover_boot_id == self.rover_boot_id
and self.emergency_event_id == 0
)
if accepted:
self.control_epoch = (self.control_epoch + 1) & 0xFFFFFFFF
if self.control_epoch == 0:
self.control_epoch = 1
self.state = RoverSessionState.ROVER_MOVEMENT_AUTHORIZED
self.speed_mps = 0.0
self.movement_permitted = False
return MovementAuthorizeAck(
MessageContext(self.ground_session_id, self.rover_boot_id, self.control_epoch, request.context.sequence_number),
request.authorization_id,
accepted,
)
def receive_control(self, message: ControlStateMessage, stream_id: int) -> ControlDecision:
context = message.context
if context.ground_session_id != self.ground_session_id:
return ControlDecision(False, RejectionCause.GROUND_SESSION_ID)
if context.rover_boot_id != self.rover_boot_id:
return ControlDecision(False, RejectionCause.ROVER_BOOT_ID)
if context.control_epoch != self.control_epoch:
return ControlDecision(False, RejectionCause.CONTROL_EPOCH)
if self.state is RoverSessionState.EMERGENCY_LATCHED:
return ControlDecision(False, RejectionCause.MOVEMENT_NOT_AUTHORIZED)
if self.state is not RoverSessionState.ROVER_MOVEMENT_AUTHORIZED:
return ControlDecision(False, RejectionCause.MOVEMENT_NOT_AUTHORIZED)
key = (context.ground_session_id, context.rover_boot_id, context.control_epoch, stream_id)
old = self.last_sequences.get(key)
if old is not None:
comparison = compare_sequence(context.sequence_number, old)
if comparison is SequenceComparison.AMBIGUOUS:
return ControlDecision(False, RejectionCause.SEQUENCE_NUMBER, True)
if comparison is not SequenceComparison.NEWER:
return ControlDecision(False, RejectionCause.SEQUENCE_NUMBER)
self.last_sequences[key] = context.sequence_number
self.speed_mps = message.desired_speed_mps
self.movement_permitted = message.movement_permitted
return ControlDecision(True, None)
def receive_emergency(self, message: EmergencyStop) -> EmergencyAck | None:
if (
message.context.ground_session_id != self.ground_session_id
or message.context.rover_boot_id != self.rover_boot_id
):
return None
self.emergency_event_id = message.emergency_event_id
self.state = RoverSessionState.EMERGENCY_LATCHED
self.speed_mps = 0.0
self.movement_permitted = False
return EmergencyAck(
MessageContext(self.ground_session_id, self.rover_boot_id, self.control_epoch, message.context.sequence_number),
message.emergency_event_id,
)
def receive_reset(self, request: ResetRequest) -> ResetDecision:
context = request.context
reason = SessionRejectReason.NOT_SYNCHRONIZED
if context.ground_session_id != self.ground_session_id:
reason = SessionRejectReason.GROUND_SESSION_ID
elif context.rover_boot_id != self.rover_boot_id:
reason = SessionRejectReason.ROVER_BOOT_ID
elif request.emergency_event_id != self.emergency_event_id:
reason = SessionRejectReason.EMERGENCY_EVENT_ID
elif self.speed_mps != 0.0:
reason = SessionRejectReason.MOVEMENT_NOT_AUTHORIZED
else:
reason = SessionRejectReason.RESET_REQUEST_ID
key = (
context.ground_session_id,
context.rover_boot_id,
request.emergency_event_id,
request.reset_request_id,
)
if key in self.accepted_reset_keys:
self.duplicate_resets += 1
acknowledgement = ResetAck(context, request.emergency_event_id, request.reset_request_id, True, SessionRejectReason.ACCEPTED)
return ResetDecision(acknowledgement, False, True)
accepted = (
context.ground_session_id == self.ground_session_id
and context.rover_boot_id == self.rover_boot_id
and self.state is RoverSessionState.EMERGENCY_LATCHED
and request.emergency_event_id == self.emergency_event_id
and request.requested_speed_mps == 0.0
and not request.movement_permitted
and self.speed_mps == 0.0
)
if accepted:
self.accepted_reset_keys.add(key)
self.emergency_event_id = 0
self.state = RoverSessionState.ROVER_SESSION_SYNCED_SAFE
self.speed_mps = 0.0
self.movement_permitted = False
reason = SessionRejectReason.ACCEPTED
acknowledgement = ResetAck(context, request.emergency_event_id, request.reset_request_id, accepted, reason)
return ResetDecision(acknowledgement, accepted, False)
def enter_watchdog_stage1(self) -> None:
if self.state is RoverSessionState.ROVER_MOVEMENT_AUTHORIZED:
self.state = RoverSessionState.STAGE1_DECELERATION
self.movement_permitted = False
def enter_watchdog_stage2(self) -> None:
if self.state in (RoverSessionState.ROVER_MOVEMENT_AUTHORIZED, RoverSessionState.STAGE1_DECELERATION):
self.state = RoverSessionState.STAGE2_BRAKING
self.movement_permitted = False
def rejection_reason_for_message(reason: RejectionCause) -> SessionRejectReason:
return {
RejectionCause.GROUND_SESSION_ID: SessionRejectReason.GROUND_SESSION_ID,
RejectionCause.ROVER_BOOT_ID: SessionRejectReason.ROVER_BOOT_ID,
RejectionCause.CONTROL_EPOCH: SessionRejectReason.CONTROL_EPOCH,
RejectionCause.SEQUENCE_NUMBER: SessionRejectReason.SEQUENCE_NUMBER,
RejectionCause.EMERGENCY_EVENT_ID: SessionRejectReason.EMERGENCY_EVENT_ID,
RejectionCause.RESET_REQUEST_ID: SessionRejectReason.RESET_REQUEST_ID,
RejectionCause.NOT_SYNCHRONIZED: SessionRejectReason.NOT_SYNCHRONIZED,
RejectionCause.MOVEMENT_NOT_AUTHORIZED: SessionRejectReason.MOVEMENT_NOT_AUTHORIZED,
}[reason]
assert len({state.value for state in GroundSessionState}) == len(GroundSessionState)
assert len({state.value for state in RoverSessionState}) == len(RoverSessionState)