M8-T17: add Home Assistant thermal entities
This commit is contained in:
+332
-1
@@ -27,7 +27,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, Callable, Optional, Protocol
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -74,6 +74,17 @@ class DeviceInfo:
|
||||
their state is being published.
|
||||
"""
|
||||
|
||||
availability_id: Optional[str] = None
|
||||
"""Stable id used for the shared availability topic, when different from
|
||||
this HA device's identity. A Meter is identified by its own UUID, while
|
||||
its liveness comes from the source/channel feeding it.
|
||||
"""
|
||||
|
||||
availability_getter: Optional[Callable[["Session"], bool]] = field(
|
||||
default=None, repr=False
|
||||
)
|
||||
"""Return whether the source behind this device is currently usable."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExposableEntity:
|
||||
@@ -877,3 +888,323 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
||||
|
||||
# Register the energy cost provider at module load time.
|
||||
register_provider(_energy_cost_provider)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M8 source / meter / thermal-cost provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SOURCE_STALE_AFTER = timedelta(minutes=5)
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
"""Small clock seam for live-value bounds and deterministic tests."""
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _as_utc(value: datetime) -> datetime:
|
||||
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
|
||||
|
||||
|
||||
def _source_is_online(source: Any, channel: Any | None = None) -> bool:
|
||||
"""Do not turn an old cumulative value into a plausible live HA state."""
|
||||
now = _utc_now()
|
||||
if not source.enabled or source.status != "online" or source.last_seen_at is None:
|
||||
return False
|
||||
source_age = now - _as_utc(source.last_seen_at)
|
||||
if not timedelta(0) <= source_age <= _SOURCE_STALE_AFTER:
|
||||
return False
|
||||
if channel is None:
|
||||
return True
|
||||
if channel.latest_at is None or channel.latest_quality not in {"valid", "unverifiable"}:
|
||||
return False
|
||||
channel_age = now - _as_utc(channel.latest_at)
|
||||
return timedelta(0) <= channel_age <= _SOURCE_STALE_AFTER
|
||||
|
||||
|
||||
def _dsmr_latest(session: Session, source_id: int, *, start: datetime | None = None,
|
||||
end: datetime | None = None, not_after: datetime | None = None) -> Any:
|
||||
"""Latest DSMR row in the source's (optionally bounded) cumulative domain."""
|
||||
from app.models.energy import DsmrReading
|
||||
from sqlalchemy import select
|
||||
|
||||
query = select(DsmrReading).where(DsmrReading.meter_source_id == source_id)
|
||||
if start is not None:
|
||||
query = query.where(DsmrReading.recorded_at >= start)
|
||||
if end is not None:
|
||||
query = query.where(DsmrReading.recorded_at < end)
|
||||
if not_after is not None:
|
||||
query = query.where(DsmrReading.recorded_at <= not_after)
|
||||
return session.execute(query.order_by(DsmrReading.recorded_at.desc()).limit(1)).scalar_one_or_none()
|
||||
|
||||
|
||||
def _dsmr_total(reading: Any) -> Any:
|
||||
"""Return imported electricity total from a real DSMR telegram, or None."""
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
try:
|
||||
payload = reading.payload or {}
|
||||
return Decimal(str(payload["electricity_delivered_1"])) + Decimal(
|
||||
str(payload["electricity_delivered_2"])
|
||||
)
|
||||
except (InvalidOperation, KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _m8_energy_provider(session: Session) -> list[ExposableEntity]:
|
||||
"""Expose accepted source snapshots and current M8 meters.
|
||||
|
||||
The provider intentionally reads the thermal service's public ``summarize``
|
||||
result for money. Keeping formulas in ``meter_cost`` prevents HA from
|
||||
becoming a second, subtly different billing implementation.
|
||||
"""
|
||||
from app.models.energy import Meter
|
||||
from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel
|
||||
from sqlalchemy import select
|
||||
|
||||
sources = session.execute(select(MeterSource)).scalars().all()
|
||||
entities: list[ExposableEntity] = []
|
||||
|
||||
for source in sources:
|
||||
source_info = DeviceInfo(
|
||||
identifiers=("meter-source", source.uuid), name=source.name,
|
||||
availability_id=source.uuid,
|
||||
availability_getter=lambda sess, source_id=source.id: _source_online_by_id(sess, source_id),
|
||||
)
|
||||
entities.append(ExposableEntity(
|
||||
key=f"source.{source.uuid}.online", component="binary_sensor", device=source_info,
|
||||
device_class="connectivity", unit="", name=f"{source.name} Online",
|
||||
value_getter=lambda sess, source_id=source.id: "ON" if _source_online_by_id(sess, source_id) else "OFF",
|
||||
))
|
||||
|
||||
active_meters = session.execute(
|
||||
select(Meter).where(
|
||||
Meter.ended_at.is_(None), Meter.commodity.in_(("electricity", "heating", "hot_water"))
|
||||
)
|
||||
).scalars().all()
|
||||
active_by_commodity = {meter.commodity: meter for meter in active_meters}
|
||||
for meter in active_meters:
|
||||
bound = session.execute(
|
||||
select(MeterSourceBinding, MeterSourceChannel, MeterSource)
|
||||
.join(MeterSourceChannel, MeterSourceChannel.id == MeterSourceBinding.channel_id)
|
||||
.join(MeterSource, MeterSource.id == MeterSourceChannel.source_id)
|
||||
.where(MeterSourceBinding.meter_id == meter.id, MeterSourceBinding.ended_at.is_(None))
|
||||
).one_or_none()
|
||||
if bound is None:
|
||||
continue
|
||||
binding, channel, source = bound
|
||||
info = DeviceInfo(
|
||||
identifiers=("meter", meter.uuid), name=meter.label,
|
||||
# Keep this opaque and Meter-anchored. In particular, do not use a
|
||||
# source UUID here: two channels of one source can be independently
|
||||
# stale/invalid and must not overwrite each other's availability.
|
||||
availability_id=f"meter-availability-{meter.uuid}",
|
||||
availability_getter=lambda sess, source_id=source.id, channel_id=channel.id:
|
||||
_bound_channel_online(sess, source_id, channel_id),
|
||||
)
|
||||
if meter.commodity == "electricity":
|
||||
unit, device_class = "kWh", "energy"
|
||||
elif meter.commodity == "heating":
|
||||
unit, device_class = "GJ", "energy"
|
||||
else:
|
||||
unit, device_class = "m³", "volume"
|
||||
for suffix, getter in (
|
||||
("total", _meter_total_getter(binding.id, source.id, channel.id)),
|
||||
("today", _meter_today_getter(binding.id, source.id, channel.id)),
|
||||
):
|
||||
entities.append(ExposableEntity(
|
||||
key=f"meter.{meter.uuid}.{suffix}", component="sensor", device=info,
|
||||
device_class=device_class, unit=unit,
|
||||
name=f"{meter.label} {suffix.title()}", value_getter=getter,
|
||||
state_class="total_increasing",
|
||||
))
|
||||
|
||||
heating, hot_water = active_by_commodity.get("heating"), active_by_commodity.get("hot_water")
|
||||
if heating is not None and hot_water is not None:
|
||||
identity = ".".join(sorted((heating.uuid, hot_water.uuid)))
|
||||
currency = _thermal_currency(session)
|
||||
cost_info = DeviceInfo(
|
||||
identifiers=("thermal-cost", identity), name="Thermal Energy Cost",
|
||||
provides_availability=False,
|
||||
)
|
||||
labels = {
|
||||
"heating": "Heating", "hot_water_heating": "Hot Water Heating", "water": "Water",
|
||||
"water_tax": "Water Tax", "fixed": "Fixed", "all_in": "All-in",
|
||||
}
|
||||
for suffix, window in (("total", None), ("today", "today")):
|
||||
for metric, label in labels.items():
|
||||
entities.append(ExposableEntity(
|
||||
key=f"thermal_cost.{identity}.{metric}_{suffix}", component="sensor", device=cost_info,
|
||||
device_class="monetary", unit=currency, name=f"Thermal {label} {suffix.title()}",
|
||||
value_getter=_thermal_cost_getter(metric, window),
|
||||
state_class="total" if suffix == "total" else "total_increasing",
|
||||
))
|
||||
return entities
|
||||
|
||||
|
||||
def _source_online_by_id(session: Session, source_id: int) -> bool:
|
||||
from app.models.meter_source import MeterSource
|
||||
source = session.get(MeterSource, source_id)
|
||||
if source is not None and source.kind == "dsmr_mqtt":
|
||||
now = _utc_now()
|
||||
# Inspect the actual latest telegram before calculating freshness: a
|
||||
# clock-skewed future telegram must not make an older one look live.
|
||||
latest = _dsmr_latest(session, source_id)
|
||||
if not source.enabled or latest is None:
|
||||
return False
|
||||
age = now - _as_utc(latest.recorded_at)
|
||||
return timedelta(0) <= age <= _SOURCE_STALE_AFTER
|
||||
return source is not None and _source_is_online(source)
|
||||
|
||||
|
||||
def _bound_channel_online(session: Session, source_id: int, channel_id: int) -> bool:
|
||||
from app.models.meter_source import MeterSource, MeterSourceChannel
|
||||
source, channel = session.get(MeterSource, source_id), session.get(MeterSourceChannel, channel_id)
|
||||
if source is not None and source.kind == "dsmr_mqtt":
|
||||
return _source_online_by_id(session, source_id)
|
||||
return source is not None and channel is not None and _source_is_online(source, channel)
|
||||
|
||||
|
||||
def _meter_total_getter(binding_id: int, source_id: int, channel_id: int) -> Callable[[Session], Any]:
|
||||
def _getter(session: Session) -> Any:
|
||||
from app.models.energy import Meter
|
||||
from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel
|
||||
if not _bound_channel_online(session, source_id, channel_id):
|
||||
return None
|
||||
binding = session.get(MeterSourceBinding, binding_id)
|
||||
if (
|
||||
binding is None or binding.ended_at is not None or binding.channel_id != channel_id
|
||||
or (meter := session.get(Meter, binding.meter_id)) is None or meter.ended_at is not None
|
||||
):
|
||||
return None
|
||||
source = session.get(MeterSource, source_id)
|
||||
channel = session.get(MeterSourceChannel, channel_id)
|
||||
if source is None or channel is None:
|
||||
return None
|
||||
start = max(_as_utc(meter.started_at), _as_utc(binding.started_at))
|
||||
# Both windows are half-open. The current records have no end, but
|
||||
# retaining this form makes a future close fail safely.
|
||||
end = min(
|
||||
(value for value in (_as_utc(meter.ended_at) if meter.ended_at else None,
|
||||
_as_utc(binding.ended_at) if binding.ended_at else None) if value is not None),
|
||||
default=None,
|
||||
)
|
||||
now = _utc_now()
|
||||
if source.kind == "dsmr_mqtt":
|
||||
latest = _dsmr_latest(session, source_id, start=start, end=end, not_after=now)
|
||||
if latest is None:
|
||||
return None
|
||||
return _dsmr_total(latest)
|
||||
if channel.latest_at is None or channel.latest_quality not in {"valid", "unverifiable"}:
|
||||
return None
|
||||
latest_at = _as_utc(channel.latest_at)
|
||||
if latest_at < start or latest_at > now or (end is not None and latest_at >= end):
|
||||
return None
|
||||
return channel.latest_value
|
||||
return _getter
|
||||
|
||||
|
||||
def _meter_today_getter(binding_id: int, source_id: int, channel_id: int) -> Callable[[Session], Any]:
|
||||
def _getter(session: Session) -> Any:
|
||||
from app.models.energy import Meter
|
||||
from app.models.meter_source import MeterSource, MeterSourceBinding, WarmteLinkReading
|
||||
from app.services import timezone as tz
|
||||
from sqlalchemy import select
|
||||
binding = session.get(MeterSourceBinding, binding_id)
|
||||
if binding is None or binding.ended_at is not None or binding.channel_id != channel_id:
|
||||
return None
|
||||
meter = session.get(Meter, binding.meter_id)
|
||||
if meter is None or meter.ended_at is not None:
|
||||
return None
|
||||
now = _utc_now()
|
||||
day = (tz.local_now() - _TODAY_RESET_GRACE).date()
|
||||
start = max(tz.local_midnight_utc(day), _as_utc(meter.started_at), _as_utc(binding.started_at))
|
||||
bounds = [tz.local_midnight_utc(day + timedelta(days=1))]
|
||||
bounds.extend(value for value in (
|
||||
_as_utc(meter.ended_at) if meter.ended_at else None,
|
||||
_as_utc(binding.ended_at) if binding.ended_at else None,
|
||||
) if value is not None)
|
||||
end = min(bounds)
|
||||
source = session.get(MeterSource, source_id)
|
||||
if source is None or not source.enabled:
|
||||
return None
|
||||
if source.kind != "dsmr_mqtt" and source.status != "online":
|
||||
return None
|
||||
if source.kind == "dsmr_mqtt":
|
||||
first = _dsmr_latest(session, source_id, start=start, end=end, not_after=now)
|
||||
if first is None:
|
||||
return None
|
||||
from app.models.energy import DsmrReading
|
||||
from sqlalchemy import select as dsmr_select
|
||||
rows = session.execute(dsmr_select(DsmrReading).where(
|
||||
DsmrReading.meter_source_id == source_id, DsmrReading.recorded_at >= start,
|
||||
DsmrReading.recorded_at < end,
|
||||
DsmrReading.recorded_at <= now,
|
||||
).order_by(DsmrReading.recorded_at)).scalars().all()
|
||||
if len(rows) < 2:
|
||||
return None
|
||||
first_total, last_total = _dsmr_total(rows[0]), _dsmr_total(rows[-1])
|
||||
if first_total is None or last_total is None:
|
||||
return None
|
||||
value = last_total - first_total
|
||||
return value if value >= 0 else None
|
||||
query = select(WarmteLinkReading).where(
|
||||
WarmteLinkReading.channel_id == channel_id,
|
||||
WarmteLinkReading.recorded_at >= start,
|
||||
WarmteLinkReading.quality.in_(("valid", "unverifiable")),
|
||||
)
|
||||
if end is not None:
|
||||
query = query.where(WarmteLinkReading.recorded_at < end)
|
||||
query = query.where(WarmteLinkReading.recorded_at <= now)
|
||||
readings = session.execute(query.order_by(WarmteLinkReading.recorded_at)).scalars().all()
|
||||
if len(readings) < 2:
|
||||
return None
|
||||
value = readings[-1].value - readings[0].value
|
||||
return value if value >= 0 else None
|
||||
return _getter
|
||||
|
||||
|
||||
def _thermal_currency(session: Session) -> str:
|
||||
from app.services.contracts import active_contract_versions
|
||||
versions = active_contract_versions(session, scope="thermal")
|
||||
return versions[-1].contract.currency if versions else "EUR"
|
||||
|
||||
|
||||
def _thermal_cost_getter(metric: str, window: str | None) -> Callable[[Session], Any]:
|
||||
def _getter(session: Session) -> Any:
|
||||
from app.services import timezone as tz
|
||||
from app.services.meter_cost import summarize
|
||||
now = _utc_now()
|
||||
from app.models.energy import Meter
|
||||
meters = session.query(Meter).filter(
|
||||
Meter.commodity.in_(("heating", "hot_water")), Meter.ended_at.is_(None)
|
||||
).all()
|
||||
if len(meters) != 2:
|
||||
return None
|
||||
epoch_start = max(_as_utc(m.started_at) for m in meters)
|
||||
if window == "today":
|
||||
day = (tz.local_now() - _TODAY_RESET_GRACE).date()
|
||||
start = max(tz.local_midnight_utc(day), epoch_start)
|
||||
# During the reset grace ``day`` is yesterday, whose local midnight
|
||||
# remains the cap; otherwise do not summarize readings from later today.
|
||||
end = min(tz.local_midnight_utc(day + timedelta(days=1)), now)
|
||||
else:
|
||||
# A combined thermal identity begins when its newest constituent
|
||||
# meter epoch begins; including pre-swap rows would mix identities.
|
||||
start, end = epoch_start, now
|
||||
result = summarize(session, start, end, now=now)
|
||||
if result["period_count"] == 0 and result["fixed_cost"] == 0:
|
||||
return None
|
||||
if metric == "fixed":
|
||||
return result["fixed_cost"]
|
||||
if metric == "all_in":
|
||||
return result["total_cost"]
|
||||
if metric == "water":
|
||||
return result["breakdown"]["hot_water"]
|
||||
if metric == "water_tax":
|
||||
return result["breakdown"]["hot_water_tax"]
|
||||
return result["breakdown"][metric]
|
||||
return _getter
|
||||
|
||||
|
||||
register_provider(_m8_energy_provider)
|
||||
|
||||
@@ -98,6 +98,15 @@ def _availability_topic(device_uuid: str, prefix: str) -> str:
|
||||
return f"{prefix}/modbus/{node}/availability"
|
||||
|
||||
|
||||
def _availability_id(entity: ExposableEntity) -> str:
|
||||
"""Return the identity which owns this entity's liveness topic.
|
||||
|
||||
M8 meters deliberately retain their own UUID as HA node/unique identity,
|
||||
while their availability is supplied by a MeterSource UUID.
|
||||
"""
|
||||
return entity.device.availability_id or entity.device.identifiers[1]
|
||||
|
||||
|
||||
def _unique_id(entity: ExposableEntity) -> str:
|
||||
"""Stable unique_id — device uuid + metric key (never from mutable fields)."""
|
||||
device_uuid = entity.device.identifiers[1]
|
||||
@@ -139,8 +148,7 @@ def build_discovery_payload(
|
||||
if state_prefix is None:
|
||||
state_prefix = discovery_prefix
|
||||
|
||||
device_uuid = entity.device.identifiers[1]
|
||||
avail_topic = _availability_topic(device_uuid, state_prefix)
|
||||
avail_topic = _availability_topic(_availability_id(entity), state_prefix)
|
||||
state_t = _state_topic(entity, state_prefix)
|
||||
topic = _discovery_topic(entity, discovery_prefix)
|
||||
|
||||
@@ -212,6 +220,21 @@ def publish_discovery(session: Session) -> None:
|
||||
logger.exception("publish_discovery: failed to build catalog; aborting")
|
||||
return
|
||||
|
||||
# Meter UUIDs are intentionally identity-changing epochs. Discovery config
|
||||
# is retained, so clear only the precisely enumerable old M8 identities;
|
||||
# never wildcard a provider/topic and risk removing another source's card.
|
||||
try:
|
||||
stale_entities = _stale_m8_entities(session)
|
||||
except Exception:
|
||||
logger.exception("publish_discovery: unable to enumerate old M8 identities")
|
||||
stale_entities = []
|
||||
for old_entity in stale_entities:
|
||||
try:
|
||||
old_topic, _ = build_discovery_payload(old_entity, discovery_prefix, state_prefix)
|
||||
mqtt_manager.publish(old_topic, b"", retain=True)
|
||||
except Exception:
|
||||
logger.exception("publish_discovery: unable to clear old identity %r", old_entity.key)
|
||||
|
||||
for entry in catalog:
|
||||
entity = entry.entity
|
||||
try:
|
||||
@@ -236,6 +259,62 @@ def publish_discovery(session: Session) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _stale_m8_entities(session: Session) -> list[ExposableEntity]:
|
||||
"""Return synthetic discovery entries for superseded thermal identities.
|
||||
|
||||
This is deliberately a narrow, best-effort cleanup: ended Meter UUIDs and
|
||||
historically possible thermal combinations only; current identities are excluded.
|
||||
"""
|
||||
from app.integrations.expose import DeviceInfo
|
||||
from app.models.energy import Meter
|
||||
from sqlalchemy import select
|
||||
|
||||
meters = session.execute(select(Meter).where(
|
||||
Meter.commodity.in_(("electricity", "heating", "hot_water"))
|
||||
)).scalars().all()
|
||||
current = {meter.commodity: meter for meter in meters if meter.ended_at is None}
|
||||
old = [meter for meter in meters if meter.ended_at is not None]
|
||||
entities: list[ExposableEntity] = []
|
||||
for meter in old:
|
||||
info = DeviceInfo(identifiers=("meter", meter.uuid), name=meter.label)
|
||||
for suffix in ("total", "today"):
|
||||
entities.append(ExposableEntity(
|
||||
key=f"meter.{meter.uuid}.{suffix}", component="sensor", device=info,
|
||||
device_class=None, unit="", name="obsolete",
|
||||
))
|
||||
heatings = [meter for meter in meters if meter.commodity == "heating"]
|
||||
waters = [meter for meter in meters if meter.commodity == "hot_water"]
|
||||
current_identity = (
|
||||
".".join(sorted((current["heating"].uuid, current["hot_water"].uuid)))
|
||||
if current.get("heating") is not None and current.get("hot_water") is not None else None
|
||||
)
|
||||
for heating in heatings:
|
||||
for water in waters:
|
||||
if heating.ended_at is None and water.ended_at is None:
|
||||
continue
|
||||
# A thermal identity can only have been published when both Meter
|
||||
# epochs were current at the same instant. Do not form a Cartesian
|
||||
# product of historical records: that would tombstone identities
|
||||
# which have never existed in HA.
|
||||
heating_start, water_start = heating.started_at, water.started_at
|
||||
heating_end, water_end = heating.ended_at, water.ended_at
|
||||
if (heating_end is not None and water_start >= heating_end) or (
|
||||
water_end is not None and heating_start >= water_end
|
||||
):
|
||||
continue
|
||||
identity = ".".join(sorted((heating.uuid, water.uuid)))
|
||||
if identity == current_identity:
|
||||
continue
|
||||
info = DeviceInfo(identifiers=("thermal-cost", identity), name="obsolete")
|
||||
for metric in ("heating", "hot_water_heating", "water", "water_tax", "fixed", "all_in"):
|
||||
for suffix in ("total", "today"):
|
||||
entities.append(ExposableEntity(
|
||||
key=f"thermal_cost.{identity}.{metric}_{suffix}", component="sensor", device=info,
|
||||
device_class=None, unit="", name="obsolete",
|
||||
))
|
||||
return entities
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public: publish states
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -288,7 +367,19 @@ def _publish_entity_state(
|
||||
Also publishes the availability topic for ``binary_sensor`` "online" entities.
|
||||
"""
|
||||
state_t = _state_topic(entity, prefix)
|
||||
device_uuid = entity.device.identifiers[1]
|
||||
# Source-backed entities can have a different liveness identity from their
|
||||
# HA device identity. Publish it before the state; a None value below is
|
||||
# intentionally not converted to a synthetic zero.
|
||||
if entity.device.provides_availability and entity.device.availability_getter is not None:
|
||||
try:
|
||||
available = bool(entity.device.availability_getter(session))
|
||||
mqtt_manager.publish(
|
||||
_availability_topic(_availability_id(entity), prefix),
|
||||
"online" if available else "offline",
|
||||
retain=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("availability_getter raised for entity %r", entity.key)
|
||||
|
||||
if entity.component == "binary_sensor" and "online" in entity.key:
|
||||
# The online sensor represents device availability.
|
||||
@@ -303,7 +394,7 @@ def _publish_entity_state(
|
||||
# Default to offline when no reading is available.
|
||||
online = (raw_value == "ON")
|
||||
avail_payload = "online" if online else "offline"
|
||||
avail_topic = _availability_topic(device_uuid, prefix)
|
||||
avail_topic = _availability_topic(_availability_id(entity), prefix)
|
||||
mqtt_manager.publish(avail_topic, avail_payload, retain=False)
|
||||
# The state of the binary_sensor itself
|
||||
state_payload = "ON" if online else "OFF"
|
||||
|
||||
Reference in New Issue
Block a user