New apps Added
This commit is contained in:
@@ -0,0 +1,599 @@
|
||||
"""Sensor platform for the Maintenance Supporter integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime, time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
ENTITY_ID_FORMAT,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfInformation
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import async_generate_entity_id
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
CONF_ADVANCED_SCHEDULE_TIME,
|
||||
CONF_OBJECT,
|
||||
CONF_TASKS,
|
||||
DEFAULT_ENTITY_LOGIC,
|
||||
DOMAIN,
|
||||
GLOBAL_UNIQUE_ID,
|
||||
SIGNAL_DOCUMENTS_UPDATED,
|
||||
SIGNAL_TASK_RESET,
|
||||
MaintenanceStatus,
|
||||
slugify_object_name,
|
||||
)
|
||||
from .coordinator import MaintenanceCoordinator
|
||||
from .entity.entity_base import MaintenanceEntity
|
||||
from .entity.summary_coordinator import MaintenanceSummaryCoordinator
|
||||
from .entity.triggers import BaseTrigger, create_triggers, normalize_entity_ids
|
||||
from .helpers.schedule import read_legacy_fields
|
||||
from .helpers.status import compute_status_from_task_dict
|
||||
|
||||
# (metric key in compute_status_counts, mdi icon) for the global summary sensors
|
||||
SUMMARY_METRICS: list[tuple[str, str]] = [
|
||||
("overdue", "mdi:alert-circle"),
|
||||
("due_soon", "mdi:clock-alert-outline"),
|
||||
("triggered", "mdi:flash"),
|
||||
("needs_attention", "mdi:wrench-clock"),
|
||||
("ok", "mdi:check-circle"),
|
||||
("total_tasks", "mdi:format-list-checks"),
|
||||
]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import MaintenanceSupporterConfigEntry
|
||||
from .helpers.documents import DocumentStore
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MaintenanceSupporterConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up sensor entities for a maintenance object."""
|
||||
# Global entry: expose the aggregate summary sensors instead of per-task ones.
|
||||
if entry.unique_id == GLOBAL_UNIQUE_ID:
|
||||
from . import DOCUMENT_STORE_KEY
|
||||
|
||||
runtime_data = entry.runtime_data
|
||||
summary = runtime_data.summary_coordinator if runtime_data else None
|
||||
entities: list[SensorEntity] = []
|
||||
if summary is not None:
|
||||
entities.extend(MaintenanceSummarySensor(summary, key, icon) for key, icon in SUMMARY_METRICS)
|
||||
# One global storage sensor (total blob bytes = real backup cost).
|
||||
doc_store = hass.data[DOMAIN][DOCUMENT_STORE_KEY]
|
||||
entities.append(DocumentStorageSensor(hass, doc_store))
|
||||
async_add_entities(entities)
|
||||
return
|
||||
|
||||
runtime_data = entry.runtime_data
|
||||
if runtime_data is None or runtime_data.coordinator is None:
|
||||
_LOGGER.error("No coordinator found for entry %s", entry.entry_id)
|
||||
return
|
||||
|
||||
coordinator = runtime_data.coordinator
|
||||
tasks = entry.data.get(CONF_TASKS, {})
|
||||
|
||||
entities = [MaintenanceSensor(coordinator, task_id) for task_id in tasks]
|
||||
# Companion timestamp sensor per task (disabled by default): the raw
|
||||
# next-due instant for tile/entities cards with the relative time-format
|
||||
# display options ("in 2 days"), template automations, etc.
|
||||
entities.extend(MaintenanceNextDueSensor(coordinator, task_id) for task_id in tasks)
|
||||
|
||||
async_add_entities(entities)
|
||||
_LOGGER.debug(
|
||||
"Added %d sensor entities for %s",
|
||||
len(entities),
|
||||
entry.title,
|
||||
)
|
||||
|
||||
|
||||
class MaintenanceSensor(MaintenanceEntity, SensorEntity):
|
||||
"""Sensor entity representing a single maintenance task."""
|
||||
|
||||
_attr_translation_key = "maintenance_task"
|
||||
_attr_device_class = SensorDeviceClass.ENUM
|
||||
_attr_options = [
|
||||
MaintenanceStatus.OK,
|
||||
MaintenanceStatus.DUE_SOON,
|
||||
MaintenanceStatus.OVERDUE,
|
||||
MaintenanceStatus.TRIGGERED,
|
||||
MaintenanceStatus.ARCHIVED,
|
||||
MaintenanceStatus.PAUSED,
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MaintenanceCoordinator,
|
||||
task_id: str,
|
||||
) -> None:
|
||||
"""Initialize the maintenance sensor."""
|
||||
super().__init__(coordinator, task_id)
|
||||
|
||||
obj_data = coordinator.entry.data.get(CONF_OBJECT, {})
|
||||
task_data = coordinator.entry.data.get(CONF_TASKS, {}).get(task_id, {})
|
||||
|
||||
object_slug = slugify_object_name(obj_data.get("name", "unknown"))
|
||||
self._attr_unique_id = f"maintenance_supporter_{object_slug}_{task_id}"
|
||||
|
||||
# Use custom entity_slug as the friendly name if provided
|
||||
entity_slug = task_data.get("entity_slug")
|
||||
if entity_slug:
|
||||
self._attr_name = entity_slug
|
||||
self._attr_translation_placeholders = {"task_name": task_data.get("name", "")}
|
||||
|
||||
# Instance-level mutable state (class attrs are just defaults)
|
||||
self._triggers: list[BaseTrigger] = []
|
||||
self._trigger_states: dict[str, bool] = {}
|
||||
self._trigger_values: dict[str, Any] = {}
|
||||
|
||||
@property
|
||||
def _trigger(self) -> BaseTrigger | None:
|
||||
"""Backwards-compatible access to the first trigger (or None)."""
|
||||
return self._triggers[0] if self._triggers else None
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the current status of the task."""
|
||||
task = self._task_data
|
||||
if not task:
|
||||
return None
|
||||
return str(task.get("_status", MaintenanceStatus.OK))
|
||||
|
||||
@property
|
||||
def icon(self) -> str | None:
|
||||
"""Return custom icon if configured, else fall back to icons.json."""
|
||||
task = self._task_data
|
||||
if task:
|
||||
custom: str | None = task.get("custom_icon")
|
||||
if custom:
|
||||
return custom
|
||||
return None
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available.
|
||||
|
||||
The sensor stays available even when the trigger entity is
|
||||
unavailable or missing. Trigger health is exposed as the
|
||||
``trigger_entity_state`` attribute instead of making the whole
|
||||
maintenance sensor disappear from dashboards.
|
||||
"""
|
||||
return super().available
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return extended attributes for the sensor.
|
||||
|
||||
Only stable attributes are exposed here to avoid excessive recorder
|
||||
writes. Fast-changing trigger values (current_value,
|
||||
accumulated_hours, change_count, etc.) are served via the WebSocket
|
||||
``subscribe`` endpoint instead.
|
||||
"""
|
||||
task = self._task_data
|
||||
if not task:
|
||||
return {}
|
||||
|
||||
sched = read_legacy_fields(task)
|
||||
attrs: dict[str, Any] = {
|
||||
"maintenance_type": task.get("type"),
|
||||
"schedule_type": sched["schedule_type"],
|
||||
"interval_days": sched["interval_days"],
|
||||
"interval_unit": sched["interval_unit"],
|
||||
"interval_anchor": sched["interval_anchor"],
|
||||
"due_date": sched["due_date"],
|
||||
"warning_days": task.get("warning_days"),
|
||||
"last_performed": task.get("last_performed"),
|
||||
"next_due": task.get("_next_due"),
|
||||
"days_until_due": task.get("_days_until_due"),
|
||||
"parent_object": self._object_data.get("name"),
|
||||
"times_performed": task.get("_times_performed", 0),
|
||||
"total_cost": task.get("_total_cost", 0.0),
|
||||
"average_duration": task.get("_average_duration"),
|
||||
# notes + documentation_url are user-entered reference data (a short
|
||||
# note and a manual link), exposed here so dashboards/templates can
|
||||
# read them via state_attr(...). They are not secrets and are also
|
||||
# served via the WS object read. (v2.8.3 briefly removed these for
|
||||
# data-minimisation; restored in v2.8.4 — the manual link in
|
||||
# particular is exactly what belongs on the entity.)
|
||||
"notes": task.get("notes"),
|
||||
"documentation_url": task.get("documentation_url"),
|
||||
}
|
||||
|
||||
# Trigger attributes (static config only — no fast-changing values)
|
||||
trigger_config = task.get("trigger_config")
|
||||
if trigger_config:
|
||||
ttype = trigger_config.get("type")
|
||||
attrs["trigger_type"] = ttype
|
||||
attrs["trigger_active"] = task.get("_trigger_active", False)
|
||||
|
||||
if ttype == "threshold":
|
||||
attrs["trigger_above"] = trigger_config.get("trigger_above")
|
||||
attrs["trigger_below"] = trigger_config.get("trigger_below")
|
||||
attrs["trigger_for_minutes"] = trigger_config.get("trigger_for_minutes")
|
||||
elif ttype == "counter":
|
||||
attrs["trigger_target_value"] = trigger_config.get("trigger_target_value")
|
||||
attrs["trigger_delta_mode"] = trigger_config.get("trigger_delta_mode")
|
||||
elif ttype == "state_change":
|
||||
attrs["trigger_from_state"] = trigger_config.get("trigger_from_state")
|
||||
attrs["trigger_to_state"] = trigger_config.get("trigger_to_state")
|
||||
attrs["trigger_target_changes"] = trigger_config.get("trigger_target_changes")
|
||||
elif ttype == "runtime":
|
||||
attrs["trigger_runtime_hours"] = trigger_config.get("trigger_runtime_hours")
|
||||
elif ttype == "compound":
|
||||
attrs["compound_logic"] = trigger_config.get("compound_logic", "AND")
|
||||
conditions = trigger_config.get("conditions", [])
|
||||
attrs["compound_conditions_count"] = len(conditions)
|
||||
|
||||
# Adaptive scheduling attributes
|
||||
if task.get("_suggested_interval") is not None:
|
||||
attrs["suggested_interval"] = task.get("_suggested_interval")
|
||||
attrs["interval_confidence"] = task.get("_interval_confidence")
|
||||
adaptive_cfg = task.get("adaptive_config")
|
||||
if adaptive_cfg:
|
||||
attrs["adaptive_scheduling_enabled"] = adaptive_cfg.get("enabled", False)
|
||||
|
||||
# Seasonal scheduling attributes
|
||||
analysis = task.get("_interval_analysis")
|
||||
if analysis and analysis.get("seasonal_factor") is not None:
|
||||
attrs["seasonal_factor"] = analysis["seasonal_factor"]
|
||||
attrs["seasonal_reason"] = analysis.get("seasonal_reason")
|
||||
|
||||
# Weibull advanced statistics
|
||||
if analysis and analysis.get("weibull_beta") is not None:
|
||||
attrs["weibull_beta"] = analysis["weibull_beta"]
|
||||
attrs["weibull_eta"] = analysis.get("weibull_eta")
|
||||
attrs["weibull_r_squared"] = analysis.get("weibull_r_squared")
|
||||
beta = analysis["weibull_beta"]
|
||||
if beta < 0.8:
|
||||
attrs["weibull_beta_interpretation"] = "early_failures"
|
||||
elif beta <= 1.2:
|
||||
attrs["weibull_beta_interpretation"] = "random_failures"
|
||||
elif beta <= 3.5:
|
||||
attrs["weibull_beta_interpretation"] = "wear_out"
|
||||
else:
|
||||
attrs["weibull_beta_interpretation"] = "highly_predictable"
|
||||
if analysis and analysis.get("confidence_interval_low") is not None:
|
||||
attrs["confidence_interval_low"] = analysis["confidence_interval_low"]
|
||||
attrs["confidence_interval_high"] = analysis.get("confidence_interval_high")
|
||||
|
||||
return attrs
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass, set up triggers if configured."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
# Use merged data (static config + Store runtime) so that persisted
|
||||
# trigger state (_trigger_state) survives HA restarts.
|
||||
static_task = self.coordinator.entry.data.get(CONF_TASKS, {}).get(self._task_id, {})
|
||||
store = self.coordinator._store
|
||||
if store is not None:
|
||||
task_data = store.merge_task_data(self._task_id, static_task)
|
||||
else:
|
||||
task_data = static_task
|
||||
trigger_config = task_data.get("trigger_config")
|
||||
|
||||
# Listen for task reset signals (completion/skip/reset) —
|
||||
# registered for ALL tasks (not just trigger-based) so that
|
||||
# status updates propagate immediately to the UI.
|
||||
signal = SIGNAL_TASK_RESET.format(entry_id=self.coordinator.entry.entry_id, task_id=self._task_id)
|
||||
self.async_on_remove(async_dispatcher_connect(self.hass, signal, self._handle_task_reset))
|
||||
|
||||
# v2.10.0: an archived task is inert — keep the status listener above
|
||||
# (so unarchive repaints immediately) but never wire up its triggers.
|
||||
if task_data.get("archived_at") is not None:
|
||||
return
|
||||
|
||||
# v2.20 (N3): same for a seasonally paused object — the pause/resume
|
||||
# WS commands reload the entry, so triggers wire up again on resume.
|
||||
obj_data = self.coordinator.entry.data.get(CONF_OBJECT, {})
|
||||
if obj_data.get("paused_at") is not None:
|
||||
return
|
||||
|
||||
if not trigger_config:
|
||||
return
|
||||
|
||||
# Compound triggers have entity_ids inside conditions, not at top level
|
||||
is_compound = trigger_config.get("type") == "compound"
|
||||
entity_ids = normalize_entity_ids(trigger_config)
|
||||
if not entity_ids and not is_compound:
|
||||
return
|
||||
|
||||
try:
|
||||
self._triggers = create_triggers(
|
||||
hass=self.hass,
|
||||
entity=self,
|
||||
trigger_config=trigger_config,
|
||||
)
|
||||
for trigger in self._triggers:
|
||||
await trigger.async_setup()
|
||||
_LOGGER.debug(
|
||||
"Trigger setup for %s monitoring %s",
|
||||
self.entity_id,
|
||||
entity_ids if not is_compound else "[compound]",
|
||||
)
|
||||
except (HomeAssistantError, ValueError, TypeError, KeyError):
|
||||
_LOGGER.exception("Failed to set up triggers for %s", self.entity_id)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""When entity is removed, clean up triggers."""
|
||||
for trigger in self._triggers:
|
||||
await trigger.async_teardown()
|
||||
self._triggers = []
|
||||
self._trigger_states = {}
|
||||
self._trigger_values = {}
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
@callback
|
||||
def _handle_task_reset(self) -> None:
|
||||
"""Reset all trigger instances after task completion/skip/reset."""
|
||||
for trigger in self._triggers:
|
||||
trigger.reset()
|
||||
self._trigger_states = {}
|
||||
self._trigger_values = {}
|
||||
|
||||
if self.coordinator.data is not None:
|
||||
tasks = self.coordinator.data.get(CONF_TASKS, {})
|
||||
task = tasks.get(self._task_id, {})
|
||||
if task:
|
||||
task["_trigger_active"] = False
|
||||
task["_trigger_current_value"] = None
|
||||
_old_status = task.get("_status")
|
||||
new_status = self._compute_live_status(task)
|
||||
task["_status"] = new_status
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def async_update_trigger_state(
|
||||
self,
|
||||
is_triggered: bool,
|
||||
current_value: float | None = None,
|
||||
trigger_entity_id: str | None = None,
|
||||
) -> None:
|
||||
"""Update trigger state from trigger callback.
|
||||
|
||||
For multi-entity triggers, aggregates per-entity states using the
|
||||
configured entity_logic ("any" or "all").
|
||||
"""
|
||||
if self.coordinator.data is None:
|
||||
return
|
||||
|
||||
tasks = self.coordinator.data.get(CONF_TASKS, {})
|
||||
task = tasks.get(self._task_id, {})
|
||||
|
||||
# Track per-entity state
|
||||
if trigger_entity_id is not None:
|
||||
self._trigger_states[trigger_entity_id] = is_triggered
|
||||
if current_value is not None:
|
||||
self._trigger_values[trigger_entity_id] = current_value
|
||||
|
||||
# Aggregate trigger states
|
||||
if len(self._triggers) > 1:
|
||||
trigger_config = self.coordinator.entry.data.get(CONF_TASKS, {}).get(self._task_id, {}).get("trigger_config", {})
|
||||
entity_logic = trigger_config.get("entity_logic", DEFAULT_ENTITY_LOGIC)
|
||||
|
||||
if entity_logic == "all":
|
||||
aggregated = bool(self._trigger_states) and all(self._trigger_states.values())
|
||||
else: # "any"
|
||||
aggregated = any(self._trigger_states.values())
|
||||
|
||||
task["_trigger_active"] = aggregated
|
||||
else:
|
||||
# Single trigger: direct assignment
|
||||
task["_trigger_active"] = is_triggered
|
||||
|
||||
if current_value is not None:
|
||||
task["_trigger_current_value"] = current_value
|
||||
|
||||
# Recompute _status immediately so native_value reflects the change
|
||||
old_status = task.get("_status")
|
||||
new_status = self._compute_live_status(task)
|
||||
task["_status"] = new_status
|
||||
|
||||
# Only write HA state when status actually changes to avoid
|
||||
# unnecessary recorder writes on every trigger value update.
|
||||
if new_status != old_status:
|
||||
self.async_write_ha_state()
|
||||
|
||||
@staticmethod
|
||||
def _compute_live_status(task: dict[str, Any]) -> str:
|
||||
"""Compute task status from coordinator data dict (mirrors MaintenanceTask.status)."""
|
||||
return compute_status_from_task_dict(task)
|
||||
|
||||
|
||||
class MaintenanceNextDueSensor(MaintenanceEntity, SensorEntity):
|
||||
"""Timestamp sensor for a task's next due instant.
|
||||
|
||||
Companion to the per-task status sensor, `disabled by default
|
||||
<https://developers.home-assistant.io/docs/core/entity/#registry-properties>`_
|
||||
so the extra entity costs nothing unless the user wants it. Once enabled
|
||||
it powers HA's relative time-format display options ("in 2 days") on
|
||||
tile/entities cards and plain timestamp automations.
|
||||
|
||||
The instant is the task's next-due date at local midnight — or at the
|
||||
task's ``schedule_time`` when the time-of-day feature is enabled,
|
||||
mirroring the calendar entity's timed events.
|
||||
"""
|
||||
|
||||
_attr_translation_key = "next_due"
|
||||
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
||||
_attr_entity_registry_enabled_default = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MaintenanceCoordinator,
|
||||
task_id: str,
|
||||
) -> None:
|
||||
"""Initialize the next-due sensor."""
|
||||
super().__init__(coordinator, task_id)
|
||||
obj_data = coordinator.entry.data.get(CONF_OBJECT, {})
|
||||
task_data = coordinator.entry.data.get(CONF_TASKS, {}).get(task_id, {})
|
||||
object_slug = slugify_object_name(obj_data.get("name", "unknown"))
|
||||
self._attr_unique_id = f"maintenance_supporter_{object_slug}_{task_id}_next_due"
|
||||
self._attr_translation_placeholders = {"task_name": task_data.get("name", "")}
|
||||
|
||||
@property
|
||||
def native_value(self) -> datetime | None:
|
||||
"""The next due instant, or None (manual / archived / done tasks)."""
|
||||
task = self._task_data
|
||||
if not task:
|
||||
return None
|
||||
if task.get("_status") == MaintenanceStatus.ARCHIVED:
|
||||
return None
|
||||
raw = task.get("_next_due")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
due = date.fromisoformat(raw)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
at = time(0, 0)
|
||||
schedule_time = task.get("schedule_time")
|
||||
if schedule_time and self._schedule_time_enabled():
|
||||
try:
|
||||
hh, mm = str(schedule_time).split(":", 1)
|
||||
at = time(int(hh), int(mm))
|
||||
except (ValueError, TypeError):
|
||||
at = time(0, 0) # malformed -> midnight, like the calendar
|
||||
return datetime.combine(due, at, tzinfo=dt_util.DEFAULT_TIME_ZONE)
|
||||
|
||||
def _schedule_time_enabled(self) -> bool:
|
||||
"""Global advanced flag — same lookup as the calendar entity."""
|
||||
for ce in self.coordinator.hass.config_entries.async_entries(DOMAIN):
|
||||
if ce.unique_id == GLOBAL_UNIQUE_ID:
|
||||
opts = ce.options or ce.data
|
||||
return bool(opts.get(CONF_ADVANCED_SCHEDULE_TIME, False))
|
||||
return False
|
||||
|
||||
|
||||
class MaintenanceSummarySensor(CoordinatorEntity[MaintenanceSummaryCoordinator], SensorEntity):
|
||||
"""Aggregate count sensor on the global Maintenance Supporter hub device.
|
||||
|
||||
Counts come from the shared summary coordinator (which uses the same
|
||||
``compute_status_counts`` aggregator as the statistics WebSocket endpoint),
|
||||
so these entities, the panel KPI chips, and the dashboard-strategy headline
|
||||
always agree.
|
||||
"""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_native_unit_of_measurement = "tasks"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MaintenanceSummaryCoordinator,
|
||||
key: str,
|
||||
icon: str,
|
||||
) -> None:
|
||||
"""Initialize a summary sensor for one metric key."""
|
||||
super().__init__(coordinator)
|
||||
self._key = key
|
||||
self._attr_translation_key = f"summary_{key}"
|
||||
self._attr_unique_id = f"{GLOBAL_UNIQUE_ID}_summary_{key}"
|
||||
self._attr_icon = icon
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, GLOBAL_UNIQUE_ID)},
|
||||
name="Maintenance Supporter",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
# Pin a stable, language-independent entity_id. With has_entity_name the
|
||||
# slug would otherwise derive from the translated name, so a non-English
|
||||
# system language would yield e.g. sensor.maintenance_supporter_überfällig
|
||||
# and break the documented IDs. The friendly name stays localized.
|
||||
self.entity_id = async_generate_entity_id(
|
||||
ENTITY_ID_FORMAT,
|
||||
f"maintenance_supporter_{key}",
|
||||
hass=coordinator.hass,
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int:
|
||||
"""Return the current count for this metric."""
|
||||
data = self.coordinator.data or {}
|
||||
return int(data.get(self._key, 0))
|
||||
|
||||
|
||||
class DocumentStorageSensor(SensorEntity):
|
||||
"""Total on-disk size of stored document blobs — the real per-backup cost.
|
||||
|
||||
The state is the **physical** footprint (unique, deduped blobs); the dedup
|
||||
saving and per-category/logical breakdown ride along as attributes. It lives
|
||||
on the global "Maintenance Supporter" service device next to the summary
|
||||
sensors and refreshes off the ``SIGNAL_DOCUMENTS_UPDATED`` dispatcher signal
|
||||
(fired by the DocumentStore after every change) rather than polling.
|
||||
"""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "document_storage"
|
||||
_attr_device_class = SensorDeviceClass.DATA_SIZE
|
||||
_attr_native_unit_of_measurement = UnitOfInformation.BYTES
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_suggested_display_precision = 1
|
||||
_attr_icon = "mdi:file-cabinet"
|
||||
|
||||
def __init__(self, hass: HomeAssistant, store: DocumentStore) -> None:
|
||||
"""Initialize the global document-storage sensor."""
|
||||
self.hass = hass
|
||||
self._store = store
|
||||
self._attr_unique_id = f"{GLOBAL_UNIQUE_ID}_document_storage"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, GLOBAL_UNIQUE_ID)},
|
||||
name="Maintenance Supporter",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
# Stable, language-independent entity_id (has_entity_name would otherwise
|
||||
# derive the slug from the translated friendly name).
|
||||
self.entity_id = async_generate_entity_id(
|
||||
ENTITY_ID_FORMAT,
|
||||
"maintenance_supporter_document_storage",
|
||||
hass=hass,
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to document-change notifications."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(async_dispatcher_connect(self.hass, SIGNAL_DOCUMENTS_UPDATED, self._handle_update))
|
||||
|
||||
@callback
|
||||
def _handle_update(self) -> None:
|
||||
"""Refresh state after a document mutation."""
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def native_value(self) -> int:
|
||||
"""Physical footprint of all stored blobs, in bytes."""
|
||||
return int(self._store.storage_summary()["total_bytes"])
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Dedup saving + logical/per-category breakdown.
|
||||
|
||||
Per-object drill-down is intentionally left to the ``documents/storage``
|
||||
WS command (id-keyed and potentially large — not recorder-friendly).
|
||||
"""
|
||||
summ = self._store.storage_summary()
|
||||
return {
|
||||
"logical_bytes": summ["logical_bytes"],
|
||||
"dedup_savings_bytes": summ["dedup_savings_bytes"],
|
||||
"blob_count": summ["blob_count"],
|
||||
"file_count": summ["file_count"],
|
||||
"link_count": summ["link_count"],
|
||||
"document_count": summ["document_count"],
|
||||
"by_category": summ["by_category"],
|
||||
}
|
||||
Reference in New Issue
Block a user