"""SQLAlchemy models for Modbus device management and telemetry. Two tables: - modbus_device: deployment/configurable metadata for each polled device. - modbus_reading: generic telemetry rows (one per device per poll cycle). """ from __future__ import annotations import uuid as _uuid from datetime import datetime from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.types import JSON from app.db import Base def _uuid4_str() -> str: return str(_uuid.uuid4()) class ModbusDevice(Base): """Deployment-layer record for a Modbus slave device reachable via a TCP gateway. Protocol knowledge (register map, decoding rules) lives in the YAML profile referenced by ``profile``. Per-device variables (network address, slave ID, display name, poll rate) live here. """ __tablename__ = "modbus_device" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) # Stable internal identity — used as the API path key and HA Discovery unique_id anchor. uuid: Mapped[str] = mapped_column( String(36), unique=True, nullable=False, default=_uuid4_str ) # Human-readable label (may be changed; changing it triggers re-publish of HA Discovery). friendly_name: Mapped[str] = mapped_column(String(255), nullable=False) # Transport protocol — only "tcp" is supported for now. transport: Mapped[str] = mapped_column(String(16), nullable=False, default="tcp") # TCP gateway address. host: Mapped[str] = mapped_column(String(255), nullable=False) port: Mapped[int] = mapped_column(Integer, nullable=False, default=502) # Modbus slave address (set on the device panel; different meters on the same gateway # must have distinct unit_ids). unit_id: Mapped[int] = mapped_column(Integer, nullable=False, default=1) # Which YAML profile to use for register mapping and decoding (e.g. "sdm120"). profile: Mapped[str] = mapped_column(String(64), nullable=False) # Polling interval in seconds. poll_interval_s: Mapped[int] = mapped_column(Integer, nullable=False, default=5) # Whether this device is included in the periodic poll sweep. enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) # Poll-status fields (updated by the poll service after each attempt). last_poll_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) last_poll_ok: Mapped[bool | None] = mapped_column(Boolean, nullable=True) # Audit timestamps. created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False ) # Relationship to readings (back-reference; not loaded eagerly). readings: Mapped[list["ModbusReading"]] = relationship( back_populates="device", cascade="save-update, merge" ) class ModbusReading(Base): """Generic telemetry row: one device, one poll instant, all decoded metrics as JSON. ``payload`` contains the full dict of engineering values produced by the YAML profile decoder, e.g. ``{"voltage": 230.2, "current": 1.3, ...}``. The profile is the key to interpreting which keys exist and what units they carry. """ __tablename__ = "modbus_reading" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) # FK to the device that produced this reading. # ON DELETE RESTRICT: prevents accidental deletion of a device that has historical data. device_id: Mapped[int] = mapped_column( ForeignKey("modbus_device.id", ondelete="RESTRICT"), nullable=False ) # The UTC timestamp of when the sample was taken — real indexed column, NOT embedded in payload. recorded_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, index=True ) # Profile-decoded engineering values as a JSON object. payload: Mapped[dict] = mapped_column(JSON, nullable=False) device: Mapped["ModbusDevice"] = relationship(back_populates="readings") # Composite index for efficient time-range queries scoped to a single device. Index("ix_modbus_reading_device_recorded", ModbusReading.device_id, ModbusReading.recorded_at)