M5-T05: add Modbus JSON API (device CRUD, readings, metrics, test)
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
"""Modbus device CRUD, readings, metrics, and test-read API (M5-T05).
|
||||
|
||||
All endpoints are under /api/modbus, require an authenticated session, and
|
||||
write endpoints (POST/PATCH/DELETE + test-read) additionally require a
|
||||
non-empty X-CSRF-Token header.
|
||||
|
||||
Deletion safety (red-line):
|
||||
DELETE /api/modbus/devices/{uuid} explicitly queries the application
|
||||
layer for existing modbus_reading rows before deleting. If any exist
|
||||
the endpoint returns 409 and suggests disabling the device instead.
|
||||
We do NOT rely on the SQLite FK RESTRICT constraint because SQLite does
|
||||
not enforce foreign-key constraints at runtime unless
|
||||
PRAGMA foreign_keys=ON is set per connection (and it is not in this
|
||||
project — see M5-T02 review note OBS-1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.routes.api.deps import require_csrf, require_session
|
||||
from app.dependencies import get_db
|
||||
from app.integrations.modbus import driver as modbus_driver
|
||||
from app.integrations.modbus.driver import ModbusDriverError
|
||||
from app.integrations.modbus.profiles import (
|
||||
ProfileNotFoundError,
|
||||
decode as decode_profile,
|
||||
list_profiles,
|
||||
load_profile,
|
||||
)
|
||||
from app.models.modbus import ModbusDevice, ModbusReading
|
||||
from app.schemas.modbus import (
|
||||
MetricInfo,
|
||||
ModbusDeviceCreate,
|
||||
ModbusDeviceListResponse,
|
||||
ModbusDeviceResponse,
|
||||
ModbusDeviceUpdate,
|
||||
ModbusLatestResponse,
|
||||
ModbusMetricsResponse,
|
||||
ModbusProfilesResponse,
|
||||
ModbusReadingResponse,
|
||||
ModbusReadingsResponse,
|
||||
ModbusTestReadResponse,
|
||||
ProfileSummary,
|
||||
)
|
||||
from app.services.auth import AuthenticatedSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/modbus", tags=["api-modbus"])
|
||||
|
||||
# Maximum number of readings that can be returned per request.
|
||||
_READINGS_LIMIT_MAX = 5000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_device_or_404(db: Session, uuid: str) -> ModbusDevice:
|
||||
"""Return the device with the given UUID or raise 404."""
|
||||
device = db.execute(
|
||||
select(ModbusDevice).where(ModbusDevice.uuid == uuid)
|
||||
).scalar_one_or_none()
|
||||
if device is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Device '{uuid}' not found.",
|
||||
)
|
||||
return device
|
||||
|
||||
|
||||
def _label_from_key(key: str) -> str:
|
||||
"""Derive a human-readable label from a metric key.
|
||||
|
||||
Example: ``"active_power"`` → ``"Active Power"``
|
||||
"""
|
||||
return key.replace("_", " ").title()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/modbus/profiles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/profiles", response_model=ModbusProfilesResponse)
|
||||
def get_profiles(
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> ModbusProfilesResponse:
|
||||
"""List all available Modbus YAML profiles (name + description).
|
||||
|
||||
Intended for the front-end's device-creation profile drop-down.
|
||||
"""
|
||||
pairs = list_profiles()
|
||||
return ModbusProfilesResponse(
|
||||
profiles=[ProfileSummary(name=name, description=desc) for name, desc in pairs]
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/devices", response_model=ModbusDeviceListResponse)
|
||||
def list_devices(
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> ModbusDeviceListResponse:
|
||||
"""Return all Modbus devices (no pagination — device counts stay small)."""
|
||||
devices = db.execute(select(ModbusDevice)).scalars().all()
|
||||
items = [ModbusDeviceResponse.model_validate(d) for d in devices]
|
||||
return ModbusDeviceListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/devices",
|
||||
response_model=ModbusDeviceResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_device(
|
||||
body: ModbusDeviceCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> ModbusDeviceResponse:
|
||||
"""Create a new Modbus device.
|
||||
|
||||
- Validates that the referenced ``profile`` exists; returns 422 if not.
|
||||
- Returns 201 with the created device on success.
|
||||
"""
|
||||
# Validate profile exists (422 if not)
|
||||
try:
|
||||
load_profile(body.profile)
|
||||
except ProfileNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Profile '{body.profile}' does not exist.",
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
device = ModbusDevice(
|
||||
friendly_name=body.friendly_name,
|
||||
transport=body.transport,
|
||||
host=body.host,
|
||||
port=body.port,
|
||||
unit_id=body.unit_id,
|
||||
profile=body.profile,
|
||||
poll_interval_s=body.poll_interval_s,
|
||||
enabled=body.enabled,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(device)
|
||||
db.commit()
|
||||
db.refresh(device)
|
||||
logger.info("Created Modbus device %r (uuid=%s)", device.friendly_name, device.uuid)
|
||||
return ModbusDeviceResponse.model_validate(device)
|
||||
|
||||
|
||||
@router.get("/devices/{uuid}", response_model=ModbusDeviceResponse)
|
||||
def get_device(
|
||||
uuid: str,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> ModbusDeviceResponse:
|
||||
"""Return a single Modbus device by UUID."""
|
||||
device = _get_device_or_404(db, uuid)
|
||||
return ModbusDeviceResponse.model_validate(device)
|
||||
|
||||
|
||||
@router.patch("/devices/{uuid}", response_model=ModbusDeviceResponse)
|
||||
def patch_device(
|
||||
uuid: str,
|
||||
body: ModbusDeviceUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> ModbusDeviceResponse:
|
||||
"""Partially update a Modbus device (including enable/disable).
|
||||
|
||||
Only fields explicitly provided in the request body are updated.
|
||||
Providing ``profile`` triggers a profile-existence check (422 if unknown).
|
||||
"""
|
||||
device = _get_device_or_404(db, uuid)
|
||||
|
||||
update_data = body.model_dump(exclude_none=True)
|
||||
|
||||
# If profile is being changed, validate it exists.
|
||||
if "profile" in update_data:
|
||||
try:
|
||||
load_profile(update_data["profile"])
|
||||
except ProfileNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Profile '{update_data['profile']}' does not exist.",
|
||||
)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(device, field, value)
|
||||
|
||||
device.updated_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
db.refresh(device)
|
||||
logger.info("Updated Modbus device %r (uuid=%s)", device.friendly_name, device.uuid)
|
||||
return ModbusDeviceResponse.model_validate(device)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/devices/{uuid}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_model=None,
|
||||
)
|
||||
def delete_device(
|
||||
uuid: str,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> None:
|
||||
"""Delete a Modbus device.
|
||||
|
||||
Returns 409 Conflict if the device has any associated readings; use
|
||||
``enabled=false`` to disable it instead.
|
||||
|
||||
**Application-layer safety**: the check is performed via an explicit
|
||||
SELECT COUNT query — not by relying on SQLite's FK RESTRICT constraint,
|
||||
which is not enforced at runtime in this project (no PRAGMA foreign_keys=ON).
|
||||
"""
|
||||
device = _get_device_or_404(db, uuid)
|
||||
|
||||
# Application-layer guard: refuse deletion if any readings exist.
|
||||
# We do NOT rely on SQLite FK RESTRICT — it is not enforced at runtime
|
||||
# without PRAGMA foreign_keys=ON, which is not set in this project.
|
||||
reading_count: int = db.execute(
|
||||
select(func.count(ModbusReading.id)).where(ModbusReading.device_id == device.id)
|
||||
).scalar_one()
|
||||
|
||||
if reading_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
f"Device '{uuid}' has {reading_count} associated reading(s) and cannot be "
|
||||
"deleted. Disable the device (set enabled=false) instead to stop polling "
|
||||
"without losing historical data."
|
||||
),
|
||||
)
|
||||
|
||||
logger.info("Deleting Modbus device %r (uuid=%s)", device.friendly_name, uuid)
|
||||
db.delete(device)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Readings endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/devices/{uuid}/latest", response_model=ModbusLatestResponse)
|
||||
def get_latest_reading(
|
||||
uuid: str,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> ModbusLatestResponse:
|
||||
"""Return the most recent reading for a device.
|
||||
|
||||
If no readings exist yet, returns ``{"found": false, "recorded_at": null,
|
||||
"payload": null}`` (200, not 404) so the front-end can distinguish
|
||||
"device exists but has no data" from "device not found".
|
||||
"""
|
||||
device = _get_device_or_404(db, uuid)
|
||||
|
||||
row = db.execute(
|
||||
select(ModbusReading)
|
||||
.where(ModbusReading.device_id == device.id)
|
||||
.order_by(ModbusReading.recorded_at.desc())
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
return ModbusLatestResponse(found=False, recorded_at=None, payload=None)
|
||||
|
||||
return ModbusLatestResponse(
|
||||
found=True,
|
||||
recorded_at=row.recorded_at,
|
||||
payload=row.payload,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/devices/{uuid}/readings", response_model=ModbusReadingsResponse)
|
||||
def get_readings(
|
||||
uuid: str,
|
||||
start: datetime | None = Query(default=None),
|
||||
end: datetime | None = Query(default=None),
|
||||
limit: int = Query(default=500, ge=1, le=_READINGS_LIMIT_MAX),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> ModbusReadingsResponse:
|
||||
"""Return time-range readings for a device.
|
||||
|
||||
Results are ordered by ``recorded_at`` ascending and capped by ``limit``
|
||||
(max {_READINGS_LIMIT_MAX}) to prevent accidental full-table exports.
|
||||
|
||||
The query uses the ``(device_id, recorded_at)`` composite index for
|
||||
efficient time-window scans.
|
||||
|
||||
Query parameters:
|
||||
- ``start``: inclusive lower bound (ISO8601 datetime)
|
||||
- ``end``: inclusive upper bound (ISO8601 datetime)
|
||||
- ``limit``: max rows to return (default 500, max {_READINGS_LIMIT_MAX})
|
||||
"""
|
||||
device = _get_device_or_404(db, uuid)
|
||||
|
||||
stmt = (
|
||||
select(ModbusReading)
|
||||
.where(ModbusReading.device_id == device.id)
|
||||
.order_by(ModbusReading.recorded_at)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if start is not None:
|
||||
stmt = stmt.where(ModbusReading.recorded_at >= start)
|
||||
if end is not None:
|
||||
stmt = stmt.where(ModbusReading.recorded_at <= end)
|
||||
|
||||
rows = db.execute(stmt).scalars().all()
|
||||
items = [ModbusReadingResponse(recorded_at=r.recorded_at, payload=r.payload) for r in rows]
|
||||
return ModbusReadingsResponse(items=items)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Metrics endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/devices/{uuid}/metrics", response_model=ModbusMetricsResponse)
|
||||
def get_metrics(
|
||||
uuid: str,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> ModbusMetricsResponse:
|
||||
"""Return the metric catalogue for a device's profile.
|
||||
|
||||
Each entry has ``key``, ``label`` (derived from key if the profile has
|
||||
none — underscores → spaces, title-cased), ``unit``, and ``device_class``.
|
||||
This is the authoritative metadata source for front-end card labels and
|
||||
chart axis labels.
|
||||
"""
|
||||
device = _get_device_or_404(db, uuid)
|
||||
|
||||
try:
|
||||
profile = load_profile(device.profile)
|
||||
except ProfileNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Device profile '{device.profile}' could not be loaded.",
|
||||
)
|
||||
|
||||
metrics = [
|
||||
MetricInfo(
|
||||
key=m.key,
|
||||
label=_label_from_key(m.key),
|
||||
unit=m.unit,
|
||||
device_class=m.device_class,
|
||||
)
|
||||
for m in profile.metrics
|
||||
]
|
||||
|
||||
return ModbusMetricsResponse(profile=profile.name, metrics=metrics)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test-read endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/devices/{uuid}/test", response_model=ModbusTestReadResponse)
|
||||
def test_read(
|
||||
uuid: str,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> ModbusTestReadResponse:
|
||||
"""Immediately read and decode the device once without persisting any data.
|
||||
|
||||
Useful for validating gateway connectivity and Modbus address settings
|
||||
before relying on the background polling job.
|
||||
|
||||
This is a write-class endpoint (it initiates network I/O on demand) and
|
||||
therefore requires a CSRF token. The response payload is never stored.
|
||||
"""
|
||||
device = _get_device_or_404(db, uuid)
|
||||
|
||||
try:
|
||||
profile = load_profile(device.profile)
|
||||
except ProfileNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Device profile '{device.profile}' could not be loaded.",
|
||||
)
|
||||
|
||||
try:
|
||||
registers = modbus_driver.read_blocks(
|
||||
device.host,
|
||||
device.port,
|
||||
device.unit_id,
|
||||
[{"start": b.start, "count": b.count} for b in profile.blocks],
|
||||
)
|
||||
payload: dict[str, Any] = decode_profile(profile, registers)
|
||||
return ModbusTestReadResponse(ok=True, payload=payload)
|
||||
|
||||
except ModbusDriverError as exc:
|
||||
logger.warning(
|
||||
"Test read failed for device %r (uuid=%s): %s",
|
||||
device.friendly_name,
|
||||
uuid,
|
||||
exc,
|
||||
)
|
||||
return ModbusTestReadResponse(ok=False, error=str(exc))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"Unexpected error during test read for device %r (uuid=%s): %s",
|
||||
device.friendly_name,
|
||||
uuid,
|
||||
exc,
|
||||
)
|
||||
return ModbusTestReadResponse(ok=False, error=f"Unexpected error: {exc}")
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
|
||||
from app import models # noqa: F401
|
||||
from app.api.routes.api.config import router as api_config_router
|
||||
from app.api.routes.api.data import router as api_data_router
|
||||
from app.api.routes.api.modbus import router as api_modbus_router
|
||||
from app.api.routes.api.session import router as api_session_router
|
||||
from app.api.routes import status
|
||||
from app.db import get_session_local
|
||||
@@ -125,6 +126,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(status.router)
|
||||
app.include_router(api_config_router)
|
||||
app.include_router(api_data_router)
|
||||
app.include_router(api_modbus_router)
|
||||
app.include_router(api_session_router)
|
||||
app.include_router(homeassistant_router)
|
||||
app.include_router(location_router)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Pydantic schemas for the Modbus device CRUD + readings + metrics API (M5-T05)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ModbusDeviceCreate(BaseModel):
|
||||
"""Request body for POST /api/modbus/devices."""
|
||||
|
||||
friendly_name: str = Field(..., min_length=1, max_length=255)
|
||||
transport: str = Field(default="tcp", max_length=16)
|
||||
host: str = Field(..., min_length=1, max_length=255)
|
||||
port: int = Field(default=502, ge=1, le=65535)
|
||||
unit_id: int = Field(default=1, ge=0, le=247)
|
||||
profile: str = Field(..., min_length=1, max_length=64)
|
||||
poll_interval_s: int = Field(default=5, ge=1, le=3600)
|
||||
enabled: bool = Field(default=True)
|
||||
|
||||
|
||||
class ModbusDeviceUpdate(BaseModel):
|
||||
"""Request body for PATCH /api/modbus/devices/{uuid} — all fields optional."""
|
||||
|
||||
friendly_name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
transport: str | None = Field(default=None, max_length=16)
|
||||
host: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
port: int | None = Field(default=None, ge=1, le=65535)
|
||||
unit_id: int | None = Field(default=None, ge=0, le=247)
|
||||
profile: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
poll_interval_s: int | None = Field(default=None, ge=1, le=3600)
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
class ModbusDeviceResponse(BaseModel):
|
||||
"""Response schema for a single Modbus device."""
|
||||
|
||||
uuid: str
|
||||
friendly_name: str
|
||||
transport: str
|
||||
host: str
|
||||
port: int
|
||||
unit_id: int
|
||||
profile: str
|
||||
poll_interval_s: int
|
||||
enabled: bool
|
||||
last_poll_at: datetime | None
|
||||
last_poll_ok: bool | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ModbusDeviceListResponse(BaseModel):
|
||||
"""Response schema for listing Modbus devices."""
|
||||
|
||||
items: list[ModbusDeviceResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reading schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ModbusReadingResponse(BaseModel):
|
||||
"""A single reading row: timestamp + decoded payload."""
|
||||
|
||||
recorded_at: datetime
|
||||
payload: dict[str, Any]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ModbusReadingsResponse(BaseModel):
|
||||
"""Response schema for the readings time-range endpoint."""
|
||||
|
||||
items: list[ModbusReadingResponse]
|
||||
|
||||
|
||||
class ModbusLatestResponse(BaseModel):
|
||||
"""Response for the /latest endpoint.
|
||||
|
||||
``found`` is False and ``recorded_at``/``payload`` are None when the device
|
||||
has no readings yet. Callers should check ``found`` before using the values.
|
||||
"""
|
||||
|
||||
found: bool
|
||||
recorded_at: datetime | None
|
||||
payload: dict[str, Any] | None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Metrics / profile schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MetricInfo(BaseModel):
|
||||
"""Metadata for a single measurable quantity in a device's profile."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
unit: str
|
||||
device_class: str
|
||||
|
||||
|
||||
class ModbusMetricsResponse(BaseModel):
|
||||
"""Response schema for GET /api/modbus/devices/{uuid}/metrics."""
|
||||
|
||||
profile: str
|
||||
metrics: list[MetricInfo]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profile list schema
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProfileSummary(BaseModel):
|
||||
"""One entry in the GET /api/modbus/profiles response."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
class ModbusProfilesResponse(BaseModel):
|
||||
"""Response schema for GET /api/modbus/profiles."""
|
||||
|
||||
profiles: list[ProfileSummary]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test-read schema
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ModbusTestReadResponse(BaseModel):
|
||||
"""Response for POST /api/modbus/devices/{uuid}/test.
|
||||
|
||||
On success ``ok=True`` and ``payload`` contains the decoded values.
|
||||
On failure ``ok=False`` and ``error`` describes the problem.
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
payload: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
Reference in New Issue
Block a user