115 lines
4.3 KiB
Python
115 lines
4.3 KiB
Python
"""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)
|