Files
HomeAssistantVS/custom_components/maintenance_supporter/sensor.py
T
2026-07-14 23:57:03 -04:00

745 lines
30 KiB
Python

"""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))
# Spare parts: how many parts across all objects need reordering.
entities.append(PartsToReorderSensor(hass))
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)
# Spare parts: one stock sensor per part on the object device (catalog-only
# parts without a tracked count read unavailable).
from .const import CONF_PARTS
entities.extend(PartStockSensor(coordinator, part_id) for part_id in entry.data.get(CONF_PARTS) or {})
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:
# Tolerate "HH:MM" and "HH:MM:SS" (HA TimeSelector's format).
parts = str(schedule_time).split(":")
at = time(int(parts[0]), int(parts[1]))
except (ValueError, TypeError, IndexError):
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 PartStockSensor(MaintenanceEntity, SensorEntity):
"""On-hand spare count for one part, on the object's device.
State = the tracked stock (``unavailable`` for catalog-only parts without a
tracked count); threshold/unit/location ride along as attributes. Refreshes
off the ``SIGNAL_PARTS_UPDATED`` dispatcher signal fired by parts_runtime
after every stock change — no polling, no recorder churn beyond real
stock movements.
"""
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_icon = "mdi:package-variant-closed"
def __init__(self, coordinator: MaintenanceCoordinator, part_id: str) -> None:
"""Initialize the stock sensor for one part."""
super().__init__(coordinator, part_id)
self._part_id = part_id
obj_data = coordinator.entry.data.get(CONF_OBJECT, {})
object_slug = slugify_object_name(obj_data.get("name", "unknown"))
self._attr_unique_id = f"maintenance_supporter_{object_slug}_part_{part_id}"
part = self._part
self._attr_name = f"{part.get('name', 'Part')} stock"
@property
def _part(self) -> dict[str, Any]:
from .const import CONF_PARTS
parts = self.coordinator.entry.data.get(CONF_PARTS) or {}
result: dict[str, Any] = parts.get(self._part_id, {})
return result
async def async_added_to_hass(self) -> None:
"""Subscribe to stock-change notifications."""
await super().async_added_to_hass()
from .parts_runtime import SIGNAL_PARTS_UPDATED
self.async_on_remove(
async_dispatcher_connect(self.hass, SIGNAL_PARTS_UPDATED, self._handle_parts_update)
)
@callback
def _handle_parts_update(self, entry_id: str) -> None:
if entry_id == self.coordinator.entry.entry_id:
self.async_write_ha_state()
@property
def _stock(self) -> int | None:
rd = getattr(self.coordinator.entry, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None
return store.get_part_stock(self._part_id) if store else None
@property
def available(self) -> bool:
"""Catalog-only parts (no tracked stock) read unavailable."""
return self._part_id in (self.coordinator.entry.data.get("parts") or {}) and self._stock is not None
@property
def native_value(self) -> int | None:
return self._stock
@property
def native_unit_of_measurement(self) -> str | None:
return self._part.get("unit") or None
@property
def extra_state_attributes(self) -> dict[str, Any]:
from .helpers.parts import part_is_low
part = self._part
return {
"part_name": part.get("name"),
"reorder_threshold": part.get("reorder_threshold"),
"storage_location": part.get("storage_location") or None,
"is_low": part_is_low(part, self._stock),
}
class PartsToReorderSensor(SensorEntity):
"""How many spare parts across all objects are at/below their threshold.
Lives on the global hub device next to the summary sensors; registry-stable
unique_id (#86 lesson). Dispatcher-driven off SIGNAL_PARTS_UPDATED.
"""
_attr_has_entity_name = True
_attr_translation_key = "parts_to_reorder"
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_icon = "mdi:cart-arrow-down"
_attr_native_unit_of_measurement = "parts"
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the global reorder-count sensor."""
self.hass = hass
self._attr_unique_id = f"{GLOBAL_UNIQUE_ID}_parts_to_reorder"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, GLOBAL_UNIQUE_ID)},
name="Maintenance Supporter",
entry_type=DeviceEntryType.SERVICE,
)
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT,
"maintenance_supporter_parts_to_reorder",
hass=hass,
)
async def async_added_to_hass(self) -> None:
"""Subscribe to stock-change notifications (any entry)."""
await super().async_added_to_hass()
from .parts_runtime import SIGNAL_PARTS_UPDATED
self.async_on_remove(
async_dispatcher_connect(self.hass, SIGNAL_PARTS_UPDATED, self._handle_parts_update)
)
@callback
def _handle_parts_update(self, _entry_id: str) -> None:
self.async_write_ha_state()
@property
def native_value(self) -> int:
from .const import CONF_PARTS
from .helpers.parts import part_is_low
count = 0
for entry in self.hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
continue
rd = getattr(entry, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None
if store is None:
continue
for part in (entry.data.get(CONF_PARTS) or {}).values():
if part_is_low(part, store.get_part_stock(part["id"])):
count += 1
return count
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"],
}