"""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), )