M8-T04: reconcile DSMR ingest from meter sources
This commit is contained in:
@@ -129,27 +129,6 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = (
|
||||
"HA State Topic Prefix",
|
||||
),
|
||||
ConfigField("Modbus", "MODBUS_POLLING_ENABLED", "modbus_polling_enabled", "Modbus Polling Enabled", input_type="checkbox"),
|
||||
ConfigField(
|
||||
"DSMR",
|
||||
"DSMR_INGEST_ENABLED",
|
||||
"dsmr_ingest_enabled",
|
||||
"DSMR Ingest Enabled",
|
||||
input_type="checkbox",
|
||||
),
|
||||
ConfigField("DSMR", "DSMR_MQTT_TOPIC", "dsmr_mqtt_topic", "DSMR MQTT Topic"),
|
||||
ConfigField(
|
||||
"DSMR",
|
||||
"DSMR_SAMPLE_INTERVAL_S",
|
||||
"dsmr_sample_interval_s",
|
||||
"DSMR Sample Interval (s)",
|
||||
input_type="number",
|
||||
),
|
||||
ConfigField(
|
||||
"DSMR",
|
||||
"DSMR_TARIFF_TOPIC",
|
||||
"dsmr_tariff_topic",
|
||||
"DSMR Tariff Topic",
|
||||
),
|
||||
ConfigField(
|
||||
"Tibber",
|
||||
"TIBBER_API_TOKEN",
|
||||
@@ -358,10 +337,6 @@ def _settings_payload(settings: Settings) -> dict[str, Any]:
|
||||
"ha_discovery_enabled": settings.ha_discovery_enabled,
|
||||
"ha_discovery_prefix": settings.ha_discovery_prefix,
|
||||
"ha_state_topic_prefix": settings.ha_state_topic_prefix,
|
||||
"dsmr_ingest_enabled": settings.dsmr_ingest_enabled,
|
||||
"dsmr_mqtt_topic": settings.dsmr_mqtt_topic,
|
||||
"dsmr_sample_interval_s": settings.dsmr_sample_interval_s,
|
||||
"dsmr_tariff_topic": settings.dsmr_tariff_topic,
|
||||
"tibber_api_token": settings.tibber_api_token,
|
||||
"tibber_home_id": settings.tibber_home_id,
|
||||
}
|
||||
|
||||
+276
-247
@@ -1,46 +1,21 @@
|
||||
"""DSMR telegram ingest service.
|
||||
|
||||
Subscribes to the DSMR Reader MQTT topic (``dsmr/json``) and persists
|
||||
down-sampled DSMR telegram frames to the ``dsmr_reading`` table.
|
||||
|
||||
Design decisions
|
||||
----------------
|
||||
- **Whole-frame storage**: the entire parsed telegram dict is stored as a JSON
|
||||
blob in ``DsmrReading.payload``; no field allow-list is applied. This lets
|
||||
future commodities (gas, heating, three-phase) be accommodated without a
|
||||
table-schema change.
|
||||
- **10-second down-sampling** (configurable via ``dsmr_sample_interval_s``):
|
||||
only telegrams whose ``timestamp`` second falls on an exact multiple of the
|
||||
interval are persisted. This reduces write volume from ~60 rows/min to ~6
|
||||
rows/min while guaranteeing that every 15-minute boundary (second=00) is
|
||||
captured.
|
||||
- **Idempotency**: the telegram's own ``id`` field is stored as ``source_id``
|
||||
with a UNIQUE constraint. A second delivery of the same telegram (e.g. after
|
||||
a broker reconnect) is silently skipped.
|
||||
- **Network-thread safety**: ``handle_message`` is called from paho's background
|
||||
loop thread. It opens and closes its own short-lived SQLAlchemy session and
|
||||
swallows all exceptions so that a buggy payload or transient DB error never
|
||||
crashes the paho loop or drops the MQTT connection.
|
||||
- **Numeric values kept as strings**: the DSMR Reader emits all numeric readings
|
||||
as JSON strings (e.g. ``"20915.154"``). They are stored verbatim; conversion
|
||||
to ``Decimal`` is deferred to the billing engine (T07) where precision matters.
|
||||
- **Null phases**: some telegrams omit certain phase readings (``null`` in JSON);
|
||||
these are stored as-is without special handling.
|
||||
"""
|
||||
"""DSMR MQTT ingest, keyed by durable meter-source identity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sqlalchemy.exc
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import get_session_local
|
||||
from app.models.energy import DsmrReading
|
||||
from app.models.energy import DsmrReading, Meter
|
||||
from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.config import Settings
|
||||
@@ -48,250 +23,304 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Tracks the DSMR topic currently subscribed via the MQTT manager, so a config
|
||||
# change can unsubscribe the old topic before subscribing the new one.
|
||||
_current_dsmr_topic: str | None = None
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DsmrSourceSnapshot:
|
||||
"""The only DSMR runtime configuration a network handler may use."""
|
||||
|
||||
# Tracks the DSMR tariff topic currently subscribed via the MQTT manager.
|
||||
_current_tariff_topic: str | None = None
|
||||
source_id: int
|
||||
topic: str
|
||||
tariff_topic: str
|
||||
sample_interval_s: int
|
||||
broker_host: str = ""
|
||||
broker_port: int = 1883
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
tls_enabled: bool = False
|
||||
|
||||
# Current electricity tariff: 1 = dal/off-peak, 2 = normal/peak.
|
||||
# Written by the paho network thread, read by the publish job — guarded by a lock.
|
||||
_current_tariff: int | None = None
|
||||
|
||||
_subscriptions: dict[int, DsmrSourceSnapshot] = {}
|
||||
_subscription_lock = threading.RLock()
|
||||
# A configuration value is not an ownership identity: disable and re-enable
|
||||
# can produce an equal snapshot. Each installed handler therefore captures a
|
||||
# fresh token and verifies object identity before it can write or update tariff.
|
||||
_subscription_tokens: dict[int, object] = {}
|
||||
_reconcile_lock = threading.RLock()
|
||||
_tariffs: dict[int, int] = {}
|
||||
_tariff_lock = threading.Lock()
|
||||
# Kept only for legacy direct test callers of set_current_tariff(value). Runtime
|
||||
# handlers never write this value; production callers resolve a binding first.
|
||||
_current_tariff: int | None = None
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def get_current_tariff() -> int | None:
|
||||
"""Return the most recently received electricity tariff (1 or 2), or None."""
|
||||
with _tariff_lock:
|
||||
def get_current_tariff(meter_source_id: int | None = None) -> int | None:
|
||||
"""Return a source tariff, or resolve the active electricity binding.
|
||||
|
||||
The no-argument form is retained for the pre-M8 expose integration. It
|
||||
opens a short session to select the current electricity binding, so a
|
||||
source's MQTT callback can never make another source's tariff current.
|
||||
``_current_tariff`` is solely a test-era fallback when no binding database
|
||||
is available; runtime MQTT handlers do not update it.
|
||||
"""
|
||||
if meter_source_id is None:
|
||||
session_local = get_session_local()
|
||||
session = session_local()
|
||||
try:
|
||||
source_id = _current_electricity_source_id(session, datetime.now(timezone.utc))
|
||||
if source_id is not None:
|
||||
return get_current_tariff(source_id)
|
||||
except Exception:
|
||||
logger.debug("DSMR legacy tariff lookup could not resolve a binding", exc_info=True)
|
||||
finally:
|
||||
session.close()
|
||||
return _current_tariff
|
||||
|
||||
|
||||
def set_current_tariff(value: int | None) -> None:
|
||||
"""Set the current electricity tariff (1 or 2), or clear it with None."""
|
||||
with _tariff_lock:
|
||||
global _current_tariff
|
||||
_current_tariff = value
|
||||
return _tariffs.get(meter_source_id)
|
||||
|
||||
|
||||
def handle_tariff_message(payload_bytes: bytes) -> None:
|
||||
"""Parse one DSMR tariff MQTT payload and update the in-memory tariff state.
|
||||
|
||||
Called from the paho network thread; *must* swallow all exceptions so that
|
||||
a bad payload never crashes the loop or drops the broker connection.
|
||||
|
||||
Accepts payload as bytes or str (paho can deliver either). Ignores
|
||||
whitespace. Only accepts integer values 1 or 2; anything else is discarded
|
||||
and the previous known tariff is preserved.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
payload_bytes:
|
||||
Raw bytes from the MQTT message (may also be a str in some paho versions).
|
||||
"""
|
||||
try:
|
||||
# Decode bytes → str if needed; strip surrounding whitespace.
|
||||
if isinstance(payload_bytes, (bytes, bytearray)):
|
||||
raw = payload_bytes.decode("utf-8", errors="replace").strip()
|
||||
def set_current_tariff(meter_source_id: int, value: int | None | object = _UNSET) -> None:
|
||||
"""Set or clear an individual source's tariff state."""
|
||||
global _current_tariff
|
||||
if value is _UNSET:
|
||||
# Compatibility with older direct callers. Do not route runtime source
|
||||
# updates through this global fallback.
|
||||
_current_tariff = meter_source_id if meter_source_id in (1, 2) else None
|
||||
return
|
||||
with _tariff_lock:
|
||||
if value is None:
|
||||
_tariffs.pop(meter_source_id, None)
|
||||
else:
|
||||
raw = str(payload_bytes).strip()
|
||||
_tariffs[meter_source_id] = value
|
||||
|
||||
|
||||
def _current_electricity_source_id(session: Session, at: datetime) -> int | None:
|
||||
"""Return the DSMR source bound to electricity at ``at``, if any."""
|
||||
return session.scalar(
|
||||
select(MeterSource.id)
|
||||
.join(MeterSourceChannel, MeterSourceChannel.source_id == MeterSource.id)
|
||||
.join(MeterSourceBinding, MeterSourceBinding.channel_id == MeterSourceChannel.id)
|
||||
.join(Meter, Meter.id == MeterSourceBinding.meter_id)
|
||||
.where(
|
||||
Meter.commodity == "electricity",
|
||||
MeterSourceBinding.started_at <= at,
|
||||
(MeterSourceBinding.ended_at.is_(None)) | (MeterSourceBinding.ended_at > at),
|
||||
)
|
||||
.order_by(MeterSourceBinding.started_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
|
||||
def get_current_electricity_tariff(session: Session, at: datetime | None = None) -> int | None:
|
||||
"""Resolve tariff through the current electricity binding, never globally."""
|
||||
source_id = _current_electricity_source_id(session, at or datetime.now(timezone.utc))
|
||||
if source_id is None:
|
||||
return None
|
||||
return get_current_tariff(source_id)
|
||||
|
||||
|
||||
def handle_tariff_message(payload_bytes: bytes, meter_source_id: int) -> None:
|
||||
"""Parse one source's tariff payload without raising in paho's thread."""
|
||||
try:
|
||||
raw = (
|
||||
payload_bytes.decode("utf-8", errors="replace").strip()
|
||||
if isinstance(payload_bytes, (bytes, bytearray))
|
||||
else str(payload_bytes).strip()
|
||||
)
|
||||
value = int(raw)
|
||||
if value not in (1, 2):
|
||||
logger.debug(
|
||||
"dsmr_ingest.handle_tariff_message: unexpected tariff value %d (expected 1 or 2, ignored).",
|
||||
value,
|
||||
)
|
||||
return
|
||||
|
||||
set_current_tariff(value)
|
||||
logger.debug("dsmr_ingest.handle_tariff_message: tariff updated to %d.", value)
|
||||
|
||||
if value in (1, 2):
|
||||
set_current_tariff(meter_source_id, value)
|
||||
except Exception:
|
||||
# Malformed payload (e.g. non-numeric); swallow silently to protect network thread.
|
||||
logger.debug(
|
||||
"dsmr_ingest.handle_tariff_message: could not parse payload %r (ignored).",
|
||||
payload_bytes,
|
||||
)
|
||||
logger.debug("DSMR tariff payload ignored for source_id=%s", meter_source_id)
|
||||
|
||||
|
||||
def apply_dsmr_subscription(settings: "Settings") -> None:
|
||||
"""(Re)apply the DSMR MQTT subscriptions to match *settings* — restart-free.
|
||||
|
||||
Call this at startup and after every config save. It makes the live MQTT
|
||||
subscriptions reflect the current ``dsmr_ingest_enabled`` / ``dsmr_mqtt_topic``
|
||||
/ ``dsmr_sample_interval_s`` / ``dsmr_tariff_topic`` settings without an app
|
||||
restart:
|
||||
|
||||
- **Disabled** → unsubscribe any active DSMR and tariff subscriptions.
|
||||
- **Enabled** → (re)subscribe to ``dsmr_mqtt_topic`` with a handler bound to
|
||||
a *fresh* settings snapshot, so a changed sample interval also takes effect.
|
||||
Also subscribe to ``dsmr_tariff_topic`` when non-empty.
|
||||
- **Topic changed** → unsubscribe the old topic before subscribing the new one.
|
||||
|
||||
Idempotent and safe to call when MQTT is not connected (the subscription is
|
||||
queued in the manager and established on the next connect).
|
||||
"""
|
||||
# Imported here (not at module top) to avoid a circular import at app start.
|
||||
from app.integrations.mqtt import mqtt_manager
|
||||
|
||||
global _current_dsmr_topic, _current_tariff_topic
|
||||
|
||||
if not settings.dsmr_ingest_enabled:
|
||||
if _current_dsmr_topic is not None:
|
||||
mqtt_manager.unsubscribe(_current_dsmr_topic)
|
||||
logger.info("DSMR ingest disabled — unsubscribed from topic=%s.", _current_dsmr_topic)
|
||||
_current_dsmr_topic = None
|
||||
if _current_tariff_topic is not None:
|
||||
mqtt_manager.unsubscribe(_current_tariff_topic)
|
||||
logger.info(
|
||||
"DSMR ingest disabled — unsubscribed from tariff topic=%s.",
|
||||
_current_tariff_topic,
|
||||
)
|
||||
_current_tariff_topic = None
|
||||
return
|
||||
|
||||
# --- Main DSMR telegram topic ---
|
||||
topic = settings.dsmr_mqtt_topic
|
||||
if _current_dsmr_topic is not None and _current_dsmr_topic != topic:
|
||||
mqtt_manager.unsubscribe(_current_dsmr_topic)
|
||||
|
||||
# Re-subscribe (overwrites any existing handler for this topic) with a fresh
|
||||
# settings snapshot so dsmr_sample_interval_s changes take effect too.
|
||||
snapshot = settings
|
||||
mqtt_manager.subscribe(topic, lambda payload: handle_message(payload, snapshot))
|
||||
_current_dsmr_topic = topic
|
||||
logger.info("DSMR ingest enabled — subscribed to topic=%s.", topic)
|
||||
|
||||
# --- DSMR tariff topic (dual-tariff slot indicator) ---
|
||||
tariff_topic = settings.dsmr_tariff_topic if settings.dsmr_tariff_topic else ""
|
||||
if tariff_topic:
|
||||
if _current_tariff_topic is not None and _current_tariff_topic != tariff_topic:
|
||||
mqtt_manager.unsubscribe(_current_tariff_topic)
|
||||
mqtt_manager.subscribe(tariff_topic, lambda payload: handle_tariff_message(payload))
|
||||
_current_tariff_topic = tariff_topic
|
||||
logger.info("DSMR tariff topic — subscribed to topic=%s.", tariff_topic)
|
||||
else:
|
||||
# tariff_topic is empty → unsubscribe any existing tariff subscription.
|
||||
if _current_tariff_topic is not None:
|
||||
mqtt_manager.unsubscribe(_current_tariff_topic)
|
||||
logger.info(
|
||||
"DSMR tariff topic cleared — unsubscribed from topic=%s.",
|
||||
_current_tariff_topic,
|
||||
)
|
||||
_current_tariff_topic = None
|
||||
def _snapshot(source: MeterSource) -> DsmrSourceSnapshot:
|
||||
config = source.config
|
||||
return DsmrSourceSnapshot(
|
||||
source_id=source.id,
|
||||
topic=str(config.get("topic", "dsmr/json")),
|
||||
tariff_topic=str(config.get("tariff_topic", "")),
|
||||
sample_interval_s=int(config.get("sample_interval_s", 10)),
|
||||
broker_host=str(config.get("broker_host", "")),
|
||||
broker_port=int(config.get("broker_port", 1883)),
|
||||
username=str(config.get("username", "")),
|
||||
password=str(config.get("password", "")),
|
||||
tls_enabled=bool(config.get("tls_enabled", False)),
|
||||
)
|
||||
|
||||
|
||||
def handle_message(payload_bytes: bytes, settings: "Settings") -> None:
|
||||
"""Parse one DSMR MQTT payload and persist it if it passes the sample filter.
|
||||
|
||||
Called from the paho network thread; *must* swallow all exceptions so that
|
||||
a bad payload or transient error does not crash the loop or drop the broker
|
||||
connection.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
payload_bytes:
|
||||
Raw bytes from the MQTT message.
|
||||
settings:
|
||||
Runtime settings snapshot (captured at subscription time). Used for
|
||||
``dsmr_sample_interval_s``.
|
||||
"""
|
||||
try:
|
||||
_handle_message_inner(payload_bytes, settings)
|
||||
except Exception:
|
||||
logger.exception("dsmr_ingest.handle_message: unexpected error (swallowed).")
|
||||
|
||||
|
||||
def _handle_message_inner(payload_bytes: bytes, settings: "Settings") -> None:
|
||||
"""Inner implementation — may raise; caller wraps in try/except."""
|
||||
# --- 1. Parse JSON ---
|
||||
try:
|
||||
data: dict = json.loads(payload_bytes)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
logger.debug("dsmr_ingest: invalid JSON payload (skipped).")
|
||||
return
|
||||
|
||||
if not isinstance(data, dict):
|
||||
logger.debug("dsmr_ingest: payload is not a JSON object (skipped).")
|
||||
return
|
||||
|
||||
# --- 2. Parse timestamp ---
|
||||
raw_ts = data.get("timestamp")
|
||||
if raw_ts is None:
|
||||
logger.debug("dsmr_ingest: missing 'timestamp' field (skipped).")
|
||||
return
|
||||
|
||||
try:
|
||||
# Python 3.11+ accepts the trailing 'Z' directly; for 3.10 compat we
|
||||
# replace 'Z' with '+00:00' before parsing.
|
||||
ts_str = raw_ts if not isinstance(raw_ts, str) else raw_ts.replace("Z", "+00:00")
|
||||
ts_utc: datetime = datetime.fromisoformat(ts_str)
|
||||
# Ensure it is timezone-aware UTC.
|
||||
if ts_utc.tzinfo is None:
|
||||
ts_utc = ts_utc.replace(tzinfo=timezone.utc)
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
logger.debug(
|
||||
"dsmr_ingest: cannot parse 'timestamp' value %r (skipped).", raw_ts
|
||||
)
|
||||
return
|
||||
|
||||
# --- 3. Down-sample: only persist if second falls on interval boundary ---
|
||||
interval = settings.dsmr_sample_interval_s
|
||||
if interval > 0 and (ts_utc.second % interval) != 0:
|
||||
# This telegram is between sample points; discard silently.
|
||||
return
|
||||
|
||||
# --- 4. Extract source_id (telegram's own id) — stored only as a reference,
|
||||
# NOT used for uniqueness/idempotency (it overflows and gets reset). ---
|
||||
source_id: int | None = data.get("id")
|
||||
if source_id is not None and not isinstance(source_id, int):
|
||||
# Unexpected type — treat as missing rather than raising.
|
||||
logger.debug(
|
||||
"dsmr_ingest: 'id' field has unexpected type %s (ignoring).",
|
||||
type(source_id).__name__,
|
||||
)
|
||||
source_id = None
|
||||
|
||||
# --- 5. Persist to database ---
|
||||
# Idempotency is keyed on recorded_at (the telegram timestamp), which is
|
||||
# telegram-id-independent: a single P1 meter emits one telegram per second,
|
||||
# and down-sampling keeps at most one per interval-aligned second. The
|
||||
# UNIQUE(recorded_at) constraint is the backstop for the IntegrityError race.
|
||||
def _enabled_snapshots() -> list[DsmrSourceSnapshot]:
|
||||
session_local = get_session_local()
|
||||
session = session_local()
|
||||
try:
|
||||
existing = session.scalar(
|
||||
select(DsmrReading).where(DsmrReading.recorded_at == ts_utc)
|
||||
)
|
||||
if existing is not None:
|
||||
logger.debug(
|
||||
"dsmr_ingest: recorded_at=%s already in DB, skipping.",
|
||||
ts_utc.isoformat(),
|
||||
sources = session.scalars(
|
||||
select(MeterSource).where(MeterSource.kind == "dsmr_mqtt", MeterSource.enabled.is_(True))
|
||||
).all()
|
||||
return [_snapshot(source) for source in sources]
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def apply_dsmr_subscription(_settings: "Settings | None" = None) -> None:
|
||||
"""Reconcile enabled DSMR source subscriptions from the database.
|
||||
|
||||
The optional, ignored settings parameter preserves the old config-route
|
||||
call shape while ensuring flat DSMR settings are no longer read.
|
||||
"""
|
||||
from app.integrations.mqtt import mqtt_manager
|
||||
|
||||
try:
|
||||
desired = {snapshot.source_id: snapshot for snapshot in _enabled_snapshots()}
|
||||
except Exception:
|
||||
logger.exception("DSMR subscription reconcile failed while reading sources")
|
||||
return
|
||||
|
||||
# Do not retain _subscription_lock while stopping MQTT clients: a callback
|
||||
# may currently hold it through its complete dispatch, and MqttManager
|
||||
# waits for that callback before teardown returns.
|
||||
with _reconcile_lock:
|
||||
# Every source owns a distinct MQTT client, so equal topics from
|
||||
# different sources/brokers are dispatchable. A telegram and tariff
|
||||
# topic on the *same* client would overwrite one handler, however.
|
||||
for source_id, snapshot in list(desired.items()):
|
||||
if snapshot.tariff_topic and snapshot.topic == snapshot.tariff_topic:
|
||||
logger.error("DSMR source_id=%s rejected: telegram/tariff topic collision", source_id)
|
||||
desired.pop(source_id)
|
||||
|
||||
with _subscription_lock:
|
||||
stale_source_ids = [
|
||||
source_id
|
||||
for source_id, current in _subscriptions.items()
|
||||
if desired.get(source_id) != current
|
||||
]
|
||||
for source_id in stale_source_ids:
|
||||
_subscriptions.pop(source_id, None)
|
||||
_subscription_tokens.pop(source_id, None)
|
||||
set_current_tariff(source_id, None)
|
||||
|
||||
for source_id in stale_source_ids:
|
||||
mqtt_manager.remove_source(source_id)
|
||||
|
||||
for source_id, snapshot in desired.items():
|
||||
with _subscription_lock:
|
||||
current = _subscriptions.get(source_id)
|
||||
if current == snapshot and mqtt_manager.source_is_active(source_id):
|
||||
continue
|
||||
if current is not None:
|
||||
# The client went inactive outside reconcile. Invalidate its
|
||||
# old token before rebuilding the same snapshot.
|
||||
with _subscription_lock:
|
||||
if _subscriptions.get(source_id) == current:
|
||||
_subscriptions.pop(source_id, None)
|
||||
_subscription_tokens.pop(source_id, None)
|
||||
mqtt_manager.remove_source(source_id)
|
||||
token = object()
|
||||
handlers = {
|
||||
snapshot.topic: lambda payload, captured=snapshot, captured_token=token: (
|
||||
handle_captured_message(payload, captured, captured_token)
|
||||
)
|
||||
}
|
||||
if snapshot.tariff_topic:
|
||||
handlers[snapshot.tariff_topic] = (
|
||||
lambda payload, captured=snapshot, captured_token=token: (
|
||||
handle_captured_tariff_message(payload, captured, captured_token)
|
||||
)
|
||||
)
|
||||
with _subscription_lock:
|
||||
_subscriptions[source_id] = snapshot
|
||||
_subscription_tokens[source_id] = token
|
||||
applied = mqtt_manager.replace_source(
|
||||
source_id,
|
||||
host=snapshot.broker_host,
|
||||
port=snapshot.broker_port,
|
||||
username=snapshot.username,
|
||||
password=snapshot.password,
|
||||
tls_enabled=snapshot.tls_enabled,
|
||||
subscriptions=handlers,
|
||||
)
|
||||
if not applied:
|
||||
with _subscription_lock:
|
||||
if _subscription_tokens.get(source_id) is token:
|
||||
_subscriptions.pop(source_id, None)
|
||||
_subscription_tokens.pop(source_id, None)
|
||||
|
||||
|
||||
def handle_message(payload_bytes: bytes, snapshot: DsmrSourceSnapshot) -> None:
|
||||
"""Persist one down-sampled frame under its captured source identity."""
|
||||
try:
|
||||
_handle_message_inner(payload_bytes, snapshot)
|
||||
except Exception:
|
||||
logger.exception("DSMR ingest handler failed for source_id=%s (swallowed)", snapshot.source_id)
|
||||
|
||||
|
||||
def handle_captured_message(
|
||||
payload_bytes: bytes, snapshot: DsmrSourceSnapshot, token: object | None = None
|
||||
) -> None:
|
||||
"""Run a broker callback only while its exact source generation is active."""
|
||||
with _subscription_lock:
|
||||
if token is not None:
|
||||
if _subscription_tokens.get(snapshot.source_id) is not token:
|
||||
return
|
||||
elif _subscriptions.get(snapshot.source_id) != snapshot:
|
||||
return
|
||||
handle_message(payload_bytes, snapshot)
|
||||
|
||||
|
||||
def handle_captured_tariff_message(
|
||||
payload_bytes: bytes, snapshot: DsmrSourceSnapshot, token: object | None = None
|
||||
) -> None:
|
||||
"""Ignore tariff callbacks retained from a removed/replaced source."""
|
||||
with _subscription_lock:
|
||||
if token is not None:
|
||||
if _subscription_tokens.get(snapshot.source_id) is not token:
|
||||
return
|
||||
elif _subscriptions.get(snapshot.source_id) != snapshot:
|
||||
return
|
||||
handle_tariff_message(payload_bytes, snapshot.source_id)
|
||||
|
||||
reading = DsmrReading(
|
||||
recorded_at=ts_utc,
|
||||
source_id=source_id,
|
||||
payload=data, # full frame, verbatim
|
||||
|
||||
def _handle_message_inner(payload_bytes: bytes, snapshot: DsmrSourceSnapshot) -> None:
|
||||
try:
|
||||
data = json.loads(payload_bytes)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
try:
|
||||
raw_ts = data["timestamp"]
|
||||
ts_utc = datetime.fromisoformat(raw_ts.replace("Z", "+00:00"))
|
||||
if ts_utc.tzinfo is None:
|
||||
ts_utc = ts_utc.replace(tzinfo=timezone.utc)
|
||||
except (KeyError, ValueError, TypeError, AttributeError):
|
||||
return
|
||||
if snapshot.sample_interval_s > 0 and ts_utc.second % snapshot.sample_interval_s:
|
||||
return
|
||||
telegram_id = data.get("id")
|
||||
if telegram_id is not None and not isinstance(telegram_id, int):
|
||||
telegram_id = None
|
||||
|
||||
session_local = get_session_local()
|
||||
session = session_local()
|
||||
try:
|
||||
exists = session.scalar(
|
||||
select(DsmrReading.id).where(
|
||||
DsmrReading.meter_source_id == snapshot.source_id,
|
||||
DsmrReading.recorded_at == ts_utc,
|
||||
)
|
||||
)
|
||||
session.add(reading)
|
||||
session.commit()
|
||||
logger.debug(
|
||||
"dsmr_ingest: persisted reading recorded_at=%s source_id=%s.",
|
||||
ts_utc.isoformat(),
|
||||
source_id,
|
||||
)
|
||||
if exists is None:
|
||||
session.add(
|
||||
DsmrReading(
|
||||
meter_source_id=snapshot.source_id,
|
||||
recorded_at=ts_utc,
|
||||
telegram_id=telegram_id,
|
||||
payload=data,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
except sqlalchemy.exc.IntegrityError:
|
||||
# Race / duplicate: another insert beat us to the same recorded_at.
|
||||
session.rollback()
|
||||
logger.debug(
|
||||
"dsmr_ingest: IntegrityError for recorded_at=%s (duplicate, skipped).",
|
||||
ts_utc.isoformat(),
|
||||
)
|
||||
except Exception:
|
||||
session.rollback()
|
||||
logger.exception("dsmr_ingest: DB error (swallowed).")
|
||||
logger.exception("DSMR database write failed for source_id=%s", snapshot.source_id)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
Reference in New Issue
Block a user