Files
alro65 8d4a698144 polish(sprint-0): clean code per ruff + mypy strict
Run the dev linters over Sprint 0's core/library/shared modules and
address every finding. Behaviour unchanged; tests still 80/80 green.

Changes:

- Replace `class Foo(str, Enum)` with `class Foo(StrEnum)` (PEP 663
  / Python 3.11+) in 7 enum classes: ActuatorType, AlarmSeverity,
  AlarmType, KnobMode, KnobFunction, AutopilotMode, AccessLevel,
  VesselType. Pydantic v2 serialises StrEnum the same way, so YAML/JSON
  round-trips are byte-identical.
- Use `datetime.UTC` alias in place of `datetime.timezone.utc`
  (UP017) across alarms.py, knob_state.py, project_config.py, and
  test_knob_state.py.
- Remove now-unnecessary forward-reference quotes from method return
  type annotations (UP037) — `from __future__ import annotations` is
  already in scope everywhere.
- Tighten `_read_json_resource` / `_read_yaml_resource` in the library
  loader: validate that the deserialised payload is actually a dict
  before returning, instead of leaking `Any` from json.loads /
  yaml.safe_load. Fixes the only two `mypy --strict` findings.
- Add `.claude/settings.local.json` to .gitignore (personal
  Claude Code overrides are not committed).

Verification:
  ruff check arautopilot/                 -> All checks passed
  mypy arautopilot/core library shared    -> Success, 0 issues, 12 files
  pytest                                  -> 80 passed in 0.25s

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:26:37 -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 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()