Files
AR-Autopilot/arautopilot/core/knob_state.py
T
alro65 700756c16f sprint-0: foundations -- data model, seed library, tests, demo
Initial commit. Delivers what the brief calls 'Sprint 0 - Foundations'
(see docs/AR_Autopilot_brief.md section 12):

- Complete repository structure (arautopilot package + firmware, display,
  installer, tools placeholders + docs).
- Core data model (Pydantic v2): modes, alarms, actuator config, PID
  config + gain scheduling, vessel config, knob state machine, project
  config with YAML/JSON serialisation.
- Seed library: 2 actuator profiles (hydraulic & electric DC reversible)
  and 2 default tunings (yacht motor planeo 30 m and 40 m). Conservative
  literature values, NOT the integrator's production tuning IP.
- Firmware skeleton: only src/hal/pinout.h with the 21 I/O contract for
  the AR-NMEA-IO v1.0 board. No drivers, no main loop.
- Studio stubs (real PySide6 app starts in Sprint 4).
- pytest suite (80 tests, all green): modes, alarms, actuator, PID
  (incl. gain interpolation and the +/-50% adaptive bound from brief
  section 6), vessel, knob state, project config, library loader,
  end-to-end roundtrip.
- examples/sprint0_demo.py - the acceptance demo from the brief.

Acceptance criteria met:
- pytest green (80/80)
- demo creates, saves (YAML + JSON), reloads, and verifies a full
  ProjectConfig using the seed library
- repository ready for tag `sprint-0-approved`

See CHANGELOG.md for the detailed scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:57:18 -04:00

163 lines
5.5 KiB
Python

"""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 datetime, timezone
from enum import Enum
from pydantic import BaseModel, ConfigDict, Field, model_validator
class KnobMode(str, Enum):
"""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(str, Enum):
"""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(timezone.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()