M5-T04: add Modbus poll service and APScheduler polling job

This commit is contained in:
2026-06-22 13:24:11 +02:00
parent c89cea4953
commit 49d15d8ffe
5 changed files with 671 additions and 1 deletions
+3
View File
@@ -41,6 +41,9 @@ class Settings(BaseSettings):
auth_trust_forwarded_for: bool = False
auth_totp_issuer: str = "" # defaults to app_name when empty
# Modbus polling — global kill-switch; T08 will wire this into CONFIG_FIELDS/UI.
modbus_polling_enabled: bool = True
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
+18
View File
@@ -25,6 +25,7 @@ from app.config import get_settings
from app.services.auth import AuthBootstrapError, initialize_auth_schema
from app.services.config_page import seed_missing_config_from_bootstrap, sync_app_hostname_from_bootstrap
from app.services.public_ip import check_public_ipv4_and_notify
from app.services.modbus_poll import poll_all_enabled_devices, BASE_POLL_TICK_SECONDS
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
logger = logging.getLogger(__name__)
@@ -48,6 +49,15 @@ def _run_scheduled_public_ip_check() -> None:
session.close()
def _run_scheduled_modbus_poll() -> None:
session_local = get_session_local()
session: Session = session_local()
try:
poll_all_enabled_devices(session, bootstrap_settings=get_settings())
finally:
session.close()
def ensure_auth_db_ready() -> None:
session_local = get_session_local()
session: Session = session_local()
@@ -83,6 +93,14 @@ async def lifespan(_: FastAPI):
max_instances=1,
coalesce=True,
)
scheduler.add_job(
_run_scheduled_modbus_poll,
trigger=IntervalTrigger(seconds=BASE_POLL_TICK_SECONDS),
id="modbus-poll",
replace_existing=True,
max_instances=1,
coalesce=True,
)
scheduler.start()
yield
scheduler.shutdown(wait=False)
+184
View File
@@ -0,0 +1,184 @@
"""Modbus polling service — periodic read-and-store for enabled Modbus devices.
Design notes
------------
- ``poll_device`` loads the device's YAML profile, calls the driver to bulk-read
all register blocks in a single TCP connection, decodes the raw registers into
an engineering-value dict via the profile, and persists one ``ModbusReading``
row. It also stamps ``last_poll_at`` / ``last_poll_ok`` on the device.
- All exceptions are caught and logged inside ``poll_device``; the function
never re-raises. This prevents a single broken device from aborting the
sweep for all other devices.
- ``poll_all_enabled_devices`` respects per-device ``poll_interval_s``: it skips
a device whose ``last_poll_at`` is recent enough. This lets the APScheduler
job run on a short base tick (e.g. 5 s) while each device self-regulates its
own cadence.
- The global ``modbus_polling_enabled`` flag from ``Settings`` (merged with any
DB overrides via ``build_runtime_settings``) gates the entire sweep as a
no-op. T08 will expose this flag in CONFIG_FIELDS/UI; for now it is
controlled by the ``MODBUS_POLLING_ENABLED`` env var (or the default True).
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings
from app.integrations.modbus import driver, profiles
from app.models.modbus import ModbusDevice, ModbusReading
from app.services.config_page import build_runtime_settings
logger = logging.getLogger(__name__)
# Base polling tick in seconds (used by APScheduler; each device checks its own interval).
BASE_POLL_TICK_SECONDS = 5
def poll_device(session: Session, device: ModbusDevice) -> ModbusReading | None:
"""Read one enabled Modbus device, decode, and persist a reading row.
Parameters
----------
session:
Open SQLAlchemy session. The caller is responsible for session
lifecycle (open / commit / close).
device:
``ModbusDevice`` ORM instance to poll.
Returns
-------
ModbusReading | None
The newly-persisted reading on success, or ``None`` on any failure.
Failure is logged and the device's ``last_poll_ok`` is set to ``False``.
"""
now = datetime.now(UTC)
try:
profile = profiles.load_profile(device.profile)
registers = driver.read_blocks(
device.host,
device.port,
device.unit_id,
[{"start": b.start, "count": b.count} for b in profile.blocks],
)
payload = profiles.decode(profile, registers)
reading = ModbusReading(
device_id=device.id,
recorded_at=now,
payload=payload,
)
session.add(reading)
device.last_poll_at = now
device.last_poll_ok = True
session.commit()
logger.debug(
"Polled device %r (id=%d): %d metrics recorded at %s",
device.friendly_name,
device.id,
len(payload),
now.isoformat(),
)
return reading
except Exception: # noqa: BLE001
logger.exception(
"Failed to poll device %r (id=%d, host=%s:%d, unit_id=%d)",
device.friendly_name,
device.id,
device.host,
device.port,
device.unit_id,
)
# Rollback first — if session.add(reading) already ran in the success
# branch before commit() failed, that pending ModbusReading must be
# discarded. Without rollback it would remain staged and could be
# flushed/committed by a subsequent operation (even for a different
# device), producing a spurious reading row for a poll that failed.
# Rollback also expires all ORM objects; device fields are re-set below.
try:
session.rollback()
except Exception: # noqa: BLE001
logger.exception(
"session.rollback() failed for device id=%d; session may be unusable", device.id
)
try:
device.last_poll_at = now
device.last_poll_ok = False
session.commit()
except Exception: # noqa: BLE001
logger.exception(
"Also failed to persist last_poll_ok=False for device id=%d", device.id
)
return None
def poll_all_enabled_devices(
session: Session,
*,
bootstrap_settings: Settings,
) -> None:
"""Sweep all enabled Modbus devices and poll each one whose interval has elapsed.
Global kill-switch: if ``build_runtime_settings(...).modbus_polling_enabled``
is ``False``, this function returns immediately without touching any device.
Per-device interval: a device is skipped if its ``last_poll_at`` is not yet
``poll_interval_s`` seconds in the past. This allows the APScheduler job to
run on a short fixed tick while each device self-regulates its own cadence.
Exceptions from individual devices are swallowed inside ``poll_device``;
this function itself will not raise.
Parameters
----------
session:
Open SQLAlchemy session.
bootstrap_settings:
The bootstrap ``Settings`` instance (from ``get_settings()``), used as
the base for ``build_runtime_settings`` to pick up any DB overrides.
"""
try:
runtime_settings = build_runtime_settings(session, bootstrap_settings)
except Exception: # noqa: BLE001
logger.exception("Failed to build runtime settings for Modbus poll; aborting sweep")
return
if not runtime_settings.modbus_polling_enabled:
logger.debug("Modbus polling is disabled (modbus_polling_enabled=False); skipping sweep")
return
try:
devices = session.execute(
select(ModbusDevice).where(ModbusDevice.enabled == True) # noqa: E712
).scalars().all()
except Exception: # noqa: BLE001
logger.exception("Failed to query enabled Modbus devices; aborting sweep")
return
now = datetime.now(UTC)
for device in devices:
# Respect per-device poll interval: skip if last poll was too recent.
if device.last_poll_at is not None:
# SQLite may return a naive datetime despite DateTime(timezone=True) —
# treat naive values as UTC so the comparison is safe.
last_poll = device.last_poll_at
if last_poll.tzinfo is None:
last_poll = last_poll.replace(tzinfo=UTC)
elapsed = (now - last_poll).total_seconds()
if elapsed < device.poll_interval_s:
logger.debug(
"Skipping device %r (id=%d): %.1fs elapsed < %ds interval",
device.friendly_name,
device.id,
elapsed,
device.poll_interval_s,
)
continue
poll_device(session, device)