700756c16f
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>
163 lines
5.5 KiB
Python
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()
|