Files
AR-Autopilot/arautopilot/core/alarms.py
T
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

117 lines
4.4 KiB
Python

"""Alarm types, severities, and the ``Alarm`` runtime record.
The catalogue mirrors section 7 of the brief. Each ``AlarmType`` has a
canonical default ``AlarmSeverity`` and a hard-coded flag stating whether
that alarm, when fired, should also trigger automatic disengagement of
the autopilot (return to STANDBY).
"""
from __future__ import annotations
from datetime import UTC, datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field
class AlarmSeverity(StrEnum):
"""IEC-style four-level severity scheme."""
EMERGENCY = "emergency"
"""Immediate danger; loudest annunciation, blocking modal on display."""
HIGH = "high"
"""Operator must act within a few seconds."""
LOW = "low"
"""Warning; degraded operation but no immediate danger."""
INFO = "info"
"""Informational only; ack with one tap."""
class AlarmType(StrEnum):
"""The fixed catalogue of alarms emitted by the firmware.
Adding an entry here is a deliberate, reviewed change — the firmware
state machine and the display strings must be updated in lockstep.
"""
OFF_COURSE = "off_course"
OFF_COURSE_SEVERE = "off_course_severe"
RUDDER_NOT_RESPONDING = "rudder_not_responding"
HEADING_SENSOR_LOST = "heading_sensor_lost"
ACTUATOR_OVERCURRENT = "actuator_overcurrent"
VOLTAGE_LOW = "voltage_low"
LIMIT_SWITCH_REACHED = "limit_switch_reached"
WATCHDOG_TRIPPED = "watchdog_tripped"
VMS_CRITICAL = "vms_critical"
# Canonical metadata per alarm type. Source of truth referenced by both
# the firmware (via code-generated header) and the Studio (via the GUI).
_CATALOGUE: dict[AlarmType, tuple[AlarmSeverity, bool, str]] = {
AlarmType.OFF_COURSE: (AlarmSeverity.LOW, False, "Heading deviates from setpoint beyond threshold"),
AlarmType.OFF_COURSE_SEVERE: (AlarmSeverity.EMERGENCY, True, "Heading deviation >30 deg for >5 s — auto-disengage"),
AlarmType.RUDDER_NOT_RESPONDING:(AlarmSeverity.EMERGENCY, True, "Rudder command sent but no feedback motion — auto-disengage"),
AlarmType.HEADING_SENSOR_LOST: (AlarmSeverity.EMERGENCY, True, "NMEA 2000 PGN 127250 not received for >5 s — auto-disengage"),
AlarmType.ACTUATOR_OVERCURRENT: (AlarmSeverity.HIGH, True, "Actuator current exceeded configured limit"),
AlarmType.VOLTAGE_LOW: (AlarmSeverity.HIGH, True, "Supply voltage below safe operating threshold"),
AlarmType.LIMIT_SWITCH_REACHED: (AlarmSeverity.LOW, False, "Rudder reached mechanical end-stop"),
AlarmType.WATCHDOG_TRIPPED: (AlarmSeverity.EMERGENCY, True, "Firmware watchdog fired — controller reset to STANDBY"),
AlarmType.VMS_CRITICAL: (AlarmSeverity.EMERGENCY, True, "VMS reported blackout or critical electrical overload"),
}
def default_severity(alarm_type: AlarmType) -> AlarmSeverity:
"""Return the canonical severity for ``alarm_type``."""
return _CATALOGUE[alarm_type][0]
def triggers_auto_disengage(alarm_type: AlarmType) -> bool:
"""Return ``True`` iff this alarm forces the pilot into STANDBY."""
return _CATALOGUE[alarm_type][1]
def default_message(alarm_type: AlarmType) -> str:
"""Return the canonical default human-readable description."""
return _CATALOGUE[alarm_type][2]
class Alarm(BaseModel):
"""A single alarm event raised by the firmware at runtime.
Persisted to the immutable audit log (brief section 14, rule #14).
"""
model_config = ConfigDict(frozen=True, extra="forbid")
type: AlarmType
severity: AlarmSeverity
message: str = Field(min_length=1, max_length=240)
timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC))
source: str = Field(
default="firmware",
description="Subsystem that raised the alarm (e.g. 'firmware', 'display', 'vms').",
max_length=64,
)
acknowledged: bool = False
auto_disengage_triggered: bool = False
@classmethod
def from_type(
cls,
alarm_type: AlarmType,
*,
message: str | None = None,
source: str = "firmware",
) -> Alarm:
"""Convenience constructor using catalogue defaults."""
return cls(
type=alarm_type,
severity=default_severity(alarm_type),
message=message or default_message(alarm_type),
source=source,
auto_disengage_triggered=triggers_auto_disengage(alarm_type),
)