"""Tarjetas AR-NMEA-IO-v1.0 y la topología de buses. Capacidades fijas por tarjeta (Parte 4 sec 1): - 10 DO (Digital Output) — MOSFET IRLML6344TRPBF - 5 DI (Digital Input) — Opto PC817 - 1 RPM (Frequency input) - 4 AI (Analog Input) - Comunicación: RS485 + CAN/NMEA 2000 + WiFi + USB Slot dipswitch físico 1-16 por bus. """ from __future__ import annotations from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from vmssailor.core.coords import ShipCoord from vmssailor.core.enums import BusRole, Protocol # --------------------------------------------------------------------------- # Bus # --------------------------------------------------------------------------- class Bus(BaseModel): """Bus físico al que se conectan tarjetas o nodos NMEA 2000.""" model_config = ConfigDict(extra="forbid") id: str = Field(..., min_length=1, max_length=64) name: str = Field(..., min_length=1, max_length=128) protocol: Protocol = Field( ..., description="MODBUS_RTU o NMEA2000. Otros protocolos no son buses.", ) physical_port: str = Field( ..., min_length=1, max_length=64, description="Puerto físico del PC del Runtime: 'COM3', 'USB-CAN0', etc.", ) baud_rate: int = Field( default=115200, ge=1200, le=1_000_000, description="Solo aplica a Modbus RTU. NMEA 2000 ignora este campo (CAN 250 kbps).", ) parity: str = Field(default="N", pattern=r"^[NEO]$", description="N/E/O. Solo Modbus.") stop_bits: int = Field(default=1, ge=1, le=2, description="1 o 2. Solo Modbus.") termination: bool = Field(default=True, description="Resistencia de terminación 120Ω.") description: str = Field(default="", max_length=512) @model_validator(mode="after") def _check_protocol(self) -> Bus: if self.protocol not in (Protocol.MODBUS_RTU, Protocol.NMEA2000): raise ValueError( f"Un Bus solo puede ser MODBUS_RTU o NMEA2000, no {self.protocol.value}." ) return self # --------------------------------------------------------------------------- # CardInstance — tarjeta concreta del proyecto # --------------------------------------------------------------------------- class CardInstance(BaseModel): """Una tarjeta AR-NMEA-IO-v1.0 colocada en un proyecto específico. El hardware es siempre el mismo (una sola SKU). Lo que varía: - Su rol en el bus (esclava Modbus, NMEA 2000 node, dual, bridge). - Su slot dipswitch físico (1-16). - Qué hace cada canal (vía `Tag.physical_binding`). """ model_config = ConfigDict(extra="forbid") id: str = Field(..., min_length=1, max_length=64, description="ID lógico: 'card_001'.") serial_number: str = Field( default="", max_length=64, description="Serial físico de fábrica: 'ARC-2026-00153'. Vacío hasta comisionado.", ) slot_number: int = Field( ..., ge=1, le=16, description="Slot dipswitch físico (1-16) por bus.", ) bus_id: str = Field(..., min_length=1, max_length=64, description="ID del Bus al que conecta.") bus_role: BusRole modbus_address: int | None = Field( default=None, ge=1, le=247, description="Dirección Modbus si bus_role implica esclavitud Modbus.", ) physical_location: str = Field( default="", max_length=256, description="Descripción humana: 'Sala máquinas, panel principal'.", ) location: ShipCoord | None = Field( default=None, description="Coordenada aproximada de la tarjeta en el buque.", ) firmware_version: str = Field( default="0.0.0", max_length=32, description="Versión de firmware esperada/desplegada.", ) # Capacidades fijas del hardware AR-NMEA-IO-v1.0 (Parte 4 sec 1) CAP_DO: int = Field(default=10, frozen=True) CAP_DI: int = Field(default=5, frozen=True) CAP_RPM: int = Field(default=1, frozen=True) CAP_AI: int = Field(default=4, frozen=True) @model_validator(mode="after") def _modbus_address_consistency(self) -> CardInstance: needs_addr = self.bus_role in ( BusRole.MODBUS_SLAVE, BusRole.DUAL, BusRole.BRIDGE, ) if needs_addr and self.modbus_address is None: raise ValueError( f"CardInstance '{self.id}' con bus_role={self.bus_role.value} " "requiere modbus_address." ) if not needs_addr and self.modbus_address is not None: raise ValueError( f"CardInstance '{self.id}' con bus_role={self.bus_role.value} " "no debe tener modbus_address." ) return self # --------------------------------------------------------------------------- # Topology — red completa # --------------------------------------------------------------------------- class Topology(BaseModel): """Red completa del proyecto: buses + cartas.""" model_config = ConfigDict(extra="forbid") buses: list[Bus] = Field(default_factory=list) cards: list[CardInstance] = Field(default_factory=list) @field_validator("buses") @classmethod def _buses_unique_ids(cls, v: list[Bus]) -> list[Bus]: ids = [b.id for b in v] if len(ids) != len(set(ids)): raise ValueError("Bus IDs deben ser únicos en una Topology.") return v @field_validator("cards") @classmethod def _cards_unique_ids(cls, v: list[CardInstance]) -> list[CardInstance]: ids = [c.id for c in v] if len(ids) != len(set(ids)): raise ValueError("CardInstance IDs deben ser únicos en una Topology.") return v @model_validator(mode="after") def _validate_cards_reference_existing_bus(self) -> Topology: bus_ids = {b.id for b in self.buses} for c in self.cards: if c.bus_id not in bus_ids: raise ValueError( f"CardInstance '{c.id}' referencia bus '{c.bus_id}' que no " f"existe en la topología (buses: {sorted(bus_ids)})." ) return self @model_validator(mode="after") def _validate_unique_slots_per_bus(self) -> Topology: seen: dict[tuple[str, int], str] = {} for c in self.cards: key = (c.bus_id, c.slot_number) if key in seen: raise ValueError( f"Slot {c.slot_number} duplicado en bus '{c.bus_id}': " f"cartas '{seen[key]}' y '{c.id}'." ) seen[key] = c.id return self @model_validator(mode="after") def _validate_unique_modbus_addresses_per_bus(self) -> Topology: seen: dict[tuple[str, int], str] = {} for c in self.cards: if c.modbus_address is None: continue key = (c.bus_id, c.modbus_address) if key in seen: raise ValueError( f"Modbus address {c.modbus_address} duplicada en bus " f"'{c.bus_id}': cartas '{seen[key]}' y '{c.id}'." ) seen[key] = c.id return self def card_by_id(self, card_id: str) -> CardInstance | None: for c in self.cards: if c.id == card_id: return c return None def bus_by_id(self, bus_id: str) -> Bus | None: for b in self.buses: if b.id == bus_id: return b return None