"""Bridge rotary knob — armed-by-software state machine. Section 5 of the brief: the knob never controls anything by default. The operator must press it, pick a function, and then turn. Idle activity auto-disarms after a configurable timeout (default 30 s). The state machine modelled here is the **logical** one consumed by the Display and by the controller; the actual encoder quadrature decoding happens in the ESP32 firmware (Sprint 7). """ from __future__ import annotations from datetime import UTC, datetime from enum import StrEnum from pydantic import BaseModel, ConfigDict, Field, model_validator class KnobMode(StrEnum): """High-level state of the bridge knob.""" LIBRE = "libre" """Idle: rotation does nothing. Default.""" ARMADO = "armado" """Function selected; rotation now produces a pending value.""" CONFIRMANDO = "confirmando" """Pending value being shown for operator confirmation.""" class KnobFunction(StrEnum): """The set of values the knob may target once armed.""" NONE = "none" """No function selected (LIBRE only).""" RUMBO = "rumbo" """Heading setpoint.""" GANANCIA_P = "ganancia_p" """Proportional gain (Technician+ only).""" VELOCIDAD_GIRO = "velocidad_giro" """Desired turn rate.""" BRILLO = "brillo" """Display brightness.""" VOLUMEN = "volumen" """Alarm volume.""" DEFAULT_ARMED_TIMEOUT_S: float = 30.0 """Default auto-disarm timeout in seconds (brief section 5).""" class KnobState(BaseModel): """Immutable snapshot of the knob state at a point in time.""" model_config = ConfigDict(frozen=True, extra="forbid") mode: KnobMode = KnobMode.LIBRE function: KnobFunction = KnobFunction.NONE current_value: float | None = Field( default=None, description="Last confirmed value of the selected function.", ) pending_value: float | None = Field( default=None, description="Value being adjusted but not yet confirmed.", ) armed_at: datetime | None = Field( default=None, description="UTC timestamp when the knob entered ARMADO mode.", ) timeout_remaining_s: float = Field( default=0.0, ge=0.0, description="Seconds remaining before auto-disarm.", ) @model_validator(mode="after") def _consistency(self) -> KnobState: if self.mode == KnobMode.LIBRE: if self.function != KnobFunction.NONE: raise ValueError("LIBRE mode requires function == NONE") if self.pending_value is not None: raise ValueError("LIBRE mode forbids pending_value") if self.armed_at is not None: raise ValueError("LIBRE mode forbids armed_at") if self.timeout_remaining_s != 0.0: raise ValueError("LIBRE mode requires timeout_remaining_s == 0") else: if self.function == KnobFunction.NONE: raise ValueError(f"{self.mode.value} mode requires a non-NONE function") if self.armed_at is None: raise ValueError(f"{self.mode.value} mode requires armed_at to be set") if self.mode == KnobMode.CONFIRMANDO and self.pending_value is None: raise ValueError("CONFIRMANDO mode requires pending_value to be set") return self # --- Pure transition helpers (return new immutable states) -------------- @classmethod def idle(cls) -> KnobState: """Construct the canonical idle (LIBRE) state.""" return cls() def arm( self, function: KnobFunction, *, current_value: float, timeout_s: float = DEFAULT_ARMED_TIMEOUT_S, now: datetime | None = None, ) -> KnobState: """Transition LIBRE → ARMADO for the given function.""" if self.mode != KnobMode.LIBRE: raise ValueError(f"Cannot arm from mode {self.mode.value}") if function == KnobFunction.NONE: raise ValueError("Cannot arm with KnobFunction.NONE") return KnobState( mode=KnobMode.ARMADO, function=function, current_value=current_value, pending_value=None, armed_at=now or datetime.now(UTC), timeout_remaining_s=timeout_s, ) def propose(self, value: float, *, timeout_s: float = DEFAULT_ARMED_TIMEOUT_S) -> KnobState: """Operator turned the knob: stage a pending value (ARMADO → CONFIRMANDO).""" if self.mode not in (KnobMode.ARMADO, KnobMode.CONFIRMANDO): raise ValueError(f"Cannot propose from mode {self.mode.value}") return KnobState( mode=KnobMode.CONFIRMANDO, function=self.function, current_value=self.current_value, pending_value=value, armed_at=self.armed_at, timeout_remaining_s=timeout_s, ) def confirm(self, *, timeout_s: float = DEFAULT_ARMED_TIMEOUT_S) -> KnobState: """Operator pressed to confirm; commits ``pending_value`` and stays armed.""" if self.mode != KnobMode.CONFIRMANDO: raise ValueError(f"Cannot confirm from mode {self.mode.value}") return KnobState( mode=KnobMode.ARMADO, function=self.function, current_value=self.pending_value, pending_value=None, armed_at=self.armed_at, timeout_remaining_s=timeout_s, ) def disarm(self) -> KnobState: """Force the knob back to LIBRE (timeout, alarm, mode change, long-press).""" return KnobState.idle()