Files
HomeAssistantVS/custom_components/maintenance_supporter/sensor.py
T
2026-07-20 22:52:35 -04:00

806 lines
32 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.battery_fleet import BatteryOverview
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))
# Battery fleet: how many batteries (across all Battery Notes devices)
# need replacing now — the aggregate that backs the single fleet task.
entities.append(BatteryFleetLowSensor(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 BatteryFleetLowSensor(SensorEntity):
"""How many batteries across all Battery Notes devices are low right now.
Aggregate over the fleet (not one sensor per battery). Its state is the
threshold source for the single "Replace low batteries" fleet task; the
grouped shopping needs + forecast ride along as attributes. Lives on the
global hub device; refreshes off the Battery Notes lifecycle events.
"""
_attr_has_entity_name = True
_attr_translation_key = "batteries_to_replace"
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_icon = "mdi:battery-alert"
_attr_native_unit_of_measurement = "batteries"
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the global battery-fleet low-count sensor."""
self.hass = hass
self._attr_unique_id = f"{GLOBAL_UNIQUE_ID}_batteries_to_replace"
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_batteries_to_replace", hass=hass)
async def async_added_to_hass(self) -> None:
"""Refresh on Battery Notes threshold/replaced/increased events."""
await super().async_added_to_hass()
for event in (
"battery_notes_battery_threshold",
"battery_notes_battery_replaced",
"battery_notes_battery_increased",
):
self.async_on_remove(self.hass.bus.async_listen(event, self._handle_event))
@callback
def _handle_event(self, _event: Any) -> None:
self.async_write_ha_state()
def _overview(self) -> BatteryOverview:
from .helpers.battery_fleet import compute_overview
return compute_overview(self.hass)
@property
def native_value(self) -> int:
return self._overview().low_count
@property
def extra_state_attributes(self) -> dict[str, Any]:
ov = self._overview()
return {
"total_batteries": ov.total,
"soon_count": len(ov.soon),
"needs_now": dict(ov.needs_now),
"needs_soon": dict(ov.needs_soon),
"battery_types": ov.types,
}
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"],
}